From 6023f7242c40fa9c670eccb174f249df85325af3 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 21 Mar 2026 15:05:23 +0100 Subject: [PATCH 001/252] feat: implement skillet configuration tool with containerized integration tests - Added skillet_core with idempotent file and system primitives. - Implemented skillet_hardening with sysctl configuration. - Added skillet-beezelbot host binary. - Implemented Podman-based integration testing and recording. - Updated AGENTS.md with quality control mandates. --- ublue/skillet/.gitignore | 1 + ublue/skillet/AGENTS.md | 61 ++ ublue/skillet/Cargo.lock | 862 ++++++++++++++++++ ublue/skillet/Cargo.toml | 23 + ublue/skillet/crates/cli/Cargo.toml | 16 + ublue/skillet/crates/cli/src/main.rs | 263 ++++++ ublue/skillet/crates/core/Cargo.toml | 17 + ublue/skillet/crates/core/src/files.rs | 217 +++++ ublue/skillet/crates/core/src/files/tests.rs | 70 ++ ublue/skillet/crates/core/src/lib.rs | 4 + ublue/skillet/crates/core/src/recorder.rs | 77 ++ ublue/skillet/crates/core/src/resource_op.rs | 18 + ublue/skillet/crates/core/src/system.rs | 63 ++ ublue/skillet/crates/core/src/system/tests.rs | 39 + ublue/skillet/crates/hardening/Cargo.toml | 12 + .../crates/hardening/files/sysctl.boxy.conf | 40 + ublue/skillet/crates/hardening/src/lib.rs | 68 ++ ublue/skillet/crates/hardening/src/tests.rs | 80 ++ .../skillet/crates/hosts/beezelbot/Cargo.toml | 15 + .../crates/hosts/beezelbot/src/main.rs | 73 ++ .../recordings/beezelbot.yaml | 6 + 21 files changed, 2025 insertions(+) create mode 100644 ublue/skillet/.gitignore create mode 100644 ublue/skillet/AGENTS.md create mode 100644 ublue/skillet/Cargo.lock create mode 100644 ublue/skillet/Cargo.toml create mode 100644 ublue/skillet/crates/cli/Cargo.toml create mode 100644 ublue/skillet/crates/cli/src/main.rs create mode 100644 ublue/skillet/crates/core/Cargo.toml create mode 100644 ublue/skillet/crates/core/src/files.rs create mode 100644 ublue/skillet/crates/core/src/files/tests.rs create mode 100644 ublue/skillet/crates/core/src/lib.rs create mode 100644 ublue/skillet/crates/core/src/recorder.rs create mode 100644 ublue/skillet/crates/core/src/resource_op.rs create mode 100644 ublue/skillet/crates/core/src/system.rs create mode 100644 ublue/skillet/crates/core/src/system/tests.rs create mode 100644 ublue/skillet/crates/hardening/Cargo.toml create mode 100644 ublue/skillet/crates/hardening/files/sysctl.boxy.conf create mode 100644 ublue/skillet/crates/hardening/src/lib.rs create mode 100644 ublue/skillet/crates/hardening/src/tests.rs create mode 100644 ublue/skillet/crates/hosts/beezelbot/Cargo.toml create mode 100644 ublue/skillet/crates/hosts/beezelbot/src/main.rs create mode 100644 ublue/skillet/integration_tests/recordings/beezelbot.yaml diff --git a/ublue/skillet/.gitignore b/ublue/skillet/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/ublue/skillet/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md new file mode 100644 index 00000000..e18d8009 --- /dev/null +++ b/ublue/skillet/AGENTS.md @@ -0,0 +1,61 @@ +# Skillet Project Constraints & Structure + +This document defines the architectural mandates and project structure for `skillet`, a Rust-based idempotent host configuration tool. + +## Core Mandates + +### 1. Error Handling & Safety +- **Libraries MUST use `thiserror`** for custom error types. +- **Libraries MUST NOT use `anyhow`**. `anyhow` is reserved for the CLI binary only. +- **NEVER use `unwrap()` or `expect()`** in library code. All errors must be propagated and handled. +- **Prioritize Crates over Shell-out**: Use Rust crates (e.g., `users`, `nix`) for system interactions whenever possible instead of executing shell commands. + +### 2. Idempotency +- All resources (files, users, groups, etc.) must be **idempotent**. +- Before performing an action, check the current state (e.g., compare SHA256 hashes for files, check existence for users). +- Actions should only be taken if the system state does not match the desired state. + +### 3. Testing Strategy +- **Unit Tests**: Place unit tests in a `tests` submodule within each module's directory (e.g., `src/files/tests.rs`). +- **Separation**: Never put tests in the same `.rs` file as the implementation code. Reference them using `#[cfg(test)] #[path = "MODULE/tests.rs"] mod tests;`. +- **Abstractions**: Use Traits (e.g., `FileResource`, `SystemResource`) to allow for mocking in higher-level library tests. + +### 4. Quality Control & Validation +- **Formatting & Linting**: Always run `cargo fmt` and `cargo clippy` after making changes to ensure code quality and consistency. +- **Verification**: Always run both: + - **Unit Tests**: `cargo test` across the workspace. + - **Integration Tests**: `skillet test run ` for affected hosts to verify end-to-end correctness in a containerized environment. + +## Project Structure + +The project is organized as a Cargo workspace: + +```text +skillet/ +├── Cargo.toml # Workspace configuration +├── AGENTS.md # This file (Project mandates) +└── crates/ + ├── core/ # skillet_core: Low-level idempotent primitives + │ ├── src/ + │ │ ├── lib.rs + │ │ ├── files.rs # File management (Traits + Impl) + │ │ ├── files/ + │ │ │ └── tests.rs # Unit tests for files + │ │ ├── system.rs # User/Group management + │ │ └── system/ + │ │ └── tests.rs # Unit tests for system + │ └── tests/ # Integration tests + ├── hardening/ # skillet_hardening: Configuration logic (modules) + │ ├── src/ + │ │ ├── lib.rs # Hardening logic using core primitives + │ │ └── tests.rs # Unit tests for hardening logic + │ └── tests/ + └── cli/ # skillet: The binary executable + └── src/ + └── main.rs # CLI entry point (uses anyhow, clap) +``` + +## Module Design +- **Modules as Cookbooks**: Each library crate under `crates/` (besides `core`) represents a "module" or "cookbook" (e.g., `skillet_hardening`). +- **Binary per Host**: The idea is to have one binary per host type that picks up these modules and reuses core primitives. +- **Core Primitives**: Found in `skillet_core`, providing the building blocks for all modules. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock new file mode 100644 index 00000000..26e1d9d7 --- /dev/null +++ b/ublue/skillet/Cargo.lock @@ -0,0 +1,862 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "skillet" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "hex", + "serde", + "serde_yaml", + "skillet_core", + "skillet_hardening", + "tempfile", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "skillet-beezelbot" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "hex", + "serde", + "serde_yaml", + "skillet_core", + "skillet_hardening", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "skillet_core" +version = "0.1.0" +dependencies = [ + "hex", + "nix", + "serde", + "sha2", + "tempfile", + "thiserror", + "tracing", + "users", +] + +[[package]] +name = "skillet_hardening" +version = "0.1.0" +dependencies = [ + "skillet_core", + "tempfile", + "thiserror", + "tracing", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "users" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032" +dependencies = [ + "libc", + "log", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml new file mode 100644 index 00000000..ab1d3f99 --- /dev/null +++ b/ublue/skillet/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +resolver = "2" +members = [ + "crates/core", + "crates/hardening", + "crates/cli", + "crates/hosts/beezelbot", +] + +[workspace.dependencies] +skillet_core = { path = "crates/core" } +skillet_hardening = { path = "crates/hardening" } +thiserror = "1.0" +sha2 = "0.10" +users = "0.11" +nix = { version = "0.27", features = ["user", "fs"] } +clap = { version = "4.4", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = "0.3" +tempfile = "3.8" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +hex = "0.4" diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml new file mode 100644 index 00000000..95f02f3e --- /dev/null +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "skillet" +version = "0.1.0" +edition = "2021" + +[dependencies] +skillet_core.workspace = true +skillet_hardening.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +anyhow = "1.0" +serde.workspace = true +serde_yaml = "0.9" +hex.workspace = true +tempfile.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs new file mode 100644 index 00000000..65bbee6e --- /dev/null +++ b/ublue/skillet/crates/cli/src/main.rs @@ -0,0 +1,263 @@ +use anyhow::{anyhow, Context, Result}; +use clap::{Parser, Subcommand}; +use skillet_core::files::LocalFileResource; +use skillet_core::recorder::Recorder; +use skillet_core::resource_op::ResourceOp; +use skillet_core::system::LinuxSystemResource; +use skillet_hardening::apply; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use tracing::{error, info, Level}; +use tracing_subscriber::FmtSubscriber; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[command(subcommand)] + command: Commands, + + /// Enable verbose logging + #[arg(short, long, global = true)] + verbose: bool, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Apply configuration (Agent Mode) + Apply { + /// Optional: Output recorded actions to this file path + #[arg(long)] + record: Option, + }, + /// Manage integration tests (Runner Mode) + Test { + #[command(subcommand)] + test_command: TestCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum TestCommands { + Record { + hostname: String, + /// Container image to use + #[arg(long, default_value = "fedora:latest")] + image: String, + }, + Run { + hostname: String, + #[arg(long, default_value = "fedora:latest")] + image: String, + }, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + let subscriber = FmtSubscriber::builder() + .with_max_level(if args.verbose { + Level::DEBUG + } else { + Level::INFO + }) + .finish(); + + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + match args.command { + Commands::Apply { record } => handle_apply(record), + Commands::Test { test_command } => handle_test(test_command), + } +} + +fn handle_apply(record_path: Option) -> Result<()> { + info!("Starting Skillet configuration (Agent Mode)..."); + + let system = LinuxSystemResource::new(); + let files = LocalFileResource::new(); + + if let Some(path) = record_path { + let recorder_system = Recorder::new(system); + let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); + + apply(&recorder_system, &recorder_files).map_err(|e| anyhow!(e))?; + + let ops = recorder_system.get_ops(); + let yaml = serde_yaml::to_string(&ops)?; + fs::write(&path, yaml).context("Failed to write recording")?; + info!("Recording saved to {}", path.display()); + } else { + apply(&system, &files).map_err(|e| anyhow!(e))?; + } + + info!("Configuration applied successfully."); + Ok(()) +} + +fn handle_test(cmd: TestCommands) -> Result<()> { + match cmd { + TestCommands::Record { hostname, image } => { + info!("Recording integration test for host: {}", hostname); + run_container_test(&hostname, &image, true)?; + } + TestCommands::Run { hostname, image } => { + info!( + "Running integration test verification for host: {}", + hostname + ); + run_container_test(&hostname, &image, false)?; + } + } + Ok(()) +} + +fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<()> { + // 1. Build binary + info!("Building skillet workspace..."); + let build_status = Command::new("cargo") + .args(["build"]) + .status() + .context("Failed to run cargo build")?; + + if !build_status.success() { + return Err(anyhow!("Build failed")); + } + + // 2. Locate binary (with fallback) + let host_binary_name = format!("skillet-{}", hostname); + let target_debug = PathBuf::from("target/debug"); + + let binary_path = if target_debug.join(&host_binary_name).exists() { + info!("Found host-specific binary: {}", host_binary_name); + target_debug.join(&host_binary_name) + } else { + info!( + "Using generic skillet binary (host binary {} not found)", + host_binary_name + ); + target_debug.join("skillet") + }; + + if !binary_path.exists() { + return Err(anyhow!( + "Binary not found at {}. Make sure you run this from workspace root.", + binary_path.display() + )); + } + let abs_binary_path = fs::canonicalize(&binary_path)?; + + // 3. Start Container + let container_name = format!("skillet-test-{}", hostname); + info!( + "Starting container {} from image {}...", + container_name, image + ); + + let _ = Command::new("podman") + .args(["rm", "-f", &container_name]) + .output(); + + let run_status = Command::new("podman") + .args([ + "run", + "-d", + "--rm", + "--name", + &container_name, + "-v", + &format!("{}:/usr/bin/skillet:ro", abs_binary_path.display()), + image, + "sleep", + "infinity", + ]) + .status() + .context("Failed to start podman container")?; + + if !run_status.success() { + return Err(anyhow!("Failed to start container")); + } + + let result = (|| -> Result<()> { + info!("Executing skillet inside container..."); + // Use 'skillet apply' directly as it's the interface for all our binaries now + // We ensure /etc/sysctl.d exists because many minimal container images lack it. + let exec_status = Command::new("podman") + .args([ + "exec", + &container_name, + "sh", + "-c", + "mkdir -p /etc/sysctl.d && skillet apply --record /tmp/ops.yaml", + ]) + .status() + .context("Failed to exec skillet")?; + + if !exec_status.success() { + return Err(anyhow!("skillet apply failed inside container")); + } + + let dest_dir = PathBuf::from("integration_tests/recordings"); + fs::create_dir_all(&dest_dir)?; + let dest_file = dest_dir.join(format!("{}.yaml", hostname)); + + if is_record { + info!("Copying recording to {}", dest_file.display()); + let cp_status = Command::new("podman") + .args([ + "cp", + &format!("{}:/tmp/ops.yaml", container_name), + dest_file.to_str().unwrap(), + ]) + .status()?; + + if !cp_status.success() { + return Err(anyhow!("Failed to copy recording from container")); + } + } else { + info!("Verifying recording..."); + let temp_dest = tempfile::Builder::new().suffix(".yaml").tempfile()?; + let temp_path = temp_dest.path().to_str().unwrap(); + + let cp_status = Command::new("podman") + .args([ + "cp", + &format!("{}:/tmp/ops.yaml", container_name), + temp_path, + ]) + .status()?; + if !cp_status.success() { + return Err(anyhow!("Failed to copy recording from container")); + } + + let recorded_content = fs::read_to_string(&dest_file).context(format!( + "Failed to read existing recording at {}", + dest_file.display() + ))?; + let new_content = fs::read_to_string(temp_path)?; + + let recorded_ops: Vec = serde_yaml::from_str(&recorded_content)?; + let new_ops: Vec = serde_yaml::from_str(&new_content)?; + + if recorded_ops != new_ops { + error!("Recording mismatch!"); + error!("Expected: {:?}", recorded_ops); + error!("Actual: {:?}", new_ops); + return Err(anyhow!( + "Integration test failed: Actions do not match recording." + )); + } else { + info!("Integration test passed!"); + } + } + + Ok(()) + })(); + + info!("Stopping container..."); + let _ = Command::new("podman") + .args(["kill", &container_name]) + .output(); + + result +} diff --git a/ublue/skillet/crates/core/Cargo.toml b/ublue/skillet/crates/core/Cargo.toml new file mode 100644 index 00000000..76e82bf2 --- /dev/null +++ b/ublue/skillet/crates/core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skillet_core" +version = "0.1.0" +edition = "2021" + +[dependencies] +thiserror.workspace = true +sha2.workspace = true +users.workspace = true +nix.workspace = true +tempfile.workspace = true +hex.workspace = true +serde.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs new file mode 100644 index 00000000..9d35caeb --- /dev/null +++ b/ublue/skillet/crates/core/src/files.rs @@ -0,0 +1,217 @@ +use nix::unistd::{chown, Gid, Uid}; +use sha2::{Digest, Sha256}; +use std::fs::{self}; +use std::io::{self, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use tempfile::NamedTempFile; +use thiserror::Error; +use tracing::info; +use users::{get_group_by_name, get_user_by_name}; + +#[derive(Error, Debug)] +pub enum FileError { + #[error("IO error: {0}")] + Io(#[from] io::Error), + #[error("Failed to persist temporary file to {0}: {1}")] + Persist(String, io::Error), + #[error("Failed to read existing file {0}: {1}")] + Read(String, io::Error), + #[error("Invalid path: {0}")] + InvalidPath(String), + #[error("Parent directory for {0} does not exist")] + ParentMissing(String), + #[error("Failed to set permissions for {0}: {1}")] + SetPermissions(String, io::Error), + #[error("Failed to set ownership for {0}: {1}")] + SetOwnership(String, String), + #[error("User {0} not found")] + UserNotFound(String), + #[error("Group {0} not found")] + GroupNotFound(String), +} + +pub trait FileResource { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result; + fn delete_file(&self, path: &Path) -> Result; +} + +pub struct LocalFileResource; + +impl LocalFileResource { + pub fn new() -> Self { + Self + } + + fn check_metadata( + &self, + path: &Path, + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result { + let metadata = + fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + let mut changed = false; + + if let Some(desired_mode) = mode { + if (metadata.permissions().mode() & 0o777) != desired_mode { + changed = true; + } + } + + if let Some(desired_user) = owner { + let _user = get_user_by_name(desired_user) + .ok_or_else(|| FileError::UserNotFound(desired_user.to_string()))?; + if metadata.permissions().mode() & 0o777 != 0 { // Placeholder for real check + // For ownership we really need to check stat, not just permissions + // Let's use nix::sys::stat::stat or std::os::unix::fs::MetadataExt + } + } + + // Ownership check is a bit more involved with std::fs::Metadata. + // We can use MetadataExt. + use std::os::unix::fs::MetadataExt; + + if let Some(desired_user) = owner { + let user = get_user_by_name(desired_user) + .ok_or_else(|| FileError::UserNotFound(desired_user.to_string()))?; + if metadata.uid() != user.uid() { + changed = true; + } + } + + if let Some(desired_group) = group { + let grp = get_group_by_name(desired_group) + .ok_or_else(|| FileError::GroupNotFound(desired_group.to_string()))?; + if metadata.gid() != grp.gid() { + changed = true; + } + } + + Ok(changed) + } + + fn apply_metadata( + &self, + path: &Path, + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result<(), FileError> { + if let Some(desired_mode) = mode { + let mut perms = fs::metadata(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))? + .permissions(); + perms.set_mode(desired_mode); + fs::set_permissions(path, perms) + .map_err(|e| FileError::SetPermissions(path.display().to_string(), e))?; + } + + if owner.is_some() || group.is_some() { + let uid = owner + .map(|u| get_user_by_name(u).ok_or_else(|| FileError::UserNotFound(u.to_string()))) + .transpose()? + .map(|u| Uid::from_raw(u.uid())); + + let gid = group + .map(|g| { + get_group_by_name(g).ok_or_else(|| FileError::GroupNotFound(g.to_string())) + }) + .transpose()? + .map(|g| Gid::from_raw(g.gid())); + + chown(path, uid, gid) + .map_err(|e| FileError::SetOwnership(path.display().to_string(), e.to_string()))?; + } + + Ok(()) + } +} + +impl Default for LocalFileResource { + fn default() -> Self { + Self::new() + } +} + +impl FileResource for LocalFileResource { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result { + // 1. Check parent directory + let parent = path + .parent() + .ok_or_else(|| FileError::InvalidPath(path.display().to_string()))?; + + if !parent.exists() { + return Err(FileError::ParentMissing(path.display().to_string())); + } + + let mut changed = false; + + // 2. Check content + let content_changed = if path.exists() { + let existing_content = + fs::read(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + + let mut hasher = Sha256::new(); + hasher.update(&existing_content); + let existing_hash = hasher.finalize(); + + let mut new_hasher = Sha256::new(); + new_hasher.update(content); + let new_hash = new_hasher.finalize(); + + existing_hash != new_hash + } else { + true + }; + + if content_changed { + // Write to temp file in same directory (for atomic rename) + let mut temp_file = NamedTempFile::new_in(parent)?; + temp_file.write_all(content)?; + temp_file + .persist(path) + .map_err(|e| FileError::Persist(path.display().to_string(), e.error))?; + changed = true; + info!("Updated file content for {}", path.display()); + } + + // 3. Check and apply metadata + if path.exists() && self.check_metadata(path, mode, owner, group)? { + self.apply_metadata(path, mode, owner, group)?; + changed = true; + info!("Updated file metadata for {}", path.display()); + } + + Ok(changed) + } + + fn delete_file(&self, path: &Path) -> Result { + if path.exists() { + fs::remove_file(path).map_err(FileError::Io)?; + info!("Deleted file {}", path.display()); + Ok(true) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +#[path = "files/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs new file mode 100644 index 00000000..232001dc --- /dev/null +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -0,0 +1,70 @@ +use super::*; +use std::fs; +use tempfile::tempdir; + +#[test] +fn test_ensure_file_creates_file() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("test.txt"); + let content = b"hello world"; + let resource = LocalFileResource::new(); + + let changed = resource + .ensure_file(&file_path, content, None, None, None) + .unwrap(); + assert!(changed); + assert!(file_path.exists()); + assert_eq!(fs::read(&file_path).unwrap(), content); +} + +#[test] +fn test_ensure_file_idempotent() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("test_idempotent.txt"); + let content = b"idempotent"; + let resource = LocalFileResource::new(); + + // First write + let changed = resource + .ensure_file(&file_path, content, None, None, None) + .unwrap(); + assert!(changed); + + // Second write (same content) + let changed_again = resource + .ensure_file(&file_path, content, None, None, None) + .unwrap(); + assert!(!changed_again); +} + +#[test] +fn test_ensure_file_updates_content() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("test_update.txt"); + let resource = LocalFileResource::new(); + + resource + .ensure_file(&file_path, b"initial", None, None, None) + .unwrap(); + + let changed = resource + .ensure_file(&file_path, b"updated", None, None, None) + .unwrap(); + assert!(changed); + assert_eq!(fs::read(&file_path).unwrap(), b"updated"); +} + +#[test] +fn test_delete_file() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("test_delete.txt"); + fs::write(&file_path, b"delete me").unwrap(); + let resource = LocalFileResource::new(); + + let changed = resource.delete_file(&file_path).unwrap(); + assert!(changed); + assert!(!file_path.exists()); + + let changed_again = resource.delete_file(&file_path).unwrap(); + assert!(!changed_again); +} diff --git a/ublue/skillet/crates/core/src/lib.rs b/ublue/skillet/crates/core/src/lib.rs new file mode 100644 index 00000000..7a18a869 --- /dev/null +++ b/ublue/skillet/crates/core/src/lib.rs @@ -0,0 +1,4 @@ +pub mod files; +pub mod recorder; +pub mod resource_op; +pub mod system; diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs new file mode 100644 index 00000000..bffd6436 --- /dev/null +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -0,0 +1,77 @@ +use crate::files::{FileError, FileResource}; +use crate::resource_op::ResourceOp; +use crate::system::{SystemError, SystemResource}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +pub struct Recorder { + inner: T, + ops: Arc>>, +} + +impl Recorder { + pub fn new(inner: T) -> Self { + Self { + inner, + ops: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn with_ops(inner: T, ops: Arc>>) -> Self { + Self { inner, ops } + } + + pub fn get_ops(&self) -> Vec { + self.ops.lock().unwrap().clone() + } + + pub fn shared_ops(&self) -> Arc>> { + self.ops.clone() + } + + fn record(&self, op: ResourceOp) { + self.ops.lock().unwrap().push(op); + } +} + +impl FileResource for Recorder { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result { + let mut hasher = Sha256::new(); + hasher.update(content); + let hash = hex::encode(hasher.finalize()); + + self.record(ResourceOp::EnsureFile { + path: path.display().to_string(), + content_hash: hash, + mode, + owner: owner.map(|s| s.to_string()), + group: group.map(|s| s.to_string()), + }); + + self.inner.ensure_file(path, content, mode, owner, group) + } + + fn delete_file(&self, path: &Path) -> Result { + self.record(ResourceOp::DeleteFile { + path: path.display().to_string(), + }); + self.inner.delete_file(path) + } +} + +impl SystemResource for Recorder { + fn ensure_group(&self, name: &str) -> Result { + self.record(ResourceOp::EnsureGroup { + name: name.to_string(), + }); + self.inner.ensure_group(name) + } +} diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs new file mode 100644 index 00000000..6008a2a4 --- /dev/null +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +pub enum ResourceOp { + EnsureFile { + path: String, + content_hash: String, + mode: Option, + owner: Option, + group: Option, + }, + DeleteFile { + path: String, + }, + EnsureGroup { + name: String, + }, +} diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs new file mode 100644 index 00000000..baf98e7e --- /dev/null +++ b/ublue/skillet/crates/core/src/system.rs @@ -0,0 +1,63 @@ +use std::process::Command; +use thiserror::Error; +use tracing::{debug, info}; +use users::get_group_by_name; + +#[derive(Error, Debug)] +pub enum SystemError { + #[error("Group check error: {0}")] + GroupCheck(String), + #[error("Command failed: {0}")] + Command(String), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +pub trait SystemResource { + fn ensure_group(&self, name: &str) -> Result; +} + +pub struct LinuxSystemResource; + +impl LinuxSystemResource { + pub fn new() -> Self { + Self + } +} + +impl Default for LinuxSystemResource { + fn default() -> Self { + Self::new() + } +} + +impl SystemResource for LinuxSystemResource { + fn ensure_group(&self, name: &str) -> Result { + // 1. Check if group exists using `users` crate + if get_group_by_name(name).is_some() { + debug!("Group {} already exists", name); + return Ok(false); + } + + // 2. Create group using `groupadd` + // Note: Creating groups requires root privileges usually. + info!("Creating group {}", name); + let output = Command::new("groupadd") + .arg(name) + // .arg("-r") // System group? Maybe make it an option? + // For now, simple group creation. + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SystemError::Command(format!("groupadd failed: {}", stderr))); + } + + info!("Created group {}", name); + Ok(true) + } +} + +#[cfg(test)] +#[path = "system/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs new file mode 100644 index 00000000..5e100091 --- /dev/null +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -0,0 +1,39 @@ +use super::*; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +// Mock implementation for testing consumers +pub struct MockSystemResource { + pub groups: Arc>>, +} + +impl MockSystemResource { + pub fn new() -> Self { + Self { + groups: Arc::new(Mutex::new(HashSet::new())), + } + } +} + +impl SystemResource for MockSystemResource { + fn ensure_group(&self, name: &str) -> Result { + let mut groups = self.groups.lock().unwrap(); + if groups.contains(name) { + Ok(false) + } else { + groups.insert(name.to_string()); + Ok(true) + } + } +} + +#[test] +fn test_mock_system_resource() { + let system = MockSystemResource::new(); + let changed = system.ensure_group("syslog").unwrap(); + assert!(changed); + assert!(system.groups.lock().unwrap().contains("syslog")); + + let changed_again = system.ensure_group("syslog").unwrap(); + assert!(!changed_again); +} diff --git a/ublue/skillet/crates/hardening/Cargo.toml b/ublue/skillet/crates/hardening/Cargo.toml new file mode 100644 index 00000000..f9a9f432 --- /dev/null +++ b/ublue/skillet/crates/hardening/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "skillet_hardening" +version = "0.1.0" +edition = "2021" + +[dependencies] +skillet_core.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/ublue/skillet/crates/hardening/files/sysctl.boxy.conf b/ublue/skillet/crates/hardening/files/sysctl.boxy.conf new file mode 100644 index 00000000..1b29f8bb --- /dev/null +++ b/ublue/skillet/crates/hardening/files/sysctl.boxy.conf @@ -0,0 +1,40 @@ +fs.suid_dumpable = 0 +kernel.randomize_va_space = 2 +kernel.sysrq = 0 +net.ipv4.conf.all.accept_redirects = 0 +net.ipv4.conf.all.accept_source_route = 0 +net.ipv4.conf.all.arp_announce = 2 +net.ipv4.conf.all.arp_ignore = 1 +net.ipv4.conf.all.log_martians = 0 +net.ipv4.conf.all.rp_filter = 1 +net.ipv4.conf.all.secure_redirects = 0 +net.ipv4.conf.all.send_redirects = 0 +net.ipv4.conf.all.shared_media = 1 +net.ipv4.conf.default.accept_redirects = 0 +net.ipv4.conf.default.accept_source_route = 0 +net.ipv4.conf.default.log_martians = 0 +net.ipv4.conf.default.rp_filter = 1 +net.ipv4.conf.default.secure_redirects = 0 +net.ipv4.conf.default.send_redirects = 0 +net.ipv4.conf.default.shared_media = 1 +net.ipv4.icmp_echo_ignore_broadcasts = 1 +net.ipv4.icmp_ignore_bogus_error_responses = 1 +net.ipv4.icmp_ratelimit = 100 +net.ipv4.icmp_ratemask = 88089 +net.ipv4.ip_forward = 1 +net.ipv4.tcp_rfc1337 = 1 +net.ipv4.tcp_syncookies = 1 +net.ipv4.tcp_timestamps = 0 +net.ipv6.conf.all.accept_ra = 0 +net.ipv6.conf.all.accept_redirects = 0 +net.ipv6.conf.all.disable_ipv6 = 0 +net.ipv6.conf.all.forwarding = 1 +net.ipv6.conf.default.accept_ra = 0 +net.ipv6.conf.default.accept_ra_defrtr = 0 +net.ipv6.conf.default.accept_ra_pinfo = 0 +net.ipv6.conf.default.accept_ra_rtr_pref = 0 +net.ipv6.conf.default.accept_redirects = 0 +net.ipv6.conf.default.autoconf = 0 +net.ipv6.conf.default.dad_transmits = 0 +net.ipv6.conf.default.max_addresses = 1 +net.ipv6.conf.default.router_solicitations = 0 diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs new file mode 100644 index 00000000..b372bfec --- /dev/null +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -0,0 +1,68 @@ +use skillet_core::files::{FileError, FileResource}; +use skillet_core::system::{SystemError, SystemResource}; +use std::path::Path; +use thiserror::Error; +use tracing::info; + +#[derive(Error, Debug)] +pub enum HardeningError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), +} + +pub fn apply(system: &S, files: &F) -> Result<(), HardeningError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Applying hardening..."); + + // 1. Sysctl hardening + apply_sysctl_hardening(files)?; + + // 2. Include 'os-hardening' + apply_os_hardening(system)?; + + // 3. Include 'ssh-hardening::server' + apply_ssh_hardening_server(system)?; + + // 4. Include 'ssh-hardening::client' + apply_ssh_hardening_client(system)?; + + Ok(()) +} + +fn apply_sysctl_hardening(files: &F) -> Result<(), HardeningError> { + info!("Applying sysctl hardening..."); + let content = include_bytes!("../files/sysctl.boxy.conf"); + let path = Path::new("/etc/sysctl.d/99-hardening.conf"); + + files.ensure_file(path, content, Some(0o644), Some("root"), Some("root"))?; + + Ok(()) +} + +fn apply_os_hardening(_system: &S) -> Result<(), HardeningError> { + info!("(Placeholder) Applying os-hardening"); + Ok(()) +} + +fn apply_ssh_hardening_server( + _system: &S, +) -> Result<(), HardeningError> { + info!("(Placeholder) Applying ssh-hardening::server"); + Ok(()) +} + +fn apply_ssh_hardening_client( + _system: &S, +) -> Result<(), HardeningError> { + info!("(Placeholder) Applying ssh-hardening::client"); + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/hardening/src/tests.rs b/ublue/skillet/crates/hardening/src/tests.rs new file mode 100644 index 00000000..81f26dcb --- /dev/null +++ b/ublue/skillet/crates/hardening/src/tests.rs @@ -0,0 +1,80 @@ +use super::*; +use skillet_core::files::{FileError, FileResource}; +use skillet_core::system::{SystemError, SystemResource}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +struct MockSystem { + groups: Arc>>, +} + +impl MockSystem { + fn new() -> Self { + Self { + groups: Arc::new(Mutex::new(HashSet::new())), + } + } +} + +impl SystemResource for MockSystem { + fn ensure_group(&self, name: &str) -> Result { + let mut groups = self.groups.lock().unwrap(); + if groups.contains(name) { + Ok(false) + } else { + groups.insert(name.to_string()); + Ok(true) + } + } +} + +struct MockFiles { + files: Arc>>>, +} + +impl MockFiles { + fn new() -> Self { + Self { + files: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl FileResource for MockFiles { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + _mode: Option, + _owner: Option<&str>, + _group: Option<&str>, + ) -> Result { + let mut files = self.files.lock().unwrap(); + let path_str = path.display().to_string(); + if let Some(existing) = files.get(&path_str) { + if existing == content { + return Ok(false); + } + } + files.insert(path_str, content.to_vec()); + Ok(true) + } + + fn delete_file(&self, path: &Path) -> Result { + let mut files = self.files.lock().unwrap(); + Ok(files.remove(&path.display().to_string()).is_some()) + } +} + +#[test] +fn test_hardening_applies_sysctl() { + let system = MockSystem::new(); + let files = MockFiles::new(); + apply(&system, &files).unwrap(); + assert!(files + .files + .lock() + .unwrap() + .contains_key("/etc/sysctl.d/99-hardening.conf")); +} diff --git a/ublue/skillet/crates/hosts/beezelbot/Cargo.toml b/ublue/skillet/crates/hosts/beezelbot/Cargo.toml new file mode 100644 index 00000000..99bb502e --- /dev/null +++ b/ublue/skillet/crates/hosts/beezelbot/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "skillet-beezelbot" +version = "0.1.0" +edition = "2021" + +[dependencies] +skillet_core.workspace = true +skillet_hardening.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +anyhow = "1.0" +serde.workspace = true +serde_yaml = "0.9" +hex.workspace = true diff --git a/ublue/skillet/crates/hosts/beezelbot/src/main.rs b/ublue/skillet/crates/hosts/beezelbot/src/main.rs new file mode 100644 index 00000000..03817940 --- /dev/null +++ b/ublue/skillet/crates/hosts/beezelbot/src/main.rs @@ -0,0 +1,73 @@ +use anyhow::{anyhow, Context, Result}; +use clap::{Parser, Subcommand}; +use skillet_core::files::LocalFileResource; +use skillet_core::recorder::Recorder; +use skillet_core::system::LinuxSystemResource; +use skillet_hardening::apply; +use std::fs; +use std::path::PathBuf; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[command(subcommand)] + command: Commands, + + /// Enable verbose logging + #[arg(short, long, global = true)] + verbose: bool, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Apply configuration + Apply { + /// Optional: Output recorded actions to this file path + #[arg(long)] + record: Option, + }, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + let subscriber = FmtSubscriber::builder() + .with_max_level(if args.verbose { + Level::DEBUG + } else { + Level::INFO + }) + .finish(); + + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + match args.command { + Commands::Apply { record } => handle_apply(record), + } +} + +fn handle_apply(record_path: Option) -> Result<()> { + info!("Starting Skillet configuration for beezelbot..."); + + let system = LinuxSystemResource::new(); + let files = LocalFileResource::new(); + + if let Some(path) = record_path { + let recorder_system = Recorder::new(system); + let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); + + apply(&recorder_system, &recorder_files).map_err(|e| anyhow!(e))?; + + let ops = recorder_system.get_ops(); + let yaml = serde_yaml::to_string(&ops)?; + fs::write(&path, yaml).context("Failed to write recording")?; + info!("Recording saved to {}", path.display()); + } else { + apply(&system, &files).map_err(|e| anyhow!(e))?; + } + + info!("Configuration applied successfully."); + Ok(()) +} diff --git a/ublue/skillet/integration_tests/recordings/beezelbot.yaml b/ublue/skillet/integration_tests/recordings/beezelbot.yaml new file mode 100644 index 00000000..3ea1f5b7 --- /dev/null +++ b/ublue/skillet/integration_tests/recordings/beezelbot.yaml @@ -0,0 +1,6 @@ +- !EnsureFile + path: /etc/sysctl.d/99-hardening.conf + content_hash: c71e2f0edb84c44cfb601a2dc3d35df3b46afbbe9d28e02283a12d4b5f55b89d + mode: 420 + owner: root + group: root From 3b2b4e468abca80c274b381333bb206209f8cd79 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 21 Mar 2026 22:02:22 +0100 Subject: [PATCH 002/252] refactor: address PR review comments - Removed unsafe unwrap on Mutex locks in recorder. - Removed redundant map_err in CLI. - Handled potential non-UTF-8 paths safely in CLI. - Removed placeholder ownership code in core. - Refactored shared host CLI logic into skillet_cli_common. - Centralized test mocks in skillet_core::test_utils. - Added file metadata verification tests. - Improved readability of recorded modes by using octal strings. --- ublue/skillet/Cargo.lock | 9 +- ublue/skillet/Cargo.toml | 2 + ublue/skillet/crates/cli-common/Cargo.toml | 14 +++ ublue/skillet/crates/cli-common/src/lib.rs | 73 ++++++++++++ ublue/skillet/crates/cli/src/main.rs | 13 ++- ublue/skillet/crates/core/Cargo.toml | 3 + ublue/skillet/crates/core/src/files.rs | 15 +-- ublue/skillet/crates/core/src/files/tests.rs | 31 +++++ ublue/skillet/crates/core/src/lib.rs | 2 + ublue/skillet/crates/core/src/recorder.rs | 6 +- ublue/skillet/crates/core/src/resource_op.rs | 2 +- ublue/skillet/crates/core/src/test_utils.rs | 110 ++++++++++++++++++ ublue/skillet/crates/hardening/Cargo.toml | 2 +- ublue/skillet/crates/hardening/src/tests.rs | 70 +---------- .../skillet/crates/hosts/beezelbot/Cargo.toml | 9 +- .../crates/hosts/beezelbot/src/main.rs | 73 +----------- .../recordings/beezelbot.yaml | 2 +- 17 files changed, 265 insertions(+), 171 deletions(-) create mode 100644 ublue/skillet/crates/cli-common/Cargo.toml create mode 100644 ublue/skillet/crates/cli-common/src/lib.rs create mode 100644 ublue/skillet/crates/core/src/test_utils.rs diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 26e1d9d7..f305e365 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -490,10 +490,17 @@ dependencies = [ [[package]] name = "skillet-beezelbot" version = "0.1.0" +dependencies = [ + "anyhow", + "skillet_cli_common", +] + +[[package]] +name = "skillet_cli_common" +version = "0.1.0" dependencies = [ "anyhow", "clap", - "hex", "serde", "serde_yaml", "skillet_core", diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index ab1d3f99..5c7bd3c4 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -5,11 +5,13 @@ members = [ "crates/hardening", "crates/cli", "crates/hosts/beezelbot", + "crates/cli-common", ] [workspace.dependencies] skillet_core = { path = "crates/core" } skillet_hardening = { path = "crates/hardening" } +skillet_cli_common = { path = "crates/cli-common" } thiserror = "1.0" sha2 = "0.10" users = "0.11" diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml new file mode 100644 index 00000000..27a0db7c --- /dev/null +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "skillet_cli_common" +version = "0.1.0" +edition = "2021" + +[dependencies] +skillet_core.workspace = true +skillet_hardening.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +anyhow = "1.0" +serde.workspace = true +serde_yaml = "0.9" diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs new file mode 100644 index 00000000..fc5bbfed --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -0,0 +1,73 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use skillet_core::files::LocalFileResource; +use skillet_core::recorder::Recorder; +use skillet_core::system::LinuxSystemResource; +use skillet_hardening::apply; +use std::fs; +use std::path::PathBuf; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +pub struct HostArgs { + #[command(subcommand)] + pub command: HostCommands, + + /// Enable verbose logging + #[arg(short, long, global = true)] + pub verbose: bool, +} + +#[derive(clap::Subcommand, Debug)] +pub enum HostCommands { + /// Apply configuration + Apply { + /// Optional: Output recorded actions to this file path + #[arg(long)] + record: Option, + }, +} + +pub fn run_host(hostname: &str) -> Result<()> { + let args = HostArgs::parse(); + + let subscriber = FmtSubscriber::builder() + .with_max_level(if args.verbose { + Level::DEBUG + } else { + Level::INFO + }) + .finish(); + + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + match args.command { + HostCommands::Apply { record } => handle_apply(hostname, record), + } +} + +fn handle_apply(hostname: &str, record_path: Option) -> Result<()> { + info!("Starting Skillet configuration for {}...", hostname); + + let system = LinuxSystemResource::new(); + let files = LocalFileResource::new(); + + if let Some(path) = record_path { + let recorder_system = Recorder::new(system); + let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); + + apply(&recorder_system, &recorder_files)?; + + let ops = recorder_system.get_ops(); + let yaml = serde_yaml::to_string(&ops)?; + fs::write(&path, yaml).context("Failed to write recording")?; + info!("Recording saved to {}", path.display()); + } else { + apply(&system, &files)?; + } + + info!("Configuration applied successfully."); + Ok(()) +} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 65bbee6e..ea9bbc00 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -81,14 +81,14 @@ fn handle_apply(record_path: Option) -> Result<()> { let recorder_system = Recorder::new(system); let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); - apply(&recorder_system, &recorder_files).map_err(|e| anyhow!(e))?; + apply(&recorder_system, &recorder_files)?; let ops = recorder_system.get_ops(); let yaml = serde_yaml::to_string(&ops)?; fs::write(&path, yaml).context("Failed to write recording")?; info!("Recording saved to {}", path.display()); } else { - apply(&system, &files).map_err(|e| anyhow!(e))?; + apply(&system, &files)?; } info!("Configuration applied successfully."); @@ -207,7 +207,9 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() .args([ "cp", &format!("{}:/tmp/ops.yaml", container_name), - dest_file.to_str().unwrap(), + dest_file + .to_str() + .ok_or_else(|| anyhow!("Destination path is not valid UTF-8"))?, ]) .status()?; @@ -217,7 +219,10 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() } else { info!("Verifying recording..."); let temp_dest = tempfile::Builder::new().suffix(".yaml").tempfile()?; - let temp_path = temp_dest.path().to_str().unwrap(); + let temp_path = temp_dest + .path() + .to_str() + .ok_or_else(|| anyhow!("Temporary path is not valid UTF-8"))?; let cp_status = Command::new("podman") .args([ diff --git a/ublue/skillet/crates/core/Cargo.toml b/ublue/skillet/crates/core/Cargo.toml index 76e82bf2..cd8f2250 100644 --- a/ublue/skillet/crates/core/Cargo.toml +++ b/ublue/skillet/crates/core/Cargo.toml @@ -15,3 +15,6 @@ tracing.workspace = true [dev-dependencies] tempfile.workspace = true + +[features] +test-utils = [] diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 9d35caeb..85891237 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -2,7 +2,7 @@ use nix::unistd::{chown, Gid, Uid}; use sha2::{Digest, Sha256}; use std::fs::{self}; use std::io::{self, Write}; -use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::Path; use tempfile::NamedTempFile; use thiserror::Error; @@ -67,19 +67,6 @@ impl LocalFileResource { } } - if let Some(desired_user) = owner { - let _user = get_user_by_name(desired_user) - .ok_or_else(|| FileError::UserNotFound(desired_user.to_string()))?; - if metadata.permissions().mode() & 0o777 != 0 { // Placeholder for real check - // For ownership we really need to check stat, not just permissions - // Let's use nix::sys::stat::stat or std::os::unix::fs::MetadataExt - } - } - - // Ownership check is a bit more involved with std::fs::Metadata. - // We can use MetadataExt. - use std::os::unix::fs::MetadataExt; - if let Some(desired_user) = owner { let user = get_user_by_name(desired_user) .ok_or_else(|| FileError::UserNotFound(desired_user.to_string()))?; diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs index 232001dc..bfe75580 100644 --- a/ublue/skillet/crates/core/src/files/tests.rs +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -1,5 +1,6 @@ use super::*; use std::fs; +use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; #[test] @@ -54,6 +55,36 @@ fn test_ensure_file_updates_content() { assert_eq!(fs::read(&file_path).unwrap(), b"updated"); } +#[test] +fn test_ensure_file_metadata() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("test_meta.txt"); + let resource = LocalFileResource::new(); + let content = b"metadata test"; + + // 1. Create with default meta + resource + .ensure_file(&file_path, content, None, None, None) + .unwrap(); + + // 2. Change mode + let changed = resource + .ensure_file(&file_path, content, Some(0o644), None, None) + .unwrap(); + assert!(changed); + let meta = fs::metadata(&file_path).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o644); + + // 3. Idempotent mode change + let changed_again = resource + .ensure_file(&file_path, content, Some(0o644), None, None) + .unwrap(); + assert!(!changed_again); + + // Note: Testing owner/group change typically requires root, so we skip it in unit tests + // or we would need to mock the underlying chown call. +} + #[test] fn test_delete_file() { let dir = tempdir().unwrap(); diff --git a/ublue/skillet/crates/core/src/lib.rs b/ublue/skillet/crates/core/src/lib.rs index 7a18a869..59285565 100644 --- a/ublue/skillet/crates/core/src/lib.rs +++ b/ublue/skillet/crates/core/src/lib.rs @@ -2,3 +2,5 @@ pub mod files; pub mod recorder; pub mod resource_op; pub mod system; +#[cfg(feature = "test-utils")] +pub mod test_utils; diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index bffd6436..e8af59a2 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -23,7 +23,7 @@ impl Recorder { } pub fn get_ops(&self) -> Vec { - self.ops.lock().unwrap().clone() + self.ops.lock().unwrap_or_else(|e| e.into_inner()).clone() } pub fn shared_ops(&self) -> Arc>> { @@ -31,7 +31,7 @@ impl Recorder { } fn record(&self, op: ResourceOp) { - self.ops.lock().unwrap().push(op); + self.ops.lock().unwrap_or_else(|e| e.into_inner()).push(op); } } @@ -51,7 +51,7 @@ impl FileResource for Recorder { self.record(ResourceOp::EnsureFile { path: path.display().to_string(), content_hash: hash, - mode, + mode: mode.map(|m| format!("0o{:o}", m)), owner: owner.map(|s| s.to_string()), group: group.map(|s| s.to_string()), }); diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index 6008a2a4..da251c3f 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -5,7 +5,7 @@ pub enum ResourceOp { EnsureFile { path: String, content_hash: String, - mode: Option, + mode: Option, owner: Option, group: Option, }, diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs new file mode 100644 index 00000000..81d4466d --- /dev/null +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -0,0 +1,110 @@ +use crate::files::{FileError, FileResource}; +use crate::system::{SystemError, SystemResource}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +pub struct MockSystem { + pub groups: Arc>>, +} + +impl MockSystem { + pub fn new() -> Self { + Self { + groups: Arc::new(Mutex::new(HashSet::new())), + } + } +} + +impl Default for MockSystem { + fn default() -> Self { + Self::new() + } +} + +impl SystemResource for MockSystem { + fn ensure_group(&self, name: &str) -> Result { + let mut groups = self.groups.lock().unwrap_or_else(|e| e.into_inner()); + if groups.contains(name) { + Ok(false) + } else { + groups.insert(name.to_string()); + Ok(true) + } + } +} + +pub struct MockFiles { + pub files: Arc>>>, + pub metadata: Arc, Option, Option)>>>, +} + +impl MockFiles { + pub fn new() -> Self { + Self { + files: Arc::new(Mutex::new(HashMap::new())), + metadata: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl Default for MockFiles { + fn default() -> Self { + Self::new() + } +} + +impl FileResource for MockFiles { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result { + let path_str = path.display().to_string(); + let mut files = self.files.lock().unwrap_or_else(|e| e.into_inner()); + let mut metadata = self.metadata.lock().unwrap_or_else(|e| e.into_inner()); + + let mut changed = false; + + if let Some(existing) = files.get(&path_str) { + if existing != content { + files.insert(path_str.clone(), content.to_vec()); + changed = true; + } + } else { + files.insert(path_str.clone(), content.to_vec()); + changed = true; + } + + let new_meta = ( + mode, + owner.map(|s| s.to_string()), + group.map(|s| s.to_string()), + ); + if let Some(existing_meta) = metadata.get(&path_str) { + if existing_meta != &new_meta { + metadata.insert(path_str, new_meta); + changed = true; + } + } else { + metadata.insert(path_str, new_meta); + changed = true; + } + + Ok(changed) + } + + fn delete_file(&self, path: &Path) -> Result { + let path_str = path.display().to_string(); + let mut files = self.files.lock().unwrap_or_else(|e| e.into_inner()); + let mut metadata = self.metadata.lock().unwrap_or_else(|e| e.into_inner()); + + let f_removed = files.remove(&path_str).is_some(); + let m_removed = metadata.remove(&path_str).is_some(); + + Ok(f_removed || m_removed) + } +} diff --git a/ublue/skillet/crates/hardening/Cargo.toml b/ublue/skillet/crates/hardening/Cargo.toml index f9a9f432..ea4c8203 100644 --- a/ublue/skillet/crates/hardening/Cargo.toml +++ b/ublue/skillet/crates/hardening/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -skillet_core.workspace = true +skillet_core = { workspace = true, features = ["test-utils"] } thiserror.workspace = true tracing.workspace = true diff --git a/ublue/skillet/crates/hardening/src/tests.rs b/ublue/skillet/crates/hardening/src/tests.rs index 81f26dcb..3d35e61b 100644 --- a/ublue/skillet/crates/hardening/src/tests.rs +++ b/ublue/skillet/crates/hardening/src/tests.rs @@ -1,71 +1,5 @@ use super::*; -use skillet_core::files::{FileError, FileResource}; -use skillet_core::system::{SystemError, SystemResource}; -use std::collections::{HashMap, HashSet}; -use std::path::Path; -use std::sync::{Arc, Mutex}; - -struct MockSystem { - groups: Arc>>, -} - -impl MockSystem { - fn new() -> Self { - Self { - groups: Arc::new(Mutex::new(HashSet::new())), - } - } -} - -impl SystemResource for MockSystem { - fn ensure_group(&self, name: &str) -> Result { - let mut groups = self.groups.lock().unwrap(); - if groups.contains(name) { - Ok(false) - } else { - groups.insert(name.to_string()); - Ok(true) - } - } -} - -struct MockFiles { - files: Arc>>>, -} - -impl MockFiles { - fn new() -> Self { - Self { - files: Arc::new(Mutex::new(HashMap::new())), - } - } -} - -impl FileResource for MockFiles { - fn ensure_file( - &self, - path: &Path, - content: &[u8], - _mode: Option, - _owner: Option<&str>, - _group: Option<&str>, - ) -> Result { - let mut files = self.files.lock().unwrap(); - let path_str = path.display().to_string(); - if let Some(existing) = files.get(&path_str) { - if existing == content { - return Ok(false); - } - } - files.insert(path_str, content.to_vec()); - Ok(true) - } - - fn delete_file(&self, path: &Path) -> Result { - let mut files = self.files.lock().unwrap(); - Ok(files.remove(&path.display().to_string()).is_some()) - } -} +use skillet_core::test_utils::{MockFiles, MockSystem}; #[test] fn test_hardening_applies_sysctl() { @@ -75,6 +9,6 @@ fn test_hardening_applies_sysctl() { assert!(files .files .lock() - .unwrap() + .unwrap_or_else(|e| e.into_inner()) .contains_key("/etc/sysctl.d/99-hardening.conf")); } diff --git a/ublue/skillet/crates/hosts/beezelbot/Cargo.toml b/ublue/skillet/crates/hosts/beezelbot/Cargo.toml index 99bb502e..666fe7f9 100644 --- a/ublue/skillet/crates/hosts/beezelbot/Cargo.toml +++ b/ublue/skillet/crates/hosts/beezelbot/Cargo.toml @@ -4,12 +4,5 @@ version = "0.1.0" edition = "2021" [dependencies] -skillet_core.workspace = true -skillet_hardening.workspace = true -clap.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true +skillet_cli_common.workspace = true anyhow = "1.0" -serde.workspace = true -serde_yaml = "0.9" -hex.workspace = true diff --git a/ublue/skillet/crates/hosts/beezelbot/src/main.rs b/ublue/skillet/crates/hosts/beezelbot/src/main.rs index 03817940..c6e4eb17 100644 --- a/ublue/skillet/crates/hosts/beezelbot/src/main.rs +++ b/ublue/skillet/crates/hosts/beezelbot/src/main.rs @@ -1,73 +1,6 @@ -use anyhow::{anyhow, Context, Result}; -use clap::{Parser, Subcommand}; -use skillet_core::files::LocalFileResource; -use skillet_core::recorder::Recorder; -use skillet_core::system::LinuxSystemResource; -use skillet_hardening::apply; -use std::fs; -use std::path::PathBuf; -use tracing::{info, Level}; -use tracing_subscriber::FmtSubscriber; - -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Args { - #[command(subcommand)] - command: Commands, - - /// Enable verbose logging - #[arg(short, long, global = true)] - verbose: bool, -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// Apply configuration - Apply { - /// Optional: Output recorded actions to this file path - #[arg(long)] - record: Option, - }, -} +use anyhow::Result; +use skillet_cli_common::run_host; fn main() -> Result<()> { - let args = Args::parse(); - - let subscriber = FmtSubscriber::builder() - .with_max_level(if args.verbose { - Level::DEBUG - } else { - Level::INFO - }) - .finish(); - - tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); - - match args.command { - Commands::Apply { record } => handle_apply(record), - } -} - -fn handle_apply(record_path: Option) -> Result<()> { - info!("Starting Skillet configuration for beezelbot..."); - - let system = LinuxSystemResource::new(); - let files = LocalFileResource::new(); - - if let Some(path) = record_path { - let recorder_system = Recorder::new(system); - let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); - - apply(&recorder_system, &recorder_files).map_err(|e| anyhow!(e))?; - - let ops = recorder_system.get_ops(); - let yaml = serde_yaml::to_string(&ops)?; - fs::write(&path, yaml).context("Failed to write recording")?; - info!("Recording saved to {}", path.display()); - } else { - apply(&system, &files).map_err(|e| anyhow!(e))?; - } - - info!("Configuration applied successfully."); - Ok(()) + run_host("beezelbot") } diff --git a/ublue/skillet/integration_tests/recordings/beezelbot.yaml b/ublue/skillet/integration_tests/recordings/beezelbot.yaml index 3ea1f5b7..572f8ba3 100644 --- a/ublue/skillet/integration_tests/recordings/beezelbot.yaml +++ b/ublue/skillet/integration_tests/recordings/beezelbot.yaml @@ -1,6 +1,6 @@ - !EnsureFile path: /etc/sysctl.d/99-hardening.conf content_hash: c71e2f0edb84c44cfb601a2dc3d35df3b46afbbe9d28e02283a12d4b5f55b89d - mode: 420 + mode: '0o644' owner: root group: root From d24fa66be5a73d5bc71a563452ac757de0513e2e Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 21 Mar 2026 22:11:45 +0100 Subject: [PATCH 003/252] refactor: replace expect with proper error propagation in cli crates - Removed expect() in skillet_cli_common and converted to thiserror. - Removed expect() in skillet_cli and used anyhow context. - Adheres to AGENTS.md mandate for library code. --- ublue/skillet/Cargo.lock | 2 +- ublue/skillet/crates/cli-common/Cargo.toml | 2 +- ublue/skillet/crates/cli-common/src/lib.rs | 22 ++++++++++++++----- ublue/skillet/crates/cli/src/main.rs | 2 +- .../crates/hosts/beezelbot/src/main.rs | 3 ++- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index f305e365..036f3c07 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -499,12 +499,12 @@ dependencies = [ name = "skillet_cli_common" version = "0.1.0" dependencies = [ - "anyhow", "clap", "serde", "serde_yaml", "skillet_core", "skillet_hardening", + "thiserror", "tracing", "tracing-subscriber", ] diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index 27a0db7c..2babad52 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -9,6 +9,6 @@ skillet_hardening.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -anyhow = "1.0" +thiserror.workspace = true serde.workspace = true serde_yaml = "0.9" diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index fc5bbfed..4b8fd280 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -1,4 +1,3 @@ -use anyhow::{Context, Result}; use clap::Parser; use skillet_core::files::LocalFileResource; use skillet_core::recorder::Recorder; @@ -6,9 +5,22 @@ use skillet_core::system::LinuxSystemResource; use skillet_hardening::apply; use std::fs; use std::path::PathBuf; +use thiserror::Error; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; +#[derive(Error, Debug)] +pub enum CliCommonError { + #[error("Failed to apply hardening: {0}")] + Hardening(#[from] skillet_hardening::HardeningError), + #[error("Failed to set default tracing subscriber: {0}")] + SetLogger(#[from] tracing::subscriber::SetGlobalDefaultError), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Serialization error: {0}")] + Yaml(#[from] serde_yaml::Error), +} + #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] pub struct HostArgs { @@ -30,7 +42,7 @@ pub enum HostCommands { }, } -pub fn run_host(hostname: &str) -> Result<()> { +pub fn run_host(hostname: &str) -> Result<(), CliCommonError> { let args = HostArgs::parse(); let subscriber = FmtSubscriber::builder() @@ -41,14 +53,14 @@ pub fn run_host(hostname: &str) -> Result<()> { }) .finish(); - tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + tracing::subscriber::set_global_default(subscriber)?; match args.command { HostCommands::Apply { record } => handle_apply(hostname, record), } } -fn handle_apply(hostname: &str, record_path: Option) -> Result<()> { +fn handle_apply(hostname: &str, record_path: Option) -> Result<(), CliCommonError> { info!("Starting Skillet configuration for {}...", hostname); let system = LinuxSystemResource::new(); @@ -62,7 +74,7 @@ fn handle_apply(hostname: &str, record_path: Option) -> Result<()> { let ops = recorder_system.get_ops(); let yaml = serde_yaml::to_string(&ops)?; - fs::write(&path, yaml).context("Failed to write recording")?; + fs::write(&path, yaml)?; info!("Recording saved to {}", path.display()); } else { apply(&system, &files)?; diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index ea9bbc00..fca62aa9 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -63,7 +63,7 @@ fn main() -> Result<()> { }) .finish(); - tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + tracing::subscriber::set_global_default(subscriber).context("setting default subscriber failed")?; match args.command { Commands::Apply { record } => handle_apply(record), diff --git a/ublue/skillet/crates/hosts/beezelbot/src/main.rs b/ublue/skillet/crates/hosts/beezelbot/src/main.rs index c6e4eb17..fdc19c19 100644 --- a/ublue/skillet/crates/hosts/beezelbot/src/main.rs +++ b/ublue/skillet/crates/hosts/beezelbot/src/main.rs @@ -2,5 +2,6 @@ use anyhow::Result; use skillet_cli_common::run_host; fn main() -> Result<()> { - run_host("beezelbot") + run_host("beezelbot")?; + Ok(()) } From 8c0debc0fee089cc0bcbb13d35472b7f69a2e59d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 21 Mar 2026 22:16:33 +0100 Subject: [PATCH 004/252] ci: add skillet quality checks to GitHub workflow - Runs clippy, unit tests, and integration tests. - Uses path filtering to run only when ublue/skillet/ is changed. --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eac3b627..0f5621f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,42 @@ jobs: run: | find . -name "*.sh" -not -path "./.venv/*" -not -path "./berks-cookbooks/*" -exec shellcheck -x {} + shellcheck -x run + + skillet-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + rust: + - 'ublue/skillet/**' + + - name: Set up Rust + if: steps.filter.outputs.rust == 'true' + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Rust Cache + if: steps.filter.outputs.rust == 'true' + uses: Swatinem/rust-cache@v2 + with: + workspaces: ublue/skillet + + - name: Run Clippy + if: steps.filter.outputs.rust == 'true' + run: cd ublue/skillet && cargo clippy -- -D warnings + + - name: Run Unit Tests + if: steps.filter.outputs.rust == 'true' + run: cd ublue/skillet && cargo test + + - name: Run Integration Tests + if: steps.filter.outputs.rust == 'true' + run: | + cd ublue/skillet + # Build binary explicitly for the test runner to find it + cargo build + ./target/debug/skillet test run beezelbot --image fedora:latest From f48e380966391063e2d4d998023c8afd6cae732e Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 21 Mar 2026 22:18:01 +0100 Subject: [PATCH 005/252] fix: resolve clippy type complexity warning in test_utils --- ublue/skillet/crates/core/src/test_utils.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 81d4466d..b5731c82 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -34,9 +34,11 @@ impl SystemResource for MockSystem { } } +pub type FileMetadata = (Option, Option, Option); + pub struct MockFiles { pub files: Arc>>>, - pub metadata: Arc, Option, Option)>>>, + pub metadata: Arc>>, } impl MockFiles { From d1b48fa007c69b6ded1b3f76dffa6338f419103a Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 23 Mar 2026 19:22:17 +0100 Subject: [PATCH 006/252] refactor: simplify integration test container setup - Moved complex shell logic to 'test_entrypoint.sh'. - Embedded entrypoint script into the binary. - Updated test runner to use the embedded script. --- ublue/skillet/crates/cli/src/main.rs | 54 ++++++++++++++++--- .../skillet/crates/cli/src/test_entrypoint.sh | 19 +++++++ 2 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/test_entrypoint.sh diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index fca62aa9..62872ded 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -63,7 +63,8 @@ fn main() -> Result<()> { }) .finish(); - tracing::subscriber::set_global_default(subscriber).context("setting default subscriber failed")?; + tracing::subscriber::set_global_default(subscriber) + .context("setting default subscriber failed")?; match args.command { Commands::Apply { record } => handle_apply(record), @@ -179,16 +180,57 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() } let result = (|| -> Result<()> { + // Prepare entrypoint script + let entrypoint_content = include_str!("test_entrypoint.sh"); + let mut temp_entrypoint = tempfile::Builder::new().suffix(".sh").tempfile()?; + use std::io::Write; + temp_entrypoint.write_all(entrypoint_content.as_bytes())?; + let temp_entrypoint_path = temp_entrypoint + .path() + .to_str() + .ok_or_else(|| anyhow!("Entrypoint path is not valid UTF-8"))?; + + // Copy entrypoint to container + info!("Copying test entrypoint to container..."); + let cp_status = Command::new("podman") + .args([ + "cp", + temp_entrypoint_path, + &format!("{}:/tmp/test_entrypoint.sh", container_name), + ]) + .status() + .context("Failed to copy entrypoint")?; + + if !cp_status.success() { + return Err(anyhow!("Failed to copy entrypoint to container")); + } + + // Make executable + let chmod_status = Command::new("podman") + .args([ + "exec", + &container_name, + "chmod", + "+x", + "/tmp/test_entrypoint.sh", + ]) + .status() + .context("Failed to chmod entrypoint")?; + + if !chmod_status.success() { + return Err(anyhow!("Failed to chmod entrypoint in container")); + } + info!("Executing skillet inside container..."); - // Use 'skillet apply' directly as it's the interface for all our binaries now - // We ensure /etc/sysctl.d exists because many minimal container images lack it. let exec_status = Command::new("podman") .args([ "exec", &container_name, - "sh", - "-c", - "mkdir -p /etc/sysctl.d && skillet apply --record /tmp/ops.yaml", + "/tmp/test_entrypoint.sh", + "skillet", + "apply", + "--record", + "/tmp/ops.yaml", ]) .status() .context("Failed to exec skillet")?; diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh new file mode 100644 index 00000000..285d649d --- /dev/null +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -e + +# Ensure /etc/sysctl.d exists (often missing in minimal containers) +mkdir -p /etc/sysctl.d + +# Mock systemctl if it doesn't exist +if [ ! -x /usr/bin/systemctl ]; then + echo "Mocking systemctl..." + cat < /usr/bin/systemctl +#!/bin/sh +echo "Mock systemctl: \$@" +exit 0 +EOF + chmod +x /usr/bin/systemctl +fi + +# Execute the passed command (skillet apply) +exec "$@" From dd5fa62d38dbece3656b91289bf84c772f06fad9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 23 Mar 2026 19:34:41 +0100 Subject: [PATCH 007/252] feat: implement service management and systemctl restart - Added ServiceStart, ServiceStop, ServiceRestart ops. - Implemented systemctl resource in system.rs. - Updated hardening to restart systemd-sysctl on config change. - Updated beezelbot recording with service restart. --- ublue/skillet/crates/core/src/recorder.rs | 21 ++++++ ublue/skillet/crates/core/src/resource_op.rs | 9 +++ ublue/skillet/crates/core/src/system.rs | 30 ++++++++- ublue/skillet/crates/core/src/system/tests.rs | 65 ++++++++++--------- ublue/skillet/crates/core/src/test_utils.rs | 26 ++++++++ ublue/skillet/crates/hardening/src/lib.rs | 15 ++++- ublue/skillet/crates/hardening/src/tests.rs | 9 +++ .../recordings/beezelbot.yaml | 2 + 8 files changed, 144 insertions(+), 33 deletions(-) diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index e8af59a2..89aa6b96 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -74,4 +74,25 @@ impl SystemResource for Recorder { }); self.inner.ensure_group(name) } + + fn service_start(&self, name: &str) -> Result<(), SystemError> { + self.record(ResourceOp::ServiceStart { + name: name.to_string(), + }); + self.inner.service_start(name) + } + + fn service_stop(&self, name: &str) -> Result<(), SystemError> { + self.record(ResourceOp::ServiceStop { + name: name.to_string(), + }); + self.inner.service_stop(name) + } + + fn service_restart(&self, name: &str) -> Result<(), SystemError> { + self.record(ResourceOp::ServiceRestart { + name: name.to_string(), + }); + self.inner.service_restart(name) + } } diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index da251c3f..f83845e5 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -15,4 +15,13 @@ pub enum ResourceOp { EnsureGroup { name: String, }, + ServiceStart { + name: String, + }, + ServiceStop { + name: String, + }, + ServiceRestart { + name: String, + }, } diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index baf98e7e..a4ed3715 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -15,6 +15,9 @@ pub enum SystemError { pub trait SystemResource { fn ensure_group(&self, name: &str) -> Result; + fn service_start(&self, name: &str) -> Result<(), SystemError>; + fn service_stop(&self, name: &str) -> Result<(), SystemError>; + fn service_restart(&self, name: &str) -> Result<(), SystemError>; } pub struct LinuxSystemResource; @@ -23,6 +26,20 @@ impl LinuxSystemResource { pub fn new() -> Self { Self } + + fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { + info!("Running systemctl {} {}", action, name); + let output = Command::new("systemctl").arg(action).arg(name).output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SystemError::Command(format!( + "systemctl {} {} failed: {}", + action, name, stderr + ))); + } + Ok(()) + } } impl Default for LinuxSystemResource { @@ -56,8 +73,19 @@ impl SystemResource for LinuxSystemResource { info!("Created group {}", name); Ok(true) } -} + fn service_start(&self, name: &str) -> Result<(), SystemError> { + self.run_systemctl("start", name) + } + + fn service_stop(&self, name: &str) -> Result<(), SystemError> { + self.run_systemctl("stop", name) + } + + fn service_restart(&self, name: &str) -> Result<(), SystemError> { + self.run_systemctl("restart", name) + } +} #[cfg(test)] #[path = "system/tests.rs"] mod tests; diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 5e100091..a34a9d79 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -1,39 +1,46 @@ use super::*; -use std::collections::HashSet; -use std::sync::{Arc, Mutex}; - -// Mock implementation for testing consumers -pub struct MockSystemResource { - pub groups: Arc>>, -} - -impl MockSystemResource { - pub fn new() -> Self { - Self { - groups: Arc::new(Mutex::new(HashSet::new())), - } - } -} - -impl SystemResource for MockSystemResource { - fn ensure_group(&self, name: &str) -> Result { - let mut groups = self.groups.lock().unwrap(); - if groups.contains(name) { - Ok(false) - } else { - groups.insert(name.to_string()); - Ok(true) - } - } -} +#[cfg(feature = "test-utils")] +use crate::test_utils::MockSystem; #[test] +#[cfg(feature = "test-utils")] fn test_mock_system_resource() { - let system = MockSystemResource::new(); + let system = MockSystem::new(); let changed = system.ensure_group("syslog").unwrap(); assert!(changed); - assert!(system.groups.lock().unwrap().contains("syslog")); + assert!(system + .groups + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains("syslog")); let changed_again = system.ensure_group("syslog").unwrap(); assert!(!changed_again); } + +#[test] +#[cfg(feature = "test-utils")] +fn test_mock_system_services() { + let system = MockSystem::new(); + system.service_start("test-service").unwrap(); + assert_eq!( + system + .services + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get("test-service") + .unwrap(), + "started" + ); + + system.service_restart("test-service").unwrap(); + assert_eq!( + system + .services + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get("test-service") + .unwrap(), + "restarted" + ); +} diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index b5731c82..724d42c7 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -6,12 +6,14 @@ use std::sync::{Arc, Mutex}; pub struct MockSystem { pub groups: Arc>>, + pub services: Arc>>, // name -> state (started, stopped, restarted) } impl MockSystem { pub fn new() -> Self { Self { groups: Arc::new(Mutex::new(HashSet::new())), + services: Arc::new(Mutex::new(HashMap::new())), } } } @@ -32,6 +34,30 @@ impl SystemResource for MockSystem { Ok(true) } } + + fn service_start(&self, name: &str) -> Result<(), SystemError> { + self.services + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(name.to_string(), "started".to_string()); + Ok(()) + } + + fn service_stop(&self, name: &str) -> Result<(), SystemError> { + self.services + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(name.to_string(), "stopped".to_string()); + Ok(()) + } + + fn service_restart(&self, name: &str) -> Result<(), SystemError> { + self.services + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(name.to_string(), "restarted".to_string()); + Ok(()) + } } pub type FileMetadata = (Option, Option, Option); diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index b372bfec..c8f6e0a3 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -20,7 +20,7 @@ where info!("Applying hardening..."); // 1. Sysctl hardening - apply_sysctl_hardening(files)?; + apply_sysctl_hardening(system, files)?; // 2. Include 'os-hardening' apply_os_hardening(system)?; @@ -34,12 +34,21 @@ where Ok(()) } -fn apply_sysctl_hardening(files: &F) -> Result<(), HardeningError> { +fn apply_sysctl_hardening(system: &S, files: &F) -> Result<(), HardeningError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ info!("Applying sysctl hardening..."); let content = include_bytes!("../files/sysctl.boxy.conf"); let path = Path::new("/etc/sysctl.d/99-hardening.conf"); - files.ensure_file(path, content, Some(0o644), Some("root"), Some("root"))?; + let changed = files.ensure_file(path, content, Some(0o644), Some("root"), Some("root"))?; + + if changed { + info!("Sysctl configuration changed, restarting systemd-sysctl..."); + system.service_restart("systemd-sysctl")?; + } Ok(()) } diff --git a/ublue/skillet/crates/hardening/src/tests.rs b/ublue/skillet/crates/hardening/src/tests.rs index 3d35e61b..eed864d8 100644 --- a/ublue/skillet/crates/hardening/src/tests.rs +++ b/ublue/skillet/crates/hardening/src/tests.rs @@ -11,4 +11,13 @@ fn test_hardening_applies_sysctl() { .lock() .unwrap_or_else(|e| e.into_inner()) .contains_key("/etc/sysctl.d/99-hardening.conf")); + assert_eq!( + system + .services + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get("systemd-sysctl") + .unwrap(), + "restarted" + ); } diff --git a/ublue/skillet/integration_tests/recordings/beezelbot.yaml b/ublue/skillet/integration_tests/recordings/beezelbot.yaml index 572f8ba3..090c8dd3 100644 --- a/ublue/skillet/integration_tests/recordings/beezelbot.yaml +++ b/ublue/skillet/integration_tests/recordings/beezelbot.yaml @@ -4,3 +4,5 @@ mode: '0o644' owner: root group: root +- !ServiceRestart + name: systemd-sysctl From 3504d39fdcbb813226df4e1750f85e80f6a52de9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 23 Mar 2026 20:20:11 +0100 Subject: [PATCH 008/252] refactor(skillet): address code review comments - Replace yanked serde_yaml with serde_yml - Refactor run_container_test into smaller, focused functions - Deduplicate handle_apply by moving it to cli-common - Move test-utils feature to dev-dependencies in skillet_hardening - Relocate use statement to top-level --- ublue/skillet/Cargo.lock | 31 ++- ublue/skillet/Cargo.toml | 1 + ublue/skillet/crates/cli-common/Cargo.toml | 2 +- ublue/skillet/crates/cli-common/src/lib.rs | 6 +- ublue/skillet/crates/cli/Cargo.toml | 3 +- ublue/skillet/crates/cli/src/main.rs | 297 ++++++++++----------- ublue/skillet/crates/hardening/Cargo.toml | 3 +- 7 files changed, 174 insertions(+), 169 deletions(-) diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 036f3c07..ebb97afe 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -280,6 +280,16 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -439,16 +449,18 @@ dependencies = [ ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_yml" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ "indexmap", "itoa", + "libyml", + "memchr", "ryu", "serde", - "unsafe-libyaml", + "version_check", ] [[package]] @@ -479,7 +491,8 @@ dependencies = [ "clap", "hex", "serde", - "serde_yaml", + "serde_yml", + "skillet_cli_common", "skillet_core", "skillet_hardening", "tempfile", @@ -501,7 +514,7 @@ version = "0.1.0" dependencies = [ "clap", "serde", - "serde_yaml", + "serde_yml", "skillet_core", "skillet_hardening", "thiserror", @@ -673,12 +686,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "users" version = "0.11.0" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 5c7bd3c4..0f4fd941 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -22,4 +22,5 @@ tracing-subscriber = "0.3" tempfile = "3.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_yml = "0.0.12" hex = "0.4" diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index 2babad52..3818197a 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -11,4 +11,4 @@ tracing.workspace = true tracing-subscriber.workspace = true thiserror.workspace = true serde.workspace = true -serde_yaml = "0.9" +serde_yml.workspace = true diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 4b8fd280..86aa2dc7 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -18,7 +18,7 @@ pub enum CliCommonError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Serialization error: {0}")] - Yaml(#[from] serde_yaml::Error), + Yaml(#[from] serde_yml::Error), } #[derive(Parser, Debug)] @@ -60,7 +60,7 @@ pub fn run_host(hostname: &str) -> Result<(), CliCommonError> { } } -fn handle_apply(hostname: &str, record_path: Option) -> Result<(), CliCommonError> { +pub fn handle_apply(hostname: &str, record_path: Option) -> Result<(), CliCommonError> { info!("Starting Skillet configuration for {}...", hostname); let system = LinuxSystemResource::new(); @@ -73,7 +73,7 @@ fn handle_apply(hostname: &str, record_path: Option) -> Result<(), CliC apply(&recorder_system, &recorder_files)?; let ops = recorder_system.get_ops(); - let yaml = serde_yaml::to_string(&ops)?; + let yaml = serde_yml::to_string(&ops)?; fs::write(&path, yaml)?; info!("Recording saved to {}", path.display()); } else { diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 95f02f3e..4cccda54 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -6,11 +6,12 @@ edition = "2021" [dependencies] skillet_core.workspace = true skillet_hardening.workspace = true +skillet_cli_common.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true anyhow = "1.0" serde.workspace = true -serde_yaml = "0.9" +serde_yml.workspace = true hex.workspace = true tempfile.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 62872ded..da445010 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,12 +1,9 @@ use anyhow::{anyhow, Context, Result}; use clap::{Parser, Subcommand}; -use skillet_core::files::LocalFileResource; -use skillet_core::recorder::Recorder; use skillet_core::resource_op::ResourceOp; -use skillet_core::system::LinuxSystemResource; -use skillet_hardening::apply; use std::fs; -use std::path::PathBuf; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::process::Command; use tracing::{error, info, Level}; use tracing_subscriber::FmtSubscriber; @@ -67,32 +64,12 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { - Commands::Apply { record } => handle_apply(record), - Commands::Test { test_command } => handle_test(test_command), - } -} - -fn handle_apply(record_path: Option) -> Result<()> { - info!("Starting Skillet configuration (Agent Mode)..."); - - let system = LinuxSystemResource::new(); - let files = LocalFileResource::new(); - - if let Some(path) = record_path { - let recorder_system = Recorder::new(system); - let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); - - apply(&recorder_system, &recorder_files)?; - - let ops = recorder_system.get_ops(); - let yaml = serde_yaml::to_string(&ops)?; - fs::write(&path, yaml).context("Failed to write recording")?; - info!("Recording saved to {}", path.display()); - } else { - apply(&system, &files)?; + Commands::Apply { record } => { + skillet_cli_common::handle_apply("(Agent Mode)", record) + .map_err(|e| anyhow!("Failed to apply configuration: {}", e))?; + } + Commands::Test { test_command } => handle_test(test_command)?, } - - info!("Configuration applied successfully."); Ok(()) } @@ -114,7 +91,28 @@ fn handle_test(cmd: TestCommands) -> Result<()> { } fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<()> { - // 1. Build binary + build_workspace()?; + + let binary_path = locate_binary(hostname)?; + let container_name = format!("skillet-test-{}", hostname); + + setup_container(&container_name, image, &binary_path)?; + + let result = (|| -> Result<()> { + prepare_and_run_skillet(&container_name)?; + verify_or_record(hostname, &container_name, is_record)?; + Ok(()) + })(); + + info!("Stopping container..."); + let _ = Command::new("podman") + .args(["kill", &container_name]) + .output(); + + result +} + +fn build_workspace() -> Result<()> { info!("Building skillet workspace..."); let build_status = Command::new("cargo") .args(["build"]) @@ -124,8 +122,10 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() if !build_status.success() { return Err(anyhow!("Build failed")); } + Ok(()) +} - // 2. Locate binary (with fallback) +fn locate_binary(hostname: &str) -> Result { let host_binary_name = format!("skillet-{}", hostname); let target_debug = PathBuf::from("target/debug"); @@ -146,17 +146,17 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() binary_path.display() )); } - let abs_binary_path = fs::canonicalize(&binary_path)?; + fs::canonicalize(&binary_path).context("Failed to canonicalize binary path") +} - // 3. Start Container - let container_name = format!("skillet-test-{}", hostname); +fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Result<()> { info!( "Starting container {} from image {}...", container_name, image ); let _ = Command::new("podman") - .args(["rm", "-f", &container_name]) + .args(["rm", "-f", container_name]) .output(); let run_status = Command::new("podman") @@ -165,9 +165,9 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() "-d", "--rm", "--name", - &container_name, + container_name, "-v", - &format!("{}:/usr/bin/skillet:ro", abs_binary_path.display()), + &format!("{}:/usr/bin/skillet:ro", binary_path.display()), image, "sleep", "infinity", @@ -178,133 +178,128 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() if !run_status.success() { return Err(anyhow!("Failed to start container")); } + Ok(()) +} - let result = (|| -> Result<()> { - // Prepare entrypoint script - let entrypoint_content = include_str!("test_entrypoint.sh"); - let mut temp_entrypoint = tempfile::Builder::new().suffix(".sh").tempfile()?; - use std::io::Write; - temp_entrypoint.write_all(entrypoint_content.as_bytes())?; - let temp_entrypoint_path = temp_entrypoint - .path() - .to_str() - .ok_or_else(|| anyhow!("Entrypoint path is not valid UTF-8"))?; +fn prepare_and_run_skillet(container_name: &str) -> Result<()> { + // Prepare entrypoint script + let entrypoint_content = include_str!("test_entrypoint.sh"); + let mut temp_entrypoint = tempfile::Builder::new().suffix(".sh").tempfile()?; + temp_entrypoint.write_all(entrypoint_content.as_bytes())?; + let temp_entrypoint_path = temp_entrypoint + .path() + .to_str() + .ok_or_else(|| anyhow!("Entrypoint path is not valid UTF-8"))?; + + // Copy entrypoint to container + info!("Copying test entrypoint to container..."); + let cp_status = Command::new("podman") + .args([ + "cp", + temp_entrypoint_path, + &format!("{}:/tmp/test_entrypoint.sh", container_name), + ]) + .status() + .context("Failed to copy entrypoint")?; + + if !cp_status.success() { + return Err(anyhow!("Failed to copy entrypoint to container")); + } - // Copy entrypoint to container - info!("Copying test entrypoint to container..."); + // Make executable + let chmod_status = Command::new("podman") + .args([ + "exec", + container_name, + "chmod", + "+x", + "/tmp/test_entrypoint.sh", + ]) + .status() + .context("Failed to chmod entrypoint")?; + + if !chmod_status.success() { + return Err(anyhow!("Failed to chmod entrypoint in container")); + } + + info!("Executing skillet inside container..."); + let exec_status = Command::new("podman") + .args([ + "exec", + container_name, + "/tmp/test_entrypoint.sh", + "skillet", + "apply", + "--record", + "/tmp/ops.yaml", + ]) + .status() + .context("Failed to exec skillet")?; + + if !exec_status.success() { + return Err(anyhow!("skillet apply failed inside container")); + } + Ok(()) +} + +fn verify_or_record(hostname: &str, container_name: &str, is_record: bool) -> Result<()> { + let dest_dir = PathBuf::from("integration_tests/recordings"); + fs::create_dir_all(&dest_dir)?; + let dest_file = dest_dir.join(format!("{}.yaml", hostname)); + + if is_record { + info!("Copying recording to {}", dest_file.display()); let cp_status = Command::new("podman") .args([ "cp", - temp_entrypoint_path, - &format!("{}:/tmp/test_entrypoint.sh", container_name), + &format!("{}:/tmp/ops.yaml", container_name), + dest_file + .to_str() + .ok_or_else(|| anyhow!("Destination path is not valid UTF-8"))?, ]) - .status() - .context("Failed to copy entrypoint")?; + .status()?; if !cp_status.success() { - return Err(anyhow!("Failed to copy entrypoint to container")); - } - - // Make executable - let chmod_status = Command::new("podman") - .args([ - "exec", - &container_name, - "chmod", - "+x", - "/tmp/test_entrypoint.sh", - ]) - .status() - .context("Failed to chmod entrypoint")?; - - if !chmod_status.success() { - return Err(anyhow!("Failed to chmod entrypoint in container")); + return Err(anyhow!("Failed to copy recording from container")); } + } else { + info!("Verifying recording..."); + let temp_dest = tempfile::Builder::new().suffix(".yaml").tempfile()?; + let temp_path = temp_dest + .path() + .to_str() + .ok_or_else(|| anyhow!("Temporary path is not valid UTF-8"))?; - info!("Executing skillet inside container..."); - let exec_status = Command::new("podman") + let cp_status = Command::new("podman") .args([ - "exec", - &container_name, - "/tmp/test_entrypoint.sh", - "skillet", - "apply", - "--record", - "/tmp/ops.yaml", + "cp", + &format!("{}:/tmp/ops.yaml", container_name), + temp_path, ]) - .status() - .context("Failed to exec skillet")?; - - if !exec_status.success() { - return Err(anyhow!("skillet apply failed inside container")); + .status()?; + if !cp_status.success() { + return Err(anyhow!("Failed to copy recording from container")); } - let dest_dir = PathBuf::from("integration_tests/recordings"); - fs::create_dir_all(&dest_dir)?; - let dest_file = dest_dir.join(format!("{}.yaml", hostname)); - - if is_record { - info!("Copying recording to {}", dest_file.display()); - let cp_status = Command::new("podman") - .args([ - "cp", - &format!("{}:/tmp/ops.yaml", container_name), - dest_file - .to_str() - .ok_or_else(|| anyhow!("Destination path is not valid UTF-8"))?, - ]) - .status()?; - - if !cp_status.success() { - return Err(anyhow!("Failed to copy recording from container")); - } + let recorded_content = fs::read_to_string(&dest_file).context(format!( + "Failed to read existing recording at {}", + dest_file.display() + ))?; + let new_content = fs::read_to_string(temp_path)?; + + let recorded_ops: Vec = serde_yml::from_str(&recorded_content)?; + let new_ops: Vec = serde_yml::from_str(&new_content)?; + + if recorded_ops != new_ops { + error!("Recording mismatch!"); + error!("Expected: {:?}", recorded_ops); + error!("Actual: {:?}", new_ops); + return Err(anyhow!( + "Integration test failed: Actions do not match recording." + )); } else { - info!("Verifying recording..."); - let temp_dest = tempfile::Builder::new().suffix(".yaml").tempfile()?; - let temp_path = temp_dest - .path() - .to_str() - .ok_or_else(|| anyhow!("Temporary path is not valid UTF-8"))?; - - let cp_status = Command::new("podman") - .args([ - "cp", - &format!("{}:/tmp/ops.yaml", container_name), - temp_path, - ]) - .status()?; - if !cp_status.success() { - return Err(anyhow!("Failed to copy recording from container")); - } - - let recorded_content = fs::read_to_string(&dest_file).context(format!( - "Failed to read existing recording at {}", - dest_file.display() - ))?; - let new_content = fs::read_to_string(temp_path)?; - - let recorded_ops: Vec = serde_yaml::from_str(&recorded_content)?; - let new_ops: Vec = serde_yaml::from_str(&new_content)?; - - if recorded_ops != new_ops { - error!("Recording mismatch!"); - error!("Expected: {:?}", recorded_ops); - error!("Actual: {:?}", new_ops); - return Err(anyhow!( - "Integration test failed: Actions do not match recording." - )); - } else { - info!("Integration test passed!"); - } + info!("Integration test passed!"); } - - Ok(()) - })(); - - info!("Stopping container..."); - let _ = Command::new("podman") - .args(["kill", &container_name]) - .output(); - - result + } + Ok(()) } diff --git a/ublue/skillet/crates/hardening/Cargo.toml b/ublue/skillet/crates/hardening/Cargo.toml index ea4c8203..7e2dac0f 100644 --- a/ublue/skillet/crates/hardening/Cargo.toml +++ b/ublue/skillet/crates/hardening/Cargo.toml @@ -4,9 +4,10 @@ version = "0.1.0" edition = "2021" [dependencies] -skillet_core = { workspace = true, features = ["test-utils"] } +skillet_core.workspace = true thiserror.workspace = true tracing.workspace = true [dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } tempfile.workspace = true From e218cdc485f8d20b8b36197f88c5cc1d08651e0a Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 3 Apr 2026 08:22:51 +0200 Subject: [PATCH 009/252] feat(skillet): implement ssh hardening and enforce pedantic clippy lints --- ublue/skillet/AGENTS.md | 2 +- ublue/skillet/Cargo.toml | 9 + ublue/skillet/crates/cli-common/Cargo.toml | 3 + ublue/skillet/crates/cli/Cargo.toml | 3 + ublue/skillet/crates/cli/src/main.rs | 28 ++- ublue/skillet/crates/core/Cargo.toml | 3 + ublue/skillet/crates/core/src/files.rs | 37 +++- ublue/skillet/crates/core/src/recorder.rs | 27 ++- ublue/skillet/crates/core/src/resource_op.rs | 8 +- ublue/skillet/crates/core/src/system.rs | 21 +-- ublue/skillet/crates/core/src/system/tests.rs | 6 +- ublue/skillet/crates/core/src/test_utils.rs | 32 +++- ublue/skillet/crates/hardening/Cargo.toml | 3 + .../skillet/crates/hardening/files/ssh_config | 100 ++++++++++ .../crates/hardening/files/sshd_config | 173 ++++++++++++++++++ ublue/skillet/crates/hardening/src/lib.rs | 50 +++-- ublue/skillet/crates/hardening/src/tests.rs | 39 +++- .../skillet/crates/hosts/beezelbot/Cargo.toml | 3 + 18 files changed, 482 insertions(+), 65 deletions(-) create mode 100644 ublue/skillet/crates/hardening/files/ssh_config create mode 100644 ublue/skillet/crates/hardening/files/sshd_config diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index e18d8009..3db8fdc1 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -21,7 +21,7 @@ This document defines the architectural mandates and project structure for `skil - **Abstractions**: Use Traits (e.g., `FileResource`, `SystemResource`) to allow for mocking in higher-level library tests. ### 4. Quality Control & Validation -- **Formatting & Linting**: Always run `cargo fmt` and `cargo clippy` after making changes to ensure code quality and consistency. +- **Formatting & Linting**: Always run `cargo fmt` and `cargo clippy` after making changes to ensure code quality and consistency. **Clippy MUST be run with `pedantic` lints enabled (configured in `Cargo.toml`).** - **Verification**: Always run both: - **Unit Tests**: `cargo test` across the workspace. - **Integration Tests**: `skillet test run ` for affected hosts to verify end-to-end correctness in a containerized environment. diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 0f4fd941..4eb3947a 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -24,3 +24,12 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yml = "0.0.12" hex = "0.4" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +missing_errors_doc = "allow" +must_use_candidate = "allow" +# Optional: explicitly allow some pedantic lints if they are too noisy diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index 3818197a..e91d6281 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -3,6 +3,9 @@ name = "skillet_cli_common" version = "0.1.0" edition = "2021" +[lints] +workspace = true + [dependencies] skillet_core.workspace = true skillet_hardening.workspace = true diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 4cccda54..5cc13aaf 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -3,6 +3,9 @@ name = "skillet" version = "0.1.0" edition = "2021" +[lints] +workspace = true + [dependencies] skillet_core.workspace = true skillet_hardening.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index da445010..f505cfe3 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -66,7 +66,7 @@ fn main() -> Result<()> { match args.command { Commands::Apply { record } => { skillet_cli_common::handle_apply("(Agent Mode)", record) - .map_err(|e| anyhow!("Failed to apply configuration: {}", e))?; + .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; } Commands::Test { test_command } => handle_test(test_command)?, } @@ -94,7 +94,7 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() build_workspace()?; let binary_path = locate_binary(hostname)?; - let container_name = format!("skillet-test-{}", hostname); + let container_name = format!("skillet-test-{hostname}"); setup_container(&container_name, image, &binary_path)?; @@ -126,16 +126,15 @@ fn build_workspace() -> Result<()> { } fn locate_binary(hostname: &str) -> Result { - let host_binary_name = format!("skillet-{}", hostname); + let host_binary_name = format!("skillet-{hostname}"); let target_debug = PathBuf::from("target/debug"); let binary_path = if target_debug.join(&host_binary_name).exists() { - info!("Found host-specific binary: {}", host_binary_name); + info!("Found host-specific binary: {host_binary_name}"); target_debug.join(&host_binary_name) } else { info!( - "Using generic skillet binary (host binary {} not found)", - host_binary_name + "Using generic skillet binary (host binary {host_binary_name} not found)" ); target_debug.join("skillet") }; @@ -151,8 +150,7 @@ fn locate_binary(hostname: &str) -> Result { fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Result<()> { info!( - "Starting container {} from image {}...", - container_name, image + "Starting container {container_name} from image {image}..." ); let _ = Command::new("podman") @@ -197,7 +195,7 @@ fn prepare_and_run_skillet(container_name: &str) -> Result<()> { .args([ "cp", temp_entrypoint_path, - &format!("{}:/tmp/test_entrypoint.sh", container_name), + &format!("{container_name}:/tmp/test_entrypoint.sh"), ]) .status() .context("Failed to copy entrypoint")?; @@ -245,14 +243,14 @@ fn prepare_and_run_skillet(container_name: &str) -> Result<()> { fn verify_or_record(hostname: &str, container_name: &str, is_record: bool) -> Result<()> { let dest_dir = PathBuf::from("integration_tests/recordings"); fs::create_dir_all(&dest_dir)?; - let dest_file = dest_dir.join(format!("{}.yaml", hostname)); + let dest_file = dest_dir.join(format!("{hostname}.yaml")); if is_record { info!("Copying recording to {}", dest_file.display()); let cp_status = Command::new("podman") .args([ "cp", - &format!("{}:/tmp/ops.yaml", container_name), + &format!("{container_name}:/tmp/ops.yaml"), dest_file .to_str() .ok_or_else(|| anyhow!("Destination path is not valid UTF-8"))?, @@ -273,7 +271,7 @@ fn verify_or_record(hostname: &str, container_name: &str, is_record: bool) -> Re let cp_status = Command::new("podman") .args([ "cp", - &format!("{}:/tmp/ops.yaml", container_name), + &format!("{container_name}:/tmp/ops.yaml"), temp_path, ]) .status()?; @@ -290,15 +288,15 @@ fn verify_or_record(hostname: &str, container_name: &str, is_record: bool) -> Re let recorded_ops: Vec = serde_yml::from_str(&recorded_content)?; let new_ops: Vec = serde_yml::from_str(&new_content)?; - if recorded_ops != new_ops { + if recorded_ops == new_ops { + info!("Integration test passed!"); + } else { error!("Recording mismatch!"); error!("Expected: {:?}", recorded_ops); error!("Actual: {:?}", new_ops); return Err(anyhow!( "Integration test failed: Actions do not match recording." )); - } else { - info!("Integration test passed!"); } } Ok(()) diff --git a/ublue/skillet/crates/core/Cargo.toml b/ublue/skillet/crates/core/Cargo.toml index cd8f2250..240af0c6 100644 --- a/ublue/skillet/crates/core/Cargo.toml +++ b/ublue/skillet/crates/core/Cargo.toml @@ -3,6 +3,9 @@ name = "skillet_core" version = "0.1.0" edition = "2021" +[lints] +workspace = true + [dependencies] thiserror.workspace = true sha2.workspace = true diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 85891237..41e1ef78 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -40,6 +40,13 @@ pub trait FileResource { owner: Option<&str>, group: Option<&str>, ) -> Result; + fn ensure_directory( + &self, + path: &Path, + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result; fn delete_file(&self, path: &Path) -> Result; } @@ -51,7 +58,6 @@ impl LocalFileResource { } fn check_metadata( - &self, path: &Path, mode: Option, owner: Option<&str>, @@ -87,7 +93,6 @@ impl LocalFileResource { } fn apply_metadata( - &self, path: &Path, mode: Option, owner: Option<&str>, @@ -179,8 +184,8 @@ impl FileResource for LocalFileResource { } // 3. Check and apply metadata - if path.exists() && self.check_metadata(path, mode, owner, group)? { - self.apply_metadata(path, mode, owner, group)?; + if path.exists() && Self::check_metadata(path, mode, owner, group)? { + Self::apply_metadata(path, mode, owner, group)?; changed = true; info!("Updated file metadata for {}", path.display()); } @@ -188,6 +193,30 @@ impl FileResource for LocalFileResource { Ok(changed) } + fn ensure_directory( + &self, + path: &Path, + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result { + let mut changed = false; + + if !path.exists() { + fs::create_dir_all(path).map_err(FileError::Io)?; + changed = true; + info!("Created directory {}", path.display()); + } + + if path.exists() && Self::check_metadata(path, mode, owner, group)? { + Self::apply_metadata(path, mode, owner, group)?; + changed = true; + info!("Updated directory metadata for {}", path.display()); + } + + Ok(changed) + } + fn delete_file(&self, path: &Path) -> Result { if path.exists() { fs::remove_file(path).map_err(FileError::Io)?; diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index 89aa6b96..18cb9127 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -23,7 +23,7 @@ impl Recorder { } pub fn get_ops(&self) -> Vec { - self.ops.lock().unwrap_or_else(|e| e.into_inner()).clone() + self.ops.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() } pub fn shared_ops(&self) -> Arc>> { @@ -31,7 +31,7 @@ impl Recorder { } fn record(&self, op: ResourceOp) { - self.ops.lock().unwrap_or_else(|e| e.into_inner()).push(op); + self.ops.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(op); } } @@ -51,14 +51,31 @@ impl FileResource for Recorder { self.record(ResourceOp::EnsureFile { path: path.display().to_string(), content_hash: hash, - mode: mode.map(|m| format!("0o{:o}", m)), - owner: owner.map(|s| s.to_string()), - group: group.map(|s| s.to_string()), + mode: mode.map(|m| format!("0o{m:o}")), + owner: owner.map(ToString::to_string), + group: group.map(ToString::to_string), }); self.inner.ensure_file(path, content, mode, owner, group) } + fn ensure_directory( + &self, + path: &Path, + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result { + self.record(ResourceOp::EnsureDirectory { + path: path.display().to_string(), + mode: mode.map(|m| format!("0o{m:o}")), + owner: owner.map(ToString::to_string), + group: group.map(ToString::to_string), + }); + + self.inner.ensure_directory(path, mode, owner, group) + } + fn delete_file(&self, path: &Path) -> Result { self.record(ResourceOp::DeleteFile { path: path.display().to_string(), diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index f83845e5..1896bcdb 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] pub enum ResourceOp { EnsureFile { path: String, @@ -12,6 +12,12 @@ pub enum ResourceOp { DeleteFile { path: String, }, + EnsureDirectory { + path: String, + mode: Option, + owner: Option, + group: Option, + }, EnsureGroup { name: String, }, diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index a4ed3715..d4df4569 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -27,15 +27,14 @@ impl LinuxSystemResource { Self } - fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { - info!("Running systemctl {} {}", action, name); + fn run_systemctl(action: &str, name: &str) -> Result<(), SystemError> { + info!("Running systemctl {action} {name}"); let output = Command::new("systemctl").arg(action).arg(name).output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(SystemError::Command(format!( - "systemctl {} {} failed: {}", - action, name, stderr + "systemctl {action} {name} failed: {stderr}" ))); } Ok(()) @@ -52,13 +51,13 @@ impl SystemResource for LinuxSystemResource { fn ensure_group(&self, name: &str) -> Result { // 1. Check if group exists using `users` crate if get_group_by_name(name).is_some() { - debug!("Group {} already exists", name); + debug!("Group {name} already exists"); return Ok(false); } // 2. Create group using `groupadd` // Note: Creating groups requires root privileges usually. - info!("Creating group {}", name); + info!("Creating group {name}"); let output = Command::new("groupadd") .arg(name) // .arg("-r") // System group? Maybe make it an option? @@ -67,23 +66,23 @@ impl SystemResource for LinuxSystemResource { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(SystemError::Command(format!("groupadd failed: {}", stderr))); + return Err(SystemError::Command(format!("groupadd failed: {stderr}"))); } - info!("Created group {}", name); + info!("Created group {name}"); Ok(true) } fn service_start(&self, name: &str) -> Result<(), SystemError> { - self.run_systemctl("start", name) + Self::run_systemctl("start", name) } fn service_stop(&self, name: &str) -> Result<(), SystemError> { - self.run_systemctl("stop", name) + Self::run_systemctl("stop", name) } fn service_restart(&self, name: &str) -> Result<(), SystemError> { - self.run_systemctl("restart", name) + Self::run_systemctl("restart", name) } } #[cfg(test)] diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index a34a9d79..8c50d2ae 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -11,7 +11,7 @@ fn test_mock_system_resource() { assert!(system .groups .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .contains("syslog")); let changed_again = system.ensure_group("syslog").unwrap(); @@ -27,7 +27,7 @@ fn test_mock_system_services() { system .services .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .get("test-service") .unwrap(), "started" @@ -38,7 +38,7 @@ fn test_mock_system_services() { system .services .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .get("test-service") .unwrap(), "restarted" diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 724d42c7..f4c5b1c4 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -26,7 +26,7 @@ impl Default for MockSystem { impl SystemResource for MockSystem { fn ensure_group(&self, name: &str) -> Result { - let mut groups = self.groups.lock().unwrap_or_else(|e| e.into_inner()); + let mut groups = self.groups.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if groups.contains(name) { Ok(false) } else { @@ -38,7 +38,7 @@ impl SystemResource for MockSystem { fn service_start(&self, name: &str) -> Result<(), SystemError> { self.services .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .insert(name.to_string(), "started".to_string()); Ok(()) } @@ -46,7 +46,7 @@ impl SystemResource for MockSystem { fn service_stop(&self, name: &str) -> Result<(), SystemError> { self.services .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .insert(name.to_string(), "stopped".to_string()); Ok(()) } @@ -54,7 +54,7 @@ impl SystemResource for MockSystem { fn service_restart(&self, name: &str) -> Result<(), SystemError> { self.services .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .insert(name.to_string(), "restarted".to_string()); Ok(()) } @@ -92,8 +92,8 @@ impl FileResource for MockFiles { group: Option<&str>, ) -> Result { let path_str = path.display().to_string(); - let mut files = self.files.lock().unwrap_or_else(|e| e.into_inner()); - let mut metadata = self.metadata.lock().unwrap_or_else(|e| e.into_inner()); + let mut files = self.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut metadata = self.metadata.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let mut changed = false; @@ -109,8 +109,8 @@ impl FileResource for MockFiles { let new_meta = ( mode, - owner.map(|s| s.to_string()), - group.map(|s| s.to_string()), + owner.map(ToString::to_string), + group.map(ToString::to_string), ); if let Some(existing_meta) = metadata.get(&path_str) { if existing_meta != &new_meta { @@ -125,10 +125,22 @@ impl FileResource for MockFiles { Ok(changed) } + fn ensure_directory( + &self, + _path: &Path, + _mode: Option, + _owner: Option<&str>, + _group: Option<&str>, + ) -> Result { + // For mock, we don't really track directories separately for now, + // but we could if needed. Just return Ok(false) as if it exists. + Ok(false) + } + fn delete_file(&self, path: &Path) -> Result { let path_str = path.display().to_string(); - let mut files = self.files.lock().unwrap_or_else(|e| e.into_inner()); - let mut metadata = self.metadata.lock().unwrap_or_else(|e| e.into_inner()); + let mut files = self.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut metadata = self.metadata.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let f_removed = files.remove(&path_str).is_some(); let m_removed = metadata.remove(&path_str).is_some(); diff --git a/ublue/skillet/crates/hardening/Cargo.toml b/ublue/skillet/crates/hardening/Cargo.toml index 7e2dac0f..6d818c18 100644 --- a/ublue/skillet/crates/hardening/Cargo.toml +++ b/ublue/skillet/crates/hardening/Cargo.toml @@ -3,6 +3,9 @@ name = "skillet_hardening" version = "0.1.0" edition = "2021" +[lints] +workspace = true + [dependencies] skillet_core.workspace = true thiserror.workspace = true diff --git a/ublue/skillet/crates/hardening/files/ssh_config b/ublue/skillet/crates/hardening/files/ssh_config new file mode 100644 index 00000000..b9e0a37f --- /dev/null +++ b/ublue/skillet/crates/hardening/files/ssh_config @@ -0,0 +1,100 @@ +# **Note:** This file was automatically created by Hardening Framework (dev-sec.io) configuration. If you use its automated setup, do not edit this file directly, but adjust the automation instead. +#--- + +# This is the ssh client system-wide configuration file. +# See ssh_config(5) for more information on any settings used. Comments will be added only to clarify why a configuration was chosen. +# +# Created for OpenSSH v5.9 up to 6.8 + +# Basic configuration +# =================== + +# Address family should always be limited to the active network configuration. +AddressFamily any + + +# The port at the destination should be defined +Port 22 + +# Identity file configuration. You may restrict available identity files. Otherwise ssh will search for a pattern and use any that matches. +#IdentityFile ~/.ssh/identity +#IdentityFile ~/.ssh/id_rsa +#IdentityFile ~/.ssh/id_dsa + + +# Security configuration +# ====================== + +# Set the protocol version to 2 for security reasons. Disables legacy support. +Protocol 2 + +# Make sure passphrase querying is enabled +BatchMode no + +# Prevent IP spoofing by checking to host IP against the `known_hosts` file. +CheckHostIP yes + +# Always ask before adding keys to the `known_hosts` file. Do not set to `yes`. +StrictHostKeyChecking ask + +# **Ciphers** -- If your clients don't support CTR (eg older versions), cbc will be added +# CBC: is true if you want to connect with OpenSSL-base libraries +# eg ruby Net::SSH::Transport::CipherFactory requires cbc-versions of the given openssh ciphers to work +# -- see: (http://net-ssh.github.com/net-ssh/classes/Net/SSH/Transport/CipherFactory.html) +# +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# **Hash algorithms** -- Make sure not to use SHA1 for hashing, unless it is really necessary. +# Weak HMAC is sometimes required if older package versions are used +# eg Ruby's Net::SSH at around 2.2.* doesn't support sha2 for hmac, so this will have to be set true in this case. +# +MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# Alternative setting, if OpenSSH version is below v5.9 +#MACs hmac-ripemd160 + +# **Key Exchange Algorithms** -- Make sure not to use SHA1 for kex, unless it is really necessary +# Weak kex is sometimes required if older package versions are used +# eg ruby's Net::SSH at around 2.2.* doesn't support sha2 for kex, so this will have to be set true in this case. +# +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + + +# Disable agent formwarding, since local agent could be accessed through forwarded connection. +ForwardAgent no + +# Disable X11 forwarding, since local X11 display could be accessed through forwarded connection. +ForwardX11 no + +# Never use host-based authentication. It can be exploited. +HostbasedAuthentication no + + +# Disable password-based authentication, it can allow for potentially easier brute-force attacks. +PasswordAuthentication no + +# Only use GSSAPIAuthentication if implemented on the network. +GSSAPIAuthentication no +GSSAPIDelegateCredentials no + +# Disable tunneling +Tunnel no + +# Disable local command execution. +PermitLocalCommand no + + +# Misc. configuration +# =================== + +# Enable compression. More pressure on the CPU, less on the network. +Compression yes + +#EscapeChar ~ +#VisualHostKey yes + +# http://undeadly.org/cgi?action=article&sid=20160114142733 +UseRoaming no + +# Send locale environment variables +SendEnv LANG LC_* LANGUAGE diff --git a/ublue/skillet/crates/hardening/files/sshd_config b/ublue/skillet/crates/hardening/files/sshd_config new file mode 100644 index 00000000..ec954166 --- /dev/null +++ b/ublue/skillet/crates/hardening/files/sshd_config @@ -0,0 +1,173 @@ +# **Note:** This file was automatically created by Hardening Framework (dev-sec.io) configuration. If you use its automated setup, do not edit this file directly, but adjust the automation instead. +#--- + +# This is the ssh client system-wide configuration file. +# See sshd_config(5) for more information on any settings used. Comments will be added only to clarify why a configuration was chosen. +# +# Created for OpenSSH v5.9 up to 6.8 + +# Basic configuration +# =================== + +# Either disable or only allow root login via certificates. +PermitRootLogin without-password + +# Define which port sshd should listen to. Default to `22`. +Port 22 + +# Address family should always be limited to the active network configuration. +AddressFamily any + +# Define which addresses sshd should listen to. Default to `0.0.0.0`, ie make sure you put your desired address in here, since otherwise sshd will listen to everyone. +ListenAddress 0.0.0.0 +ListenAddress :: + +# List HostKeys here. +HostKey /etc/ssh/ssh_host_rsa_key # Req 20 +HostKey /etc/ssh/ssh_host_ecdsa_key # Req 20 +HostKey /etc/ssh/ssh_host_ed25519_key # Req 20 + + +# Security configuration +# ====================== + +# Set the protocol version to 2 for security reasons. Disables legacy support. +Protocol 2 + +# Make sure sshd checks file modes and ownership before accepting logins. This prevents accidental misconfiguration. +StrictModes yes + +# Logging, obsoletes QuietMode and FascistLogging +SyslogFacility AUTH +LogLevel VERBOSE + +# Cryptography +# ------------ + +# **Ciphers** -- If your clients don't support CTR (eg older versions), cbc will be added +# CBC: is true if you want to connect with OpenSSL-base libraries +# eg ruby Net::SSH::Transport::CipherFactory requires cbc-versions of the given openssh ciphers to work +# -- see: (http://net-ssh.github.com/net-ssh/classes/Net/SSH/Transport/CipherFactory.html) +# +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# **Hash algorithms** -- Make sure not to use SHA1 for hashing, unless it is really necessary. +# Weak HMAC is sometimes required if older package versions are used +# eg Ruby's Net::SSH at around 2.2.* doesn't support sha2 for hmac, so this will have to be set true in this case. +# +MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# Alternative setting, if OpenSSH version is below v5.9 +#MACs hmac-ripemd160 + +# **Key Exchange Algorithms** -- Make sure not to use SHA1 for kex, unless it is really necessary +# Weak kex is sometimes required if older package versions are used +# eg ruby's Net::SSH at around 2.2.* doesn't support sha2 for kex, so this will have to be set true in this case. +# based on: https://bettercrypto.org/static/applied-crypto-hardening.pdf +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# Authentication +# -------------- + +# Secure Login directives. +PermitUserEnvironment no +LoginGraceTime 30s +MaxAuthTries 2 +MaxSessions 10 +MaxStartups 10:30:100 + +# Enable public key authentication +PubkeyAuthentication yes + + +# Never use host-based authentication. It can be exploited. +IgnoreRhosts yes +IgnoreUserKnownHosts yes +HostbasedAuthentication no + +# Enable PAM to enforce system wide rules +UsePAM yes +# Disable password-based authentication, it can allow for potentially easier brute-force attacks. +PasswordAuthentication no +PermitEmptyPasswords no +ChallengeResponseAuthentication no + +# Only enable Kerberos authentication if it is configured. +KerberosAuthentication no +KerberosOrLocalPasswd no +KerberosTicketCleanup yes +#KerberosGetAFSToken no + +# Only enable GSSAPI authentication if it is configured. +GSSAPIAuthentication no +GSSAPICleanupCredentials yes + +#DenyUsers * +#AllowUsers user1 +#DenyGroups * +#AllowGroups group1 + + +# Network +# ------- + +# Disable TCP keep alive since it is spoofable. Use ClientAlive messages instead, they use the encrypted channel +TCPKeepAlive no + +# Manage `ClientAlive..` signals via interval and maximum count. This will periodically check up to a `..CountMax` number of times within `..Interval` timeframe, and abort the connection once these fail. +ClientAliveInterval 300 +ClientAliveCountMax 3 + +# Disable tunneling +PermitTunnel no + +# Disable forwarding tcp connections. +# no real advantage without denied shell access +AllowTcpForwarding yes + +# Disable agent formwarding, since local agent could be accessed through forwarded connection. +# no real advantage without denied shell access +AllowAgentForwarding no + +# Do not allow remote port forwardings to bind to non-loopback addresses. +GatewayPorts no + +# Disable X11 forwarding, since local X11 display could be accessed through forwarded connection. +X11Forwarding no +X11UseLocalhost yes + + +# Misc. configuration +# =================== + + +PrintMotd no +PrintLastLog no +Banner none + + +# Since OpenSSH 6.8, this value defaults to 'no' +#UseDNS no +#PidFile /var/run/sshd.pid +#MaxStartups 10 +#ChrootDirectory none +#ChrootDirectory /home/%u + +# Accept locale environment variables +AcceptEnv LANG LC_* LANGUAGE + + +# Configuration, in case SFTP is used +## override default of no subsystems +## Subsystem sftp /opt/app/openssh5/libexec/sftp-server +Subsystem sftp internal-sftp -l VERBOSE + +## These lines must appear at the *end* of sshd_config +Match Group sftponly + ForceCommand internal-sftp -l VERBOSE + ChrootDirectory /home/%u + AllowTcpForwarding no + AllowAgentForwarding no + PasswordAuthentication no + PermitRootLogin no + X11Forwarding no diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index c8f6e0a3..260df8f3 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -23,13 +23,13 @@ where apply_sysctl_hardening(system, files)?; // 2. Include 'os-hardening' - apply_os_hardening(system)?; + apply_os_hardening(system); // 3. Include 'ssh-hardening::server' - apply_ssh_hardening_server(system)?; + apply_ssh_hardening_server(system, files)?; // 4. Include 'ssh-hardening::client' - apply_ssh_hardening_client(system)?; + apply_ssh_hardening_client(system, files)?; Ok(()) } @@ -53,22 +53,46 @@ where Ok(()) } -fn apply_os_hardening(_system: &S) -> Result<(), HardeningError> { +fn apply_os_hardening(_system: &S) { info!("(Placeholder) Applying os-hardening"); - Ok(()) } -fn apply_ssh_hardening_server( - _system: &S, -) -> Result<(), HardeningError> { - info!("(Placeholder) Applying ssh-hardening::server"); +fn apply_ssh_hardening_server(system: &S, files: &F) -> Result<(), HardeningError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Applying ssh-hardening::server"); + let ssh_dir = Path::new("/etc/ssh"); + files.ensure_directory(ssh_dir, Some(0o755), Some("root"), Some("root"))?; + + let content = include_bytes!("../files/sshd_config"); + let path = Path::new("/etc/ssh/sshd_config"); + + let changed = files.ensure_file(path, content, Some(0o600), Some("root"), Some("root"))?; + + if changed { + info!("SSH server configuration changed, restarting sshd..."); + system.service_restart("sshd")?; + } + Ok(()) } -fn apply_ssh_hardening_client( - _system: &S, -) -> Result<(), HardeningError> { - info!("(Placeholder) Applying ssh-hardening::client"); +fn apply_ssh_hardening_client(_system: &S, files: &F) -> Result<(), HardeningError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Applying ssh-hardening::client"); + let ssh_dir = Path::new("/etc/ssh"); + files.ensure_directory(ssh_dir, Some(0o755), Some("root"), Some("root"))?; + + let content = include_bytes!("../files/ssh_config"); + let path = Path::new("/etc/ssh/ssh_config"); + + files.ensure_file(path, content, Some(0o644), Some("root"), Some("root"))?; + Ok(()) } diff --git a/ublue/skillet/crates/hardening/src/tests.rs b/ublue/skillet/crates/hardening/src/tests.rs index eed864d8..6ef71625 100644 --- a/ublue/skillet/crates/hardening/src/tests.rs +++ b/ublue/skillet/crates/hardening/src/tests.rs @@ -9,15 +9,50 @@ fn test_hardening_applies_sysctl() { assert!(files .files .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .contains_key("/etc/sysctl.d/99-hardening.conf")); assert_eq!( system .services .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .get("systemd-sysctl") .unwrap(), "restarted" ); } + +#[test] +fn test_hardening_applies_ssh_server() { + let system = MockSystem::new(); + let files = MockFiles::new(); + apply(&system, &files).unwrap(); + let files_map = files.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(files_map.contains_key("/etc/ssh/sshd_config")); + + let content = String::from_utf8(files_map.get("/etc/ssh/sshd_config").unwrap().clone()).unwrap(); + assert!(content.contains("PermitRootLogin without-password")); + assert!(content.contains("Ciphers chacha20-poly1305@openssh.com")); + + assert_eq!( + system + .services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get("sshd") + .unwrap(), + "restarted" + ); +} + +#[test] +fn test_hardening_applies_ssh_client() { + let system = MockSystem::new(); + let files = MockFiles::new(); + apply(&system, &files).unwrap(); + let files_map = files.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(files_map.contains_key("/etc/ssh/ssh_config")); + + let content = String::from_utf8(files_map.get("/etc/ssh/ssh_config").unwrap().clone()).unwrap(); + assert!(content.contains("StrictHostKeyChecking ask")); +} diff --git a/ublue/skillet/crates/hosts/beezelbot/Cargo.toml b/ublue/skillet/crates/hosts/beezelbot/Cargo.toml index 666fe7f9..4292cd45 100644 --- a/ublue/skillet/crates/hosts/beezelbot/Cargo.toml +++ b/ublue/skillet/crates/hosts/beezelbot/Cargo.toml @@ -3,6 +3,9 @@ name = "skillet-beezelbot" version = "0.1.0" edition = "2021" +[lints] +workspace = true + [dependencies] skillet_cli_common.workspace = true anyhow = "1.0" From 04350ebc330704c352754bc6b04d4c1d8ed2c7f8 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 3 Apr 2026 08:40:39 +0200 Subject: [PATCH 010/252] test(skillet): update beezelbot integration test recording --- .../recordings/beezelbot.yaml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ublue/skillet/integration_tests/recordings/beezelbot.yaml b/ublue/skillet/integration_tests/recordings/beezelbot.yaml index 090c8dd3..09b6b627 100644 --- a/ublue/skillet/integration_tests/recordings/beezelbot.yaml +++ b/ublue/skillet/integration_tests/recordings/beezelbot.yaml @@ -6,3 +6,27 @@ group: root - !ServiceRestart name: systemd-sysctl +- !EnsureDirectory + path: /etc/ssh + mode: '0o755' + owner: root + group: root +- !EnsureFile + path: /etc/ssh/sshd_config + content_hash: '1355f199c4b2ed28c09c1cc2c7fc6fa44690f9b77d01412013f08118faa7b42b' + mode: '0o600' + owner: root + group: root +- !ServiceRestart + name: sshd +- !EnsureDirectory + path: /etc/ssh + mode: '0o755' + owner: root + group: root +- !EnsureFile + path: /etc/ssh/ssh_config + content_hash: b1c686c7da8fcea74e83f6a2dbd5552f2fb16a58601f347058b5ba4529e6d602 + mode: '0o644' + owner: root + group: root From 1a43b81a2a8e7c232ec8a4eefb11c1e6fc6d23cc Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 3 Apr 2026 08:47:17 +0200 Subject: [PATCH 011/252] refactor(skillet): address second round of review comments --- ublue/skillet/crates/cli-common/src/lib.rs | 3 ++ ublue/skillet/crates/cli/src/main.rs | 39 ++++++++++++---------- ublue/skillet/crates/core/src/files.rs | 24 ++++++------- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 86aa2dc7..9c6c7d6a 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -74,6 +74,9 @@ pub fn handle_apply(hostname: &str, record_path: Option) -> Result<(), let ops = recorder_system.get_ops(); let yaml = serde_yml::to_string(&ops)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } fs::write(&path, yaml)?; info!("Recording saved to {}", path.display()); } else { diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index f505cfe3..c75653bd 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -127,24 +127,29 @@ fn build_workspace() -> Result<()> { fn locate_binary(hostname: &str) -> Result { let host_binary_name = format!("skillet-{hostname}"); - let target_debug = PathBuf::from("target/debug"); + let binary_path = ["target/release", "target/debug"] + .iter() + .find_map(|dir| { + let p = PathBuf::from(dir).join(&host_binary_name); + if p.exists() { + Some(p) + } else { + None + } + }) + .or_else(|| { + ["target/release", "target/debug"].iter().find_map(|dir| { + let p = PathBuf::from(dir).join("skillet"); + if p.exists() { + Some(p) + } else { + None + } + }) + }) + .ok_or_else(|| anyhow!("Binary not found in target/release or target/debug"))?; - let binary_path = if target_debug.join(&host_binary_name).exists() { - info!("Found host-specific binary: {host_binary_name}"); - target_debug.join(&host_binary_name) - } else { - info!( - "Using generic skillet binary (host binary {host_binary_name} not found)" - ); - target_debug.join("skillet") - }; - - if !binary_path.exists() { - return Err(anyhow!( - "Binary not found at {}. Make sure you run this from workspace root.", - binary_path.display() - )); - } + info!("Using binary: {}", binary_path.display()); fs::canonicalize(&binary_path).context("Failed to canonicalize binary path") } diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 41e1ef78..a1eb3c5d 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -1,5 +1,4 @@ use nix::unistd::{chown, Gid, Uid}; -use sha2::{Digest, Sha256}; use std::fs::{self}; use std::io::{self, Write}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -68,7 +67,7 @@ impl LocalFileResource { let mut changed = false; if let Some(desired_mode) = mode { - if (metadata.permissions().mode() & 0o777) != desired_mode { + if (metadata.permissions().mode() & 0o7777) != desired_mode { changed = true; } } @@ -156,18 +155,15 @@ impl FileResource for LocalFileResource { // 2. Check content let content_changed = if path.exists() { - let existing_content = - fs::read(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; - - let mut hasher = Sha256::new(); - hasher.update(&existing_content); - let existing_hash = hasher.finalize(); - - let mut new_hasher = Sha256::new(); - new_hasher.update(content); - let new_hash = new_hasher.finalize(); - - existing_hash != new_hash + let metadata = + fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + if metadata.len() == content.len() as u64 { + let existing_content = + fs::read(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + existing_content != content + } else { + true + } } else { true }; From a5116562137822581bb5a2cda9d3c253cf33dbd2 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 4 Apr 2026 14:54:42 +0200 Subject: [PATCH 012/252] refactor(skillet): address review comments including systemd DBus interaction --- ublue/skillet/Cargo.toml | 1 + ublue/skillet/crates/cli-common/src/lib.rs | 2 + ublue/skillet/crates/core/Cargo.toml | 1 + ublue/skillet/crates/core/src/files.rs | 8 +- ublue/skillet/crates/core/src/system.rs | 79 +++++++++++++++---- ublue/skillet/crates/hardening/src/lib.rs | 7 +- .../recordings/beezelbot.yaml | 5 ++ 7 files changed, 84 insertions(+), 19 deletions(-) diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 4eb3947a..c7b8661b 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -24,6 +24,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yml = "0.0.12" hex = "0.4" +zbus = { version = "4.3", features = ["blocking"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 9c6c7d6a..8ab6118d 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -13,6 +13,8 @@ use tracing_subscriber::FmtSubscriber; pub enum CliCommonError { #[error("Failed to apply hardening: {0}")] Hardening(#[from] skillet_hardening::HardeningError), + #[error("System error: {0}")] + System(#[from] skillet_core::system::SystemError), #[error("Failed to set default tracing subscriber: {0}")] SetLogger(#[from] tracing::subscriber::SetGlobalDefaultError), #[error("IO error: {0}")] diff --git a/ublue/skillet/crates/core/Cargo.toml b/ublue/skillet/crates/core/Cargo.toml index 240af0c6..1c80a9d2 100644 --- a/ublue/skillet/crates/core/Cargo.toml +++ b/ublue/skillet/crates/core/Cargo.toml @@ -15,6 +15,7 @@ tempfile.workspace = true hex.workspace = true serde.workspace = true tracing.workspace = true +zbus.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index a1eb3c5d..dc2de967 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -199,7 +199,13 @@ impl FileResource for LocalFileResource { let mut changed = false; if !path.exists() { - fs::create_dir_all(path).map_err(FileError::Io)?; + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + if let Some(m) = mode { + builder.mode(m); + } + builder.create(path).map_err(FileError::Io)?; changed = true; info!("Created directory {}", path.display()); } diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index d4df4569..4d5aed54 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -1,7 +1,19 @@ use std::process::Command; use thiserror::Error; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use users::get_group_by_name; +use zbus::proxy; + +#[proxy( + interface = "org.freedesktop.systemd1.Manager", + default_service = "org.freedesktop.systemd1", + default_path = "/org/freedesktop/systemd1" +)] +trait SystemdManager { + fn start_unit(&self, name: &str, mode: &str) -> zbus::Result; + fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result; + fn restart_unit(&self, name: &str, mode: &str) -> zbus::Result; +} #[derive(Error, Debug)] pub enum SystemError { @@ -9,6 +21,8 @@ pub enum SystemError { GroupCheck(String), #[error("Command failed: {0}")] Command(String), + #[error("DBus error: {0}")] + DBus(#[from] zbus::Error), #[error("IO error: {0}")] Io(#[from] std::io::Error), } @@ -20,21 +34,59 @@ pub trait SystemResource { fn service_restart(&self, name: &str) -> Result<(), SystemError>; } -pub struct LinuxSystemResource; +pub struct LinuxSystemResource { + conn: Option, +} impl LinuxSystemResource { pub fn new() -> Self { - Self + let conn = match zbus::blocking::Connection::system() { + Ok(c) => Some(c), + Err(e) => { + warn!("Failed to connect to system DBus, will fallback to CLI: {e}"); + None + } + }; + Self { conn } } - fn run_systemctl(action: &str, name: &str) -> Result<(), SystemError> { - info!("Running systemctl {action} {name}"); - let output = Command::new("systemctl").arg(action).arg(name).output()?; + fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { + let name_with_suffix = if name.contains('.') { + name.to_string() + } else { + format!("{name}.service") + }; + + if let Some(conn) = &self.conn { + info!("Running systemctl {action} {name_with_suffix} via DBus"); + let proxy = SystemdManagerProxyBlocking::new(conn)?; + let res = match action { + "start" => proxy.start_unit(&name_with_suffix, "replace"), + "stop" => proxy.stop_unit(&name_with_suffix, "replace"), + "restart" => proxy.restart_unit(&name_with_suffix, "replace"), + _ => { + return Err(SystemError::Command(format!("Unsupported action: {action}"))); + } + }; + + match res { + Ok(_) => return Ok(()), + Err(e) => { + warn!("DBus call failed, falling back to CLI: {e}"); + } + } + } + + info!("Running systemctl {action} {name_with_suffix} via CLI"); + let output = Command::new("systemctl") + .arg(action) + .arg(&name_with_suffix) + .output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(SystemError::Command(format!( - "systemctl {action} {name} failed: {stderr}" + "systemctl {action} {name_with_suffix} failed: {stderr}" ))); } Ok(()) @@ -56,13 +108,8 @@ impl SystemResource for LinuxSystemResource { } // 2. Create group using `groupadd` - // Note: Creating groups requires root privileges usually. info!("Creating group {name}"); - let output = Command::new("groupadd") - .arg(name) - // .arg("-r") // System group? Maybe make it an option? - // For now, simple group creation. - .output()?; + let output = Command::new("groupadd").arg(name).output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -74,15 +121,15 @@ impl SystemResource for LinuxSystemResource { } fn service_start(&self, name: &str) -> Result<(), SystemError> { - Self::run_systemctl("start", name) + self.run_systemctl("start", name) } fn service_stop(&self, name: &str) -> Result<(), SystemError> { - Self::run_systemctl("stop", name) + self.run_systemctl("stop", name) } fn service_restart(&self, name: &str) -> Result<(), SystemError> { - Self::run_systemctl("restart", name) + self.run_systemctl("restart", name) } } #[cfg(test)] diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index 260df8f3..55ea9eee 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -40,10 +40,13 @@ where F: FileResource + ?Sized, { info!("Applying sysctl hardening..."); + let sysctl_dir = Path::new("/etc/sysctl.d"); + files.ensure_directory(sysctl_dir, Some(0o755), Some("root"), Some("root"))?; + let content = include_bytes!("../files/sysctl.boxy.conf"); - let path = Path::new("/etc/sysctl.d/99-hardening.conf"); + let path = sysctl_dir.join("99-hardening.conf"); - let changed = files.ensure_file(path, content, Some(0o644), Some("root"), Some("root"))?; + let changed = files.ensure_file(&path, content, Some(0o644), Some("root"), Some("root"))?; if changed { info!("Sysctl configuration changed, restarting systemd-sysctl..."); diff --git a/ublue/skillet/integration_tests/recordings/beezelbot.yaml b/ublue/skillet/integration_tests/recordings/beezelbot.yaml index 09b6b627..c123e6ba 100644 --- a/ublue/skillet/integration_tests/recordings/beezelbot.yaml +++ b/ublue/skillet/integration_tests/recordings/beezelbot.yaml @@ -1,3 +1,8 @@ +- !EnsureDirectory + path: /etc/sysctl.d + mode: '0o755' + owner: root + group: root - !EnsureFile path: /etc/sysctl.d/99-hardening.conf content_hash: c71e2f0edb84c44cfb601a2dc3d35df3b46afbbe9d28e02283a12d4b5f55b89d From cf8889f5bf3f64a9759046364eac2ea634f8a81c Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 4 Apr 2026 18:26:02 +0200 Subject: [PATCH 013/252] refactor(skillet): optimize file hashing and verify directory types --- ublue/skillet/crates/core/src/files.rs | 27 ++++++++++++++--- ublue/skillet/crates/core/src/files/tests.rs | 29 +++++++++++++++++++ ublue/skillet/crates/core/src/system/tests.rs | 1 - 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index dc2de967..0f302ef8 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -1,4 +1,5 @@ use nix::unistd::{chown, Gid, Uid}; +use sha2::{Digest, Sha256}; use std::fs::{self}; use std::io::{self, Write}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -28,6 +29,8 @@ pub enum FileError { UserNotFound(String), #[error("Group {0} not found")] GroupNotFound(String), + #[error("Path {0} exists but is not a directory")] + NotADirectory(String), } pub trait FileResource { @@ -158,9 +161,19 @@ impl FileResource for LocalFileResource { let metadata = fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; if metadata.len() == content.len() as u64 { - let existing_content = - fs::read(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; - existing_content != content + let file = + fs::File::open(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + let mut reader = std::io::BufReader::new(file); + let mut hasher = Sha256::new(); + std::io::copy(&mut reader, &mut hasher) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; + let existing_hash = hasher.finalize(); + + let mut new_hasher = Sha256::new(); + new_hasher.update(content); + let new_hash = new_hasher.finalize(); + + existing_hash != new_hash } else { true } @@ -198,7 +211,13 @@ impl FileResource for LocalFileResource { ) -> Result { let mut changed = false; - if !path.exists() { + if path.exists() { + let metadata = + fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + if !metadata.is_dir() { + return Err(FileError::NotADirectory(path.display().to_string())); + } + } else { use std::os::unix::fs::DirBuilderExt; let mut builder = fs::DirBuilder::new(); builder.recursive(true); diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs index bfe75580..51426bb5 100644 --- a/ublue/skillet/crates/core/src/files/tests.rs +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -85,6 +85,35 @@ fn test_ensure_file_metadata() { // or we would need to mock the underlying chown call. } +#[test] +fn test_ensure_directory_creates_dir() { + let dir = tempdir().unwrap(); + let sub_dir = dir.path().join("subdir"); + let resource = LocalFileResource::new(); + + let changed = resource + .ensure_directory(&sub_dir, Some(0o755), None, None) + .unwrap(); + assert!(changed); + assert!(sub_dir.exists()); + assert!(sub_dir.is_dir()); +} + +#[test] +fn test_ensure_directory_fails_if_file() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("file.txt"); + fs::write(&file_path, b"not a dir").unwrap(); + let resource = LocalFileResource::new(); + + let result = resource.ensure_directory(&file_path, None, None, None); + assert!(result.is_err()); + match result { + Err(FileError::NotADirectory(p)) => assert_eq!(p, file_path.display().to_string()), + _ => panic!("Expected NotADirectory error, got {result:?}"), + } +} + #[test] fn test_delete_file() { let dir = tempdir().unwrap(); diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 8c50d2ae..65a038c1 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -1,4 +1,3 @@ -use super::*; #[cfg(feature = "test-utils")] use crate::test_utils::MockSystem; From 500dc706aa07a38e0f7ffb484dbbd9f187881103 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 4 Apr 2026 18:29:25 +0200 Subject: [PATCH 014/252] refactor(skillet): address review comments on file hashing and directory type verification --- ublue/skillet/crates/core/src/system/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 65a038c1..8c50d2ae 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -1,3 +1,4 @@ +use super::*; #[cfg(feature = "test-utils")] use crate::test_utils::MockSystem; From 5f1ad94342cc582622d065f8c922a89c33c906e1 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 4 Apr 2026 18:54:26 +0200 Subject: [PATCH 015/252] refactor(skillet): improve file/directory type verification and handle group creation race --- ublue/skillet/crates/core/src/files.rs | 19 ++++++---- ublue/skillet/crates/core/src/files/tests.rs | 38 ++++++++++++++++++++ ublue/skillet/crates/core/src/system.rs | 6 ++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 0f302ef8..b0c24b7b 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -31,6 +31,8 @@ pub enum FileError { GroupNotFound(String), #[error("Path {0} exists but is not a directory")] NotADirectory(String), + #[error("Path {0} exists but is not a regular file")] + NotARegularFile(String), } pub trait FileResource { @@ -158,11 +160,16 @@ impl FileResource for LocalFileResource { // 2. Check content let content_changed = if path.exists() { - let metadata = - fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + let metadata = fs::symlink_metadata(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; + + if !metadata.is_file() { + return Err(FileError::NotARegularFile(path.display().to_string())); + } + if metadata.len() == content.len() as u64 { - let file = - fs::File::open(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + let file = fs::File::open(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; let mut reader = std::io::BufReader::new(file); let mut hasher = Sha256::new(); std::io::copy(&mut reader, &mut hasher) @@ -212,8 +219,8 @@ impl FileResource for LocalFileResource { let mut changed = false; if path.exists() { - let metadata = - fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + let metadata = fs::symlink_metadata(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; if !metadata.is_dir() { return Err(FileError::NotADirectory(path.display().to_string())); } diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs index 51426bb5..f38c97d0 100644 --- a/ublue/skillet/crates/core/src/files/tests.rs +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -85,6 +85,25 @@ fn test_ensure_file_metadata() { // or we would need to mock the underlying chown call. } +#[test] +fn test_ensure_file_fails_if_symlink() { + let dir = tempdir().unwrap(); + let target_path = dir.path().join("target.txt"); + let link_path = dir.path().join("link.txt"); + fs::write(&target_path, b"target").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&target_path, &link_path).unwrap(); + + let resource = LocalFileResource::new(); + let result = resource.ensure_file(&link_path, b"new content", None, None, None); + + assert!(result.is_err()); + match result { + Err(FileError::NotARegularFile(p)) => assert_eq!(p, link_path.display().to_string()), + _ => panic!("Expected NotARegularFile error, got {result:?}"), + } +} + #[test] fn test_ensure_directory_creates_dir() { let dir = tempdir().unwrap(); @@ -114,6 +133,25 @@ fn test_ensure_directory_fails_if_file() { } } +#[test] +fn test_ensure_directory_fails_if_symlink() { + let dir = tempdir().unwrap(); + let target_dir = dir.path().join("target_dir"); + let link_path = dir.path().join("link_dir"); + fs::create_dir(&target_dir).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&target_dir, &link_path).unwrap(); + + let resource = LocalFileResource::new(); + let result = resource.ensure_directory(&link_path, None, None, None); + + assert!(result.is_err()); + match result { + Err(FileError::NotADirectory(p)) => assert_eq!(p, link_path.display().to_string()), + _ => panic!("Expected NotADirectory error, got {result:?}"), + } +} + #[test] fn test_delete_file() { let dir = tempdir().unwrap(); diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 4d5aed54..a986aca6 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -112,6 +112,12 @@ impl SystemResource for LinuxSystemResource { let output = Command::new("groupadd").arg(name).output()?; if !output.status.success() { + // Check if group was created by another process in the meantime (exit code 9 for groupadd) + if output.status.code() == Some(9) { + debug!("Group {name} was created by another process"); + return Ok(false); + } + let stderr = String::from_utf8_lossy(&output.stderr); return Err(SystemError::Command(format!("groupadd failed: {stderr}"))); } From 43462ec3f2cf5cee3ecc18e717ecb5cde5317163 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 4 Apr 2026 19:19:05 +0200 Subject: [PATCH 016/252] refactor(skillet): optimize systemd unit handling and improve test runner cleanup --- ublue/skillet/crates/cli/src/main.rs | 12 +++++--- ublue/skillet/crates/core/src/system.rs | 34 ++++++++++++++++++--- ublue/skillet/crates/core/src/test_utils.rs | 18 ++++++++--- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index c75653bd..5c1ff35a 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -104,14 +104,18 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() Ok(()) })(); - info!("Stopping container..."); - let _ = Command::new("podman") - .args(["kill", &container_name]) - .output(); + stop_container(&container_name); result } +fn stop_container(container_name: &str) { + info!("Stopping container {container_name}..."); + let _ = Command::new("podman") + .args(["rm", "-f", container_name]) + .output(); +} + fn build_workspace() -> Result<()> { info!("Building skillet workspace..."); let build_status = Command::new("cargo") diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index a986aca6..be0e5c5e 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -1,9 +1,37 @@ use std::process::Command; +use std::sync::LazyLock; use thiserror::Error; use tracing::{debug, info, warn}; use users::get_group_by_name; use zbus::proxy; +static SYSTEMD_UNIT_SUFFIXES: LazyLock> = LazyLock::new(|| { + vec![ + ".service", + ".socket", + ".device", + ".mount", + ".automount", + ".swap", + ".target", + ".path", + ".timer", + ".slice", + ".scope", + ] +}); + +fn ensure_systemd_suffix(name: &str) -> String { + if SYSTEMD_UNIT_SUFFIXES + .iter() + .any(|suffix| name.ends_with(suffix)) + { + name.to_string() + } else { + format!("{name}.service") + } +} + #[proxy( interface = "org.freedesktop.systemd1.Manager", default_service = "org.freedesktop.systemd1", @@ -51,11 +79,7 @@ impl LinuxSystemResource { } fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { - let name_with_suffix = if name.contains('.') { - name.to_string() - } else { - format!("{name}.service") - }; + let name_with_suffix = ensure_systemd_suffix(name); if let Some(conn) = &self.conn { info!("Running systemctl {action} {name_with_suffix} via DBus"); diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index f4c5b1c4..c5fafb51 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -65,6 +65,7 @@ pub type FileMetadata = (Option, Option, Option); pub struct MockFiles { pub files: Arc>>>, pub metadata: Arc>>, + pub directories: Arc>>, } impl MockFiles { @@ -72,6 +73,7 @@ impl MockFiles { Self { files: Arc::new(Mutex::new(HashMap::new())), metadata: Arc::new(Mutex::new(HashMap::new())), + directories: Arc::new(Mutex::new(HashSet::new())), } } } @@ -127,14 +129,22 @@ impl FileResource for MockFiles { fn ensure_directory( &self, - _path: &Path, + path: &Path, _mode: Option, _owner: Option<&str>, _group: Option<&str>, ) -> Result { - // For mock, we don't really track directories separately for now, - // but we could if needed. Just return Ok(false) as if it exists. - Ok(false) + let path_str = path.display().to_string(); + let mut directories = self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if directories.contains(&path_str) { + Ok(false) + } else { + directories.insert(path_str); + Ok(true) + } } fn delete_file(&self, path: &Path) -> Result { From 2753cacc347d42d5beca8a220e4a77d3abb54072 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 4 Apr 2026 19:37:08 +0200 Subject: [PATCH 017/252] refactor(skillet): fix Sha256 usage and use constant for group existence exit code --- ublue/skillet/crates/core/src/files.rs | 12 +++++++++--- ublue/skillet/crates/core/src/system.rs | 6 ++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index b0c24b7b..bac226e8 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -1,7 +1,7 @@ use nix::unistd::{chown, Gid, Uid}; use sha2::{Digest, Sha256}; use std::fs::{self}; -use std::io::{self, Write}; +use std::io::{self, Read, Write}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::Path; use tempfile::NamedTempFile; @@ -172,8 +172,14 @@ impl FileResource for LocalFileResource { .map_err(|e| FileError::Read(path.display().to_string(), e))?; let mut reader = std::io::BufReader::new(file); let mut hasher = Sha256::new(); - std::io::copy(&mut reader, &mut hasher) - .map_err(|e| FileError::Read(path.display().to_string(), e))?; + + let mut buffer = [0; 8192]; + while let Ok(n) = reader.read(&mut buffer) { + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } let existing_hash = hasher.finalize(); let mut new_hasher = Sha256::new(); diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index be0e5c5e..1783df78 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -123,6 +123,8 @@ impl Default for LinuxSystemResource { } } +const EXIT_CODE_GROUP_EXISTS: i32 = 9; + impl SystemResource for LinuxSystemResource { fn ensure_group(&self, name: &str) -> Result { // 1. Check if group exists using `users` crate @@ -136,8 +138,8 @@ impl SystemResource for LinuxSystemResource { let output = Command::new("groupadd").arg(name).output()?; if !output.status.success() { - // Check if group was created by another process in the meantime (exit code 9 for groupadd) - if output.status.code() == Some(9) { + // Check if group was created by another process in the meantime + if output.status.code() == Some(EXIT_CODE_GROUP_EXISTS) { debug!("Group {name} was created by another process"); return Ok(false); } From d51f0f78a76e56e3f33b06dc505a73e87e965591 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 5 Apr 2026 09:55:28 +0200 Subject: [PATCH 018/252] refactor(skillet): ensure I/O errors are not ignored during hashing and improve binary discovery --- ublue/skillet/crates/cli/src/main.rs | 8 ++++++-- ublue/skillet/crates/core/src/files.rs | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 5c1ff35a..3fe9a295 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -131,7 +131,10 @@ fn build_workspace() -> Result<()> { fn locate_binary(hostname: &str) -> Result { let host_binary_name = format!("skillet-{hostname}"); - let binary_path = ["target/release", "target/debug"] + let dirs = ["target/release", "target/debug"]; + + // First, try to find host-specific binary in release, then debug + let binary_path = dirs .iter() .find_map(|dir| { let p = PathBuf::from(dir).join(&host_binary_name); @@ -141,8 +144,9 @@ fn locate_binary(hostname: &str) -> Result { None } }) + // If not found, try generic skillet binary in release, then debug .or_else(|| { - ["target/release", "target/debug"].iter().find_map(|dir| { + dirs.iter().find_map(|dir| { let p = PathBuf::from(dir).join("skillet"); if p.exists() { Some(p) diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index bac226e8..cfb0b30b 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -174,7 +174,9 @@ impl FileResource for LocalFileResource { let mut hasher = Sha256::new(); let mut buffer = [0; 8192]; - while let Ok(n) = reader.read(&mut buffer) { + loop { + let n = reader.read(&mut buffer) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; if n == 0 { break; } From b006b7ec07d1125b080b12b1dcbba10c634732a1 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 5 Apr 2026 10:51:51 +0200 Subject: [PATCH 019/252] refactor(skillet): optimize file metadata calls and hashing, and refine binary discovery search --- ublue/skillet/crates/cli/src/main.rs | 41 +++++++--------- ublue/skillet/crates/core/src/files.rs | 65 ++++++++++++++------------ 2 files changed, 51 insertions(+), 55 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 3fe9a295..d352e92d 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -131,31 +131,22 @@ fn build_workspace() -> Result<()> { fn locate_binary(hostname: &str) -> Result { let host_binary_name = format!("skillet-{hostname}"); - let dirs = ["target/release", "target/debug"]; - - // First, try to find host-specific binary in release, then debug - let binary_path = dirs - .iter() - .find_map(|dir| { - let p = PathBuf::from(dir).join(&host_binary_name); - if p.exists() { - Some(p) - } else { - None - } - }) - // If not found, try generic skillet binary in release, then debug - .or_else(|| { - dirs.iter().find_map(|dir| { - let p = PathBuf::from(dir).join("skillet"); - if p.exists() { - Some(p) - } else { - None - } - }) - }) - .ok_or_else(|| anyhow!("Binary not found in target/release or target/debug"))?; + + // Ordered search: + // 1. host-specific release + // 2. host-specific debug + // 3. generic skillet release + // 4. generic skillet debug + + let binary_path = [ + PathBuf::from("target/release").join(&host_binary_name), + PathBuf::from("target/debug").join(&host_binary_name), + PathBuf::from("target/release").join("skillet"), + PathBuf::from("target/debug").join("skillet"), + ] + .into_iter() + .find(|p| p.exists()) + .ok_or_else(|| anyhow!("No suitable skillet binary found in target/release or target/debug"))?; info!("Using binary: {}", binary_path.display()); fs::canonicalize(&binary_path).context("Failed to canonicalize binary path") diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index cfb0b30b..20eef2da 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -62,13 +62,12 @@ impl LocalFileResource { } fn check_metadata( - path: &Path, + _path: &Path, + metadata: &fs::Metadata, mode: Option, owner: Option<&str>, group: Option<&str>, ) -> Result { - let metadata = - fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; let mut changed = false; if let Some(desired_mode) = mode { @@ -98,14 +97,13 @@ impl LocalFileResource { fn apply_metadata( path: &Path, + metadata: &fs::Metadata, mode: Option, owner: Option<&str>, group: Option<&str>, ) -> Result<(), FileError> { if let Some(desired_mode) = mode { - let mut perms = fs::metadata(path) - .map_err(|e| FileError::Read(path.display().to_string(), e))? - .permissions(); + let mut perms = metadata.permissions(); perms.set_mode(desired_mode); fs::set_permissions(path, perms) .map_err(|e| FileError::SetPermissions(path.display().to_string(), e))?; @@ -159,23 +157,22 @@ impl FileResource for LocalFileResource { let mut changed = false; // 2. Check content - let content_changed = if path.exists() { - let metadata = fs::symlink_metadata(path) - .map_err(|e| FileError::Read(path.display().to_string(), e))?; - - if !metadata.is_file() { + let mut metadata = fs::symlink_metadata(path).ok(); + let content_changed = if let Some(meta) = &metadata { + if !meta.is_file() { return Err(FileError::NotARegularFile(path.display().to_string())); } - if metadata.len() == content.len() as u64 { + if meta.len() == content.len() as u64 { let file = fs::File::open(path) .map_err(|e| FileError::Read(path.display().to_string(), e))?; let mut reader = std::io::BufReader::new(file); let mut hasher = Sha256::new(); - + let mut buffer = [0; 8192]; loop { - let n = reader.read(&mut buffer) + let n = reader + .read(&mut buffer) .map_err(|e| FileError::Read(path.display().to_string(), e))?; if n == 0 { break; @@ -183,10 +180,7 @@ impl FileResource for LocalFileResource { hasher.update(&buffer[..n]); } let existing_hash = hasher.finalize(); - - let mut new_hasher = Sha256::new(); - new_hasher.update(content); - let new_hash = new_hasher.finalize(); + let new_hash = Sha256::digest(content); existing_hash != new_hash } else { @@ -205,13 +199,19 @@ impl FileResource for LocalFileResource { .map_err(|e| FileError::Persist(path.display().to_string(), e.error))?; changed = true; info!("Updated file content for {}", path.display()); + // Fetch metadata for the newly created file + metadata = Some( + fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?, + ); } // 3. Check and apply metadata - if path.exists() && Self::check_metadata(path, mode, owner, group)? { - Self::apply_metadata(path, mode, owner, group)?; - changed = true; - info!("Updated file metadata for {}", path.display()); + if let Some(meta) = metadata { + if Self::check_metadata(path, &meta, mode, owner, group)? { + Self::apply_metadata(path, &meta, mode, owner, group)?; + changed = true; + info!("Updated file metadata for {}", path.display()); + } } Ok(changed) @@ -226,10 +226,9 @@ impl FileResource for LocalFileResource { ) -> Result { let mut changed = false; - if path.exists() { - let metadata = fs::symlink_metadata(path) - .map_err(|e| FileError::Read(path.display().to_string(), e))?; - if !metadata.is_dir() { + let mut metadata = fs::symlink_metadata(path).ok(); + if let Some(meta) = &metadata { + if !meta.is_dir() { return Err(FileError::NotADirectory(path.display().to_string())); } } else { @@ -242,12 +241,18 @@ impl FileResource for LocalFileResource { builder.create(path).map_err(FileError::Io)?; changed = true; info!("Created directory {}", path.display()); + // Fetch metadata for the newly created directory + metadata = Some( + fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?, + ); } - if path.exists() && Self::check_metadata(path, mode, owner, group)? { - Self::apply_metadata(path, mode, owner, group)?; - changed = true; - info!("Updated directory metadata for {}", path.display()); + if let Some(meta) = metadata { + if Self::check_metadata(path, &meta, mode, owner, group)? { + Self::apply_metadata(path, &meta, mode, owner, group)?; + changed = true; + info!("Updated directory metadata for {}", path.display()); + } } Ok(changed) From f619913e3cfce8288970e54e74426d7f08bb6136 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 6 Apr 2026 20:25:11 +0200 Subject: [PATCH 020/252] refactor(skillet): update architectural docs and consolidate directory creation --- ublue/skillet/AGENTS.md | 20 ++++++- ublue/skillet/crates/cli/src/main.rs | 59 ++++++++++++------- ublue/skillet/crates/hardening/src/lib.rs | 10 ++-- .../recordings/beezelbot.yaml | 5 -- 4 files changed, 60 insertions(+), 34 deletions(-) diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 3db8fdc1..b3791ba7 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -26,6 +26,17 @@ This document defines the architectural mandates and project structure for `skil - **Unit Tests**: `cargo test` across the workspace. - **Integration Tests**: `skillet test run ` for affected hosts to verify end-to-end correctness in a containerized environment. +## Testing & Recording Philosophy + +Skillet uses a multi-layered testing approach to ensure reliability and idempotency: + +1. **Trait-based Abstraction**: Core resources (`FileResource`, `SystemResource`) are defined as traits. This allows for easy mocking using `MockFiles` and `MockSystem` in unit tests. +2. **The Recorder Wrapper**: A `Recorder` wrapper can be applied to any resource implementing these traits. It intercepts all operations (e.g., `EnsureFile`, `ServiceRestart`), records them into a sequence of `ResourceOp` enums, and then passes the call to the underlying implementation. +3. **Containerized Integration Tests**: + * **Record Mode**: The tool runs against a fresh container, and the `Recorder` saves the sequence of operations to a YAML file (e.g., `integration_tests/recordings/beezelbot.yaml`). + * **Run Mode**: The tool runs again, and the *actual* sequence of operations is compared against the *recorded* one. Any mismatch (e.g., a missing service restart or an extra file write) causes the test to fail. + * **Idempotency**: By running the tool twice in the same container, we can verify that the second run produces an empty (or minimal) set of operations, confirming idempotency. + ## Project Structure The project is organized as a Cargo workspace: @@ -50,9 +61,12 @@ skillet/ │ │ ├── lib.rs # Hardening logic using core primitives │ │ └── tests.rs # Unit tests for hardening logic │ └── tests/ - └── cli/ # skillet: The binary executable - └── src/ - └── main.rs # CLI entry point (uses anyhow, clap) + ├── cli/ # skillet: The main binary executable + │ └── src/ + │ └── main.rs # CLI entry point (uses anyhow, clap) + ├── cli-common/ # skillet_cli_common: Shared CLI logic + └── hosts/ + └── beezelbot/ # skillet-beezelbot: Host-specific binary for beezelbot ``` ## Module Design diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index d352e92d..913669ae 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -41,11 +41,17 @@ enum TestCommands { /// Container image to use #[arg(long, default_value = "fedora:latest")] image: String, + /// Build in release mode + #[arg(long)] + release: bool, }, Run { hostname: String, #[arg(long, default_value = "fedora:latest")] image: String, + /// Build in release mode + #[arg(long)] + release: bool, }, } @@ -75,23 +81,36 @@ fn main() -> Result<()> { fn handle_test(cmd: TestCommands) -> Result<()> { match cmd { - TestCommands::Record { hostname, image } => { + TestCommands::Record { + hostname, + image, + release, + } => { info!("Recording integration test for host: {}", hostname); - run_container_test(&hostname, &image, true)?; + run_container_test(&hostname, &image, true, release)?; } - TestCommands::Run { hostname, image } => { + TestCommands::Run { + hostname, + image, + release, + } => { info!( "Running integration test verification for host: {}", hostname ); - run_container_test(&hostname, &image, false)?; + run_container_test(&hostname, &image, false, release)?; } } Ok(()) } -fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<()> { - build_workspace()?; +fn run_container_test( + hostname: &str, + image: &str, + is_record: bool, + release: bool, +) -> Result<()> { + build_workspace(release)?; let binary_path = locate_binary(hostname)?; let container_name = format!("skillet-test-{hostname}"); @@ -116,10 +135,14 @@ fn stop_container(container_name: &str) { .output(); } -fn build_workspace() -> Result<()> { - info!("Building skillet workspace..."); +fn build_workspace(release: bool) -> Result<()> { + info!("Building skillet workspace (release={})...", release); + let mut args = vec!["build"]; + if release { + args.push("--release"); + } let build_status = Command::new("cargo") - .args(["build"]) + .args(args) .status() .context("Failed to run cargo build")?; @@ -131,13 +154,13 @@ fn build_workspace() -> Result<()> { fn locate_binary(hostname: &str) -> Result { let host_binary_name = format!("skillet-{hostname}"); - + // Ordered search: // 1. host-specific release // 2. host-specific debug // 3. generic skillet release // 4. generic skillet debug - + let binary_path = [ PathBuf::from("target/release").join(&host_binary_name), PathBuf::from("target/debug").join(&host_binary_name), @@ -146,16 +169,16 @@ fn locate_binary(hostname: &str) -> Result { ] .into_iter() .find(|p| p.exists()) - .ok_or_else(|| anyhow!("No suitable skillet binary found in target/release or target/debug"))?; + .ok_or_else(|| { + anyhow!("No suitable skillet binary found in target/release or target/debug") + })?; info!("Using binary: {}", binary_path.display()); fs::canonicalize(&binary_path).context("Failed to canonicalize binary path") } fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Result<()> { - info!( - "Starting container {container_name} from image {image}..." - ); + info!("Starting container {container_name} from image {image}..."); let _ = Command::new("podman") .args(["rm", "-f", container_name]) @@ -273,11 +296,7 @@ fn verify_or_record(hostname: &str, container_name: &str, is_record: bool) -> Re .ok_or_else(|| anyhow!("Temporary path is not valid UTF-8"))?; let cp_status = Command::new("podman") - .args([ - "cp", - &format!("{container_name}:/tmp/ops.yaml"), - temp_path, - ]) + .args(["cp", &format!("{container_name}:/tmp/ops.yaml"), temp_path]) .status()?; if !cp_status.success() { return Err(anyhow!("Failed to copy recording from container")); diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index 55ea9eee..47c47a6d 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -25,6 +25,10 @@ where // 2. Include 'os-hardening' apply_os_hardening(system); + // Common setup for SSH + let ssh_dir = Path::new("/etc/ssh"); + files.ensure_directory(ssh_dir, Some(0o755), Some("root"), Some("root"))?; + // 3. Include 'ssh-hardening::server' apply_ssh_hardening_server(system, files)?; @@ -66,9 +70,6 @@ where F: FileResource + ?Sized, { info!("Applying ssh-hardening::server"); - let ssh_dir = Path::new("/etc/ssh"); - files.ensure_directory(ssh_dir, Some(0o755), Some("root"), Some("root"))?; - let content = include_bytes!("../files/sshd_config"); let path = Path::new("/etc/ssh/sshd_config"); @@ -88,9 +89,6 @@ where F: FileResource + ?Sized, { info!("Applying ssh-hardening::client"); - let ssh_dir = Path::new("/etc/ssh"); - files.ensure_directory(ssh_dir, Some(0o755), Some("root"), Some("root"))?; - let content = include_bytes!("../files/ssh_config"); let path = Path::new("/etc/ssh/ssh_config"); diff --git a/ublue/skillet/integration_tests/recordings/beezelbot.yaml b/ublue/skillet/integration_tests/recordings/beezelbot.yaml index c123e6ba..7cc20f21 100644 --- a/ublue/skillet/integration_tests/recordings/beezelbot.yaml +++ b/ublue/skillet/integration_tests/recordings/beezelbot.yaml @@ -24,11 +24,6 @@ group: root - !ServiceRestart name: sshd -- !EnsureDirectory - path: /etc/ssh - mode: '0o755' - owner: root - group: root - !EnsureFile path: /etc/ssh/ssh_config content_hash: b1c686c7da8fcea74e83f6a2dbd5552f2fb16a58601f347058b5ba4529e6d602 From 3edc0bdcd19724cc9a19bf2dc7e038b6c3b77d95 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 7 Apr 2026 20:03:03 +0200 Subject: [PATCH 021/252] refactor(skillet): improve robustness of file operations and optimize D-Bus connection management --- ublue/skillet/crates/core/src/files.rs | 33 +++++++------------- ublue/skillet/crates/core/src/files/tests.rs | 32 ++++++++++--------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 20eef2da..63f85c9b 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -1,7 +1,7 @@ use nix::unistd::{chown, Gid, Uid}; use sha2::{Digest, Sha256}; use std::fs::{self}; -use std::io::{self, Read, Write}; +use std::io::{self, Write}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::Path; use tempfile::NamedTempFile; @@ -31,8 +31,6 @@ pub enum FileError { GroupNotFound(String), #[error("Path {0} exists but is not a directory")] NotADirectory(String), - #[error("Path {0} exists but is not a regular file")] - NotARegularFile(String), } pub trait FileResource { @@ -159,26 +157,15 @@ impl FileResource for LocalFileResource { // 2. Check content let mut metadata = fs::symlink_metadata(path).ok(); let content_changed = if let Some(meta) = &metadata { + // Treat non-regular files (like symlinks) as "changed" to trigger replacement if !meta.is_file() { - return Err(FileError::NotARegularFile(path.display().to_string())); - } - - if meta.len() == content.len() as u64 { - let file = fs::File::open(path) + true + } else if meta.len() == content.len() as u64 { + let mut file = fs::File::open(path) .map_err(|e| FileError::Read(path.display().to_string(), e))?; - let mut reader = std::io::BufReader::new(file); let mut hasher = Sha256::new(); - - let mut buffer = [0; 8192]; - loop { - let n = reader - .read(&mut buffer) - .map_err(|e| FileError::Read(path.display().to_string(), e))?; - if n == 0 { - break; - } - hasher.update(&buffer[..n]); - } + std::io::copy(&mut file, &mut hasher) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; let existing_hash = hasher.finalize(); let new_hash = Sha256::digest(content); @@ -226,7 +213,8 @@ impl FileResource for LocalFileResource { ) -> Result { let mut changed = false; - let mut metadata = fs::symlink_metadata(path).ok(); + // Use metadata() instead of symlink_metadata() to follow symlinks to directories + let mut metadata = fs::metadata(path).ok(); if let Some(meta) = &metadata { if !meta.is_dir() { return Err(FileError::NotADirectory(path.display().to_string())); @@ -259,7 +247,8 @@ impl FileResource for LocalFileResource { } fn delete_file(&self, path: &Path) -> Result { - if path.exists() { + // Use symlink_metadata to correctly detect and remove broken symlinks + if path.symlink_metadata().is_ok() { fs::remove_file(path).map_err(FileError::Io)?; info!("Deleted file {}", path.display()); Ok(true) diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs index f38c97d0..c267ad7e 100644 --- a/ublue/skillet/crates/core/src/files/tests.rs +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -86,7 +86,7 @@ fn test_ensure_file_metadata() { } #[test] -fn test_ensure_file_fails_if_symlink() { +fn test_ensure_file_replaces_symlink() { let dir = tempdir().unwrap(); let target_path = dir.path().join("target.txt"); let link_path = dir.path().join("link.txt"); @@ -95,13 +95,15 @@ fn test_ensure_file_fails_if_symlink() { std::os::unix::fs::symlink(&target_path, &link_path).unwrap(); let resource = LocalFileResource::new(); - let result = resource.ensure_file(&link_path, b"new content", None, None, None); + let content = b"new content"; + let changed = resource + .ensure_file(&link_path, content, None, None, None) + .unwrap(); - assert!(result.is_err()); - match result { - Err(FileError::NotARegularFile(p)) => assert_eq!(p, link_path.display().to_string()), - _ => panic!("Expected NotARegularFile error, got {result:?}"), - } + assert!(changed); + assert!(link_path.exists()); + assert!(fs::symlink_metadata(&link_path).unwrap().is_file()); + assert_eq!(fs::read(&link_path).unwrap(), content); } #[test] @@ -134,7 +136,7 @@ fn test_ensure_directory_fails_if_file() { } #[test] -fn test_ensure_directory_fails_if_symlink() { +fn test_ensure_directory_follows_symlink() { let dir = tempdir().unwrap(); let target_dir = dir.path().join("target_dir"); let link_path = dir.path().join("link_dir"); @@ -143,13 +145,15 @@ fn test_ensure_directory_fails_if_symlink() { std::os::unix::fs::symlink(&target_dir, &link_path).unwrap(); let resource = LocalFileResource::new(); - let result = resource.ensure_directory(&link_path, None, None, None); + let changed = resource + .ensure_directory(&link_path, None, None, None) + .unwrap(); - assert!(result.is_err()); - match result { - Err(FileError::NotADirectory(p)) => assert_eq!(p, link_path.display().to_string()), - _ => panic!("Expected NotADirectory error, got {result:?}"), - } + // target_dir already exists, and we follow the symlink link_path to it. + // So no change should be reported. + assert!(!changed); + assert!(link_path.exists()); + assert!(link_path.is_dir()); } #[test] From 72bf23850fa0cde48402c6d0decfb6ede6735ccf Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 7 Apr 2026 20:14:30 +0200 Subject: [PATCH 022/252] chore(skillet): commit missing files from previous refactor --- ublue/skillet/Cargo.lock | 744 ++++++++++++++++++++++++++- ublue/skillet/crates/cli/src/main.rs | 38 +- 2 files changed, 769 insertions(+), 13 deletions(-) diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index ebb97afe..25239627 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -38,7 +38,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +49,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,6 +58,160 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" @@ -73,12 +227,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.0" @@ -125,6 +298,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -134,6 +316,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -154,6 +342,33 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -167,7 +382,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", ] [[package]] @@ -182,6 +418,58 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -192,6 +480,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -226,6 +525,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -308,6 +613,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "nix" version = "0.27.1" @@ -319,13 +633,26 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -340,12 +667,62 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -356,6 +733,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -380,6 +766,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rustix" version = "1.1.4" @@ -390,7 +806,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -448,6 +864,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_yml" version = "0.0.12" @@ -463,6 +890,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -483,6 +921,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "skillet" version = "0.1.0" @@ -527,13 +975,14 @@ name = "skillet_core" version = "0.1.0" dependencies = [ "hex", - "nix", + "nix 0.27.1", "serde", "sha2", "tempfile", "thiserror", "tracing", "users", + "zbus", ] [[package]] @@ -546,12 +995,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -576,10 +1037,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -611,6 +1072,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -674,6 +1165,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -714,6 +1216,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -772,6 +1280,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -781,6 +1307,79 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -869,8 +1468,137 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 913669ae..3e61742e 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -152,8 +152,35 @@ fn build_workspace(release: bool) -> Result<()> { Ok(()) } +fn find_workspace_root() -> Result { + let mut current = std::env::current_exe()? + .parent() + .ok_or_else(|| anyhow!("Failed to get executable directory"))? + .to_path_buf(); + + loop { + if current.join("Cargo.toml").exists() { + return Ok(current); + } + if !current.pop() { + break; + } + } + + // Fallback to CWD if not found relative to exe + let cwd = std::env::current_dir()?; + if cwd.join("Cargo.toml").exists() { + return Ok(cwd); + } + + Err(anyhow!( + "Failed to locate workspace root (looking for Cargo.toml)" + )) +} + fn locate_binary(hostname: &str) -> Result { let host_binary_name = format!("skillet-{hostname}"); + let root = find_workspace_root()?; // Ordered search: // 1. host-specific release @@ -162,10 +189,10 @@ fn locate_binary(hostname: &str) -> Result { // 4. generic skillet debug let binary_path = [ - PathBuf::from("target/release").join(&host_binary_name), - PathBuf::from("target/debug").join(&host_binary_name), - PathBuf::from("target/release").join("skillet"), - PathBuf::from("target/debug").join("skillet"), + root.join("target/release").join(&host_binary_name), + root.join("target/debug").join(&host_binary_name), + root.join("target/release").join("skillet"), + root.join("target/debug").join("skillet"), ] .into_iter() .find(|p| p.exists()) @@ -268,7 +295,8 @@ fn prepare_and_run_skillet(container_name: &str) -> Result<()> { } fn verify_or_record(hostname: &str, container_name: &str, is_record: bool) -> Result<()> { - let dest_dir = PathBuf::from("integration_tests/recordings"); + let root = find_workspace_root()?; + let dest_dir = root.join("integration_tests/recordings"); fs::create_dir_all(&dest_dir)?; let dest_file = dest_dir.join(format!("{hostname}.yaml")); From 930e25cdfba2e8d1b9fe49d19e3cc25a1bd84802 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 7 Apr 2026 20:31:36 +0200 Subject: [PATCH 023/252] Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ublue/skillet/Cargo.toml | 4 ++-- ublue/skillet/crates/cli-common/src/lib.rs | 3 +++ ublue/skillet/crates/cli/src/main.rs | 7 +++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index c7b8661b..f56b46e4 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -15,7 +15,7 @@ skillet_cli_common = { path = "crates/cli-common" } thiserror = "1.0" sha2 = "0.10" users = "0.11" -nix = { version = "0.27", features = ["user", "fs"] } +nix = { version = "0.29", features = ["user", "fs"] } clap = { version = "4.4", features = ["derive"] } tracing = "0.1" tracing-subscriber = "0.3" @@ -24,7 +24,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yml = "0.0.12" hex = "0.4" -zbus = { version = "4.3", features = ["blocking"] } +zbus = { version = "4.4", features = ["blocking"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 8ab6118d..9f5d84cf 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -79,6 +79,9 @@ pub fn handle_apply(hostname: &str, record_path: Option) -> Result<(), if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } fs::write(&path, yaml)?; info!("Recording saved to {}", path.display()); } else { diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 3e61742e..a53f05f4 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -130,9 +130,12 @@ fn run_container_test( fn stop_container(container_name: &str) { info!("Stopping container {container_name}..."); - let _ = Command::new("podman") + let status = Command::new("podman") .args(["rm", "-f", container_name]) - .output(); + .status(); + if let Err(e) = status { + error!("Failed to execute podman to stop container {}: {}", container_name, e); + } } fn build_workspace(release: bool) -> Result<()> { From c8d830d609bc138e77a6a6c7d5338320ca99e767 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 00:17:52 +0200 Subject: [PATCH 024/252] feat(clamps): add skillet run for clamps host --- ublue/butane/clamps.bu | 1 + ublue/butane/includes/skillet.bu | 20 ++++++++++++ ublue/skillet/Cargo.lock | 8 +++++ ublue/skillet/Cargo.toml | 1 + ublue/skillet/crates/cli/src/main.rs | 8 +++-- ublue/skillet/crates/hosts/clamps/Cargo.toml | 11 +++++++ ublue/skillet/crates/hosts/clamps/src/main.rs | 7 ++++ .../integration_tests/recordings/clamps.yaml | 32 +++++++++++++++++++ 8 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 ublue/butane/includes/skillet.bu create mode 100644 ublue/skillet/crates/hosts/clamps/Cargo.toml create mode 100644 ublue/skillet/crates/hosts/clamps/src/main.rs create mode 100644 ublue/skillet/integration_tests/recordings/clamps.yaml diff --git a/ublue/butane/clamps.bu b/ublue/butane/clamps.bu index dcbd8b05..e9bc4acc 100644 --- a/ublue/butane/clamps.bu +++ b/ublue/butane/clamps.bu @@ -6,6 +6,7 @@ ignition: - local: includes/passwd.bu - local: includes/zram.bu - local: includes/brew.bu + - local: includes/skillet.bu storage: disks: - # The link to the block device the OS was booted from. diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu new file mode 100644 index 00000000..2c3e957a --- /dev/null +++ b/ublue/butane/includes/skillet.bu @@ -0,0 +1,20 @@ +variant: fcos +version: 1.6.0 +systemd: + units: + - name: skillet-apply.service + enabled: true + contents: | + [Unit] + Description=Apply Skillet configuration + After=network-online.target + Wants=network-online.target + + [Service] + Type=oneshot + # Use the generic skillet binary and pass the hostname from /etc/hostname + ExecStart=/usr/bin/sh -c "/usr/bin/skillet apply --host $(cat /etc/hostname)" + RemainAfterExit=yes + + [Install] + WantedBy=multi-user.target diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 25239627..7e0e6c25 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -956,6 +956,14 @@ dependencies = [ "skillet_cli_common", ] +[[package]] +name = "skillet-clamps" +version = "0.1.0" +dependencies = [ + "anyhow", + "skillet_cli_common", +] + [[package]] name = "skillet_cli_common" version = "0.1.0" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index f56b46e4..e2f4c38c 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/hardening", "crates/cli", "crates/hosts/beezelbot", + "crates/hosts/clamps", "crates/cli-common", ] diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index a53f05f4..a90a7fbb 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -23,6 +23,9 @@ struct Args { enum Commands { /// Apply configuration (Agent Mode) Apply { + /// Optional: Hostname to apply configuration for + #[arg(long)] + host: Option, /// Optional: Output recorded actions to this file path #[arg(long)] record: Option, @@ -70,8 +73,9 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { - Commands::Apply { record } => { - skillet_cli_common::handle_apply("(Agent Mode)", record) + Commands::Apply { host, record } => { + let hostname = host.as_deref().unwrap_or("(Agent Mode)"); + skillet_cli_common::handle_apply(hostname, record) .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; } Commands::Test { test_command } => handle_test(test_command)?, diff --git a/ublue/skillet/crates/hosts/clamps/Cargo.toml b/ublue/skillet/crates/hosts/clamps/Cargo.toml new file mode 100644 index 00000000..359f4857 --- /dev/null +++ b/ublue/skillet/crates/hosts/clamps/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "skillet-clamps" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_cli_common.workspace = true +anyhow = "1.0" diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs new file mode 100644 index 00000000..e844294a --- /dev/null +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -0,0 +1,7 @@ +use anyhow::Result; +use skillet_cli_common::run_host; + +fn main() -> Result<()> { + run_host("clamps")?; + Ok(()) +} diff --git a/ublue/skillet/integration_tests/recordings/clamps.yaml b/ublue/skillet/integration_tests/recordings/clamps.yaml new file mode 100644 index 00000000..7cc20f21 --- /dev/null +++ b/ublue/skillet/integration_tests/recordings/clamps.yaml @@ -0,0 +1,32 @@ +- !EnsureDirectory + path: /etc/sysctl.d + mode: '0o755' + owner: root + group: root +- !EnsureFile + path: /etc/sysctl.d/99-hardening.conf + content_hash: c71e2f0edb84c44cfb601a2dc3d35df3b46afbbe9d28e02283a12d4b5f55b89d + mode: '0o644' + owner: root + group: root +- !ServiceRestart + name: systemd-sysctl +- !EnsureDirectory + path: /etc/ssh + mode: '0o755' + owner: root + group: root +- !EnsureFile + path: /etc/ssh/sshd_config + content_hash: '1355f199c4b2ed28c09c1cc2c7fc6fa44690f9b77d01412013f08118faa7b42b' + mode: '0o600' + owner: root + group: root +- !ServiceRestart + name: sshd +- !EnsureFile + path: /etc/ssh/ssh_config + content_hash: b1c686c7da8fcea74e83f6a2dbd5552f2fb16a58601f347058b5ba4529e6d602 + mode: '0o644' + owner: root + group: root From fea63035cd06e8b949f527a121afc1f53fd4764a Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 12:35:39 +0200 Subject: [PATCH 025/252] refactor(skillet): ensure deterministic Quadlet generation and optimize D-Bus proxy usage --- ublue/skillet/Cargo.lock | 141 ++++++++++++- ublue/skillet/Cargo.toml | 3 + ublue/skillet/crates/core/Cargo.toml | 1 + ublue/skillet/crates/core/src/lib.rs | 1 + ublue/skillet/crates/core/src/recorder.rs | 14 ++ ublue/skillet/crates/core/src/resource_op.rs | 5 + ublue/skillet/crates/core/src/system.rs | 59 +++++- ublue/skillet/crates/core/src/templates.rs | 25 +++ ublue/skillet/crates/core/src/test_utils.rs | 17 ++ ublue/skillet/crates/hardening/Cargo.toml | 2 + ublue/skillet/crates/hardening/src/lib.rs | 110 +++++++++++ ublue/skillet/crates/hardening/src/tests.rs | 36 ++++ .../hardening/templates/pihole/custom.list.j2 | 3 + ublue/skillet/crates/podman/Cargo.toml | 15 ++ ublue/skillet/crates/podman/src/lib.rs | 187 ++++++++++++++++++ .../podman/templates/quadlet.container.j2 | 6 + .../recordings/beezelbot.yaml | 54 +++++ .../integration_tests/recordings/clamps.yaml | 54 +++++ 18 files changed, 723 insertions(+), 10 deletions(-) create mode 100644 ublue/skillet/crates/core/src/templates.rs create mode 100644 ublue/skillet/crates/hardening/templates/pihole/custom.list.j2 create mode 100644 ublue/skillet/crates/podman/Cargo.toml create mode 100644 ublue/skillet/crates/podman/src/lib.rs create mode 100644 ublue/skillet/crates/podman/templates/quadlet.container.j2 diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 7e0e6c25..95580f0c 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -58,6 +58,50 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -212,6 +256,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -537,6 +590,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -585,6 +647,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libyml" version = "0.0.5" @@ -623,16 +691,27 @@ dependencies = [ ] [[package]] -name = "nix" -version = "0.27.1" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ - "bitflags", - "cfg-if", - "libc", + "mime", + "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "nix" version = "0.29.0" @@ -646,6 +725,16 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -655,6 +744,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -683,6 +781,12 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -982,8 +1086,9 @@ dependencies = [ name = "skillet_core" version = "0.1.0" dependencies = [ + "askama", "hex", - "nix 0.27.1", + "nix", "serde", "sha2", "tempfile", @@ -997,12 +1102,26 @@ dependencies = [ name = "skillet_hardening" version = "0.1.0" dependencies = [ + "askama", "skillet_core", + "skillet_podman", "tempfile", "thiserror", "tracing", ] +[[package]] +name = "skillet_podman" +version = "0.1.0" +dependencies = [ + "askama", + "serde", + "skillet_core", + "thiserror", + "tracing", + "users", +] + [[package]] name = "slab" version = "0.4.12" @@ -1184,6 +1303,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1508,7 +1633,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix 0.29.0", + "nix", "ordered-stream", "rand", "serde", diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index e2f4c38c..07d8db5c 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/core", "crates/hardening", + "crates/podman", "crates/cli", "crates/hosts/beezelbot", "crates/hosts/clamps", @@ -12,6 +13,7 @@ members = [ [workspace.dependencies] skillet_core = { path = "crates/core" } skillet_hardening = { path = "crates/hardening" } +skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } thiserror = "1.0" sha2 = "0.10" @@ -26,6 +28,7 @@ serde_json = "1.0" serde_yml = "0.0.12" hex = "0.4" zbus = { version = "4.4", features = ["blocking"] } +askama = "0.12" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ublue/skillet/crates/core/Cargo.toml b/ublue/skillet/crates/core/Cargo.toml index 1c80a9d2..801fa94e 100644 --- a/ublue/skillet/crates/core/Cargo.toml +++ b/ublue/skillet/crates/core/Cargo.toml @@ -16,6 +16,7 @@ hex.workspace = true serde.workspace = true tracing.workspace = true zbus.workspace = true +askama.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/ublue/skillet/crates/core/src/lib.rs b/ublue/skillet/crates/core/src/lib.rs index 59285565..07e024c2 100644 --- a/ublue/skillet/crates/core/src/lib.rs +++ b/ublue/skillet/crates/core/src/lib.rs @@ -2,5 +2,6 @@ pub mod files; pub mod recorder; pub mod resource_op; pub mod system; +pub mod templates; #[cfg(feature = "test-utils")] pub mod test_utils; diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index 18cb9127..7cea356f 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -92,6 +92,20 @@ impl SystemResource for Recorder { self.inner.ensure_group(name) } + fn ensure_user( + &self, + name: &str, + uid: Option, + gid: Option, + ) -> Result { + self.record(ResourceOp::EnsureUser { + name: name.to_string(), + uid, + gid, + }); + self.inner.ensure_user(name, uid, gid) + } + fn service_start(&self, name: &str) -> Result<(), SystemError> { self.record(ResourceOp::ServiceStart { name: name.to_string(), diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index 1896bcdb..ca6e8dfe 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -21,6 +21,11 @@ pub enum ResourceOp { EnsureGroup { name: String, }, + EnsureUser { + name: String, + uid: Option, + gid: Option, + }, ServiceStart { name: String, }, diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 1783df78..bc11849a 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -2,7 +2,7 @@ use std::process::Command; use std::sync::LazyLock; use thiserror::Error; use tracing::{debug, info, warn}; -use users::get_group_by_name; +use users::{get_group_by_name, get_user_by_name}; use zbus::proxy; static SYSTEMD_UNIT_SUFFIXES: LazyLock> = LazyLock::new(|| { @@ -57,6 +57,12 @@ pub enum SystemError { pub trait SystemResource { fn ensure_group(&self, name: &str) -> Result; + fn ensure_user( + &self, + name: &str, + uid: Option, + gid: Option, + ) -> Result; fn service_start(&self, name: &str) -> Result<(), SystemError>; fn service_stop(&self, name: &str) -> Result<(), SystemError>; fn service_restart(&self, name: &str) -> Result<(), SystemError>; @@ -124,6 +130,7 @@ impl Default for LinuxSystemResource { } const EXIT_CODE_GROUP_EXISTS: i32 = 9; +const EXIT_CODE_USER_EXISTS: i32 = 9; impl SystemResource for LinuxSystemResource { fn ensure_group(&self, name: &str) -> Result { @@ -138,7 +145,7 @@ impl SystemResource for LinuxSystemResource { let output = Command::new("groupadd").arg(name).output()?; if !output.status.success() { - // Check if group was created by another process in the meantime + // Check if group was created by another process in the meantime (exit code 9 for groupadd) if output.status.code() == Some(EXIT_CODE_GROUP_EXISTS) { debug!("Group {name} was created by another process"); return Ok(false); @@ -152,6 +159,54 @@ impl SystemResource for LinuxSystemResource { Ok(true) } + fn ensure_user( + &self, + name: &str, + uid: Option, + gid: Option, + ) -> Result { + // 1. Check if user exists using `users` crate + if let Some(user) = get_user_by_name(name) { + debug!("User {name} already exists"); + if let Some(desired_uid) = uid { + if user.uid() != desired_uid { + warn!( + "User {name} exists but UID {} does not match desired {}", + user.uid(), + desired_uid + ); + } + } + return Ok(false); + } + + // 2. Create user using `useradd` + info!("Creating user {name}"); + let mut cmd = Command::new("useradd"); + if let Some(u) = uid { + cmd.arg("-u").arg(u.to_string()); + } + if let Some(g) = gid { + cmd.arg("-g").arg(g.to_string()); + } + cmd.arg(name); + + let output = cmd.output()?; + + if !output.status.success() { + if output.status.code() == Some(EXIT_CODE_USER_EXISTS) { + debug!("User {name} was created by another process"); + return Ok(false); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SystemError::Command(format!("useradd failed: {stderr}"))); + } + + info!("Created user {name}"); + Ok(true) + } + fn service_start(&self, name: &str) -> Result<(), SystemError> { self.run_systemctl("start", name) } diff --git a/ublue/skillet/crates/core/src/templates.rs b/ublue/skillet/crates/core/src/templates.rs new file mode 100644 index 00000000..d5973c80 --- /dev/null +++ b/ublue/skillet/crates/core/src/templates.rs @@ -0,0 +1,25 @@ +use crate::files::{FileError, FileResource}; +use askama::Template; +use std::path::Path; + +pub fn ensure_templated_file( + files: &F, + path: &Path, + template: T, + mode: Option, + owner: Option<&str>, + group: Option<&str>, +) -> Result +where + T: Template, + F: FileResource + ?Sized, +{ + let content = template.render().map_err(|e| { + FileError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Template rendering failed: {e}"), + )) + })?; + + files.ensure_file(path, content.as_bytes(), mode, owner, group) +} diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index c5fafb51..efbdfb7c 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex}; pub struct MockSystem { pub groups: Arc>>, + pub users: Arc>>, pub services: Arc>>, // name -> state (started, stopped, restarted) } @@ -13,6 +14,7 @@ impl MockSystem { pub fn new() -> Self { Self { groups: Arc::new(Mutex::new(HashSet::new())), + users: Arc::new(Mutex::new(HashSet::new())), services: Arc::new(Mutex::new(HashMap::new())), } } @@ -35,6 +37,21 @@ impl SystemResource for MockSystem { } } + fn ensure_user( + &self, + name: &str, + _uid: Option, + _gid: Option, + ) -> Result { + let mut users = self.users.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if users.contains(name) { + Ok(false) + } else { + users.insert(name.to_string()); + Ok(true) + } + } + fn service_start(&self, name: &str) -> Result<(), SystemError> { self.services .lock() diff --git a/ublue/skillet/crates/hardening/Cargo.toml b/ublue/skillet/crates/hardening/Cargo.toml index 6d818c18..d7ef4b07 100644 --- a/ublue/skillet/crates/hardening/Cargo.toml +++ b/ublue/skillet/crates/hardening/Cargo.toml @@ -8,8 +8,10 @@ workspace = true [dependencies] skillet_core.workspace = true +skillet_podman.workspace = true thiserror.workspace = true tracing.workspace = true +askama.workspace = true [dev-dependencies] skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index 47c47a6d..6256f62a 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -1,5 +1,9 @@ +use askama::Template; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; +use skillet_core::templates::ensure_templated_file; +use skillet_podman::{self, ContainerUser, HostUser, PodmanError, Volume}; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; use thiserror::Error; use tracing::info; @@ -10,6 +14,14 @@ pub enum HardeningError { System(#[from] SystemError), #[error("File error: {0}")] File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), +} + +#[derive(Template)] +#[template(path = "pihole/custom.list.j2")] +struct CustomListTemplate { + custom: HashMap, } pub fn apply(system: &S, files: &F) -> Result<(), HardeningError> @@ -35,6 +47,9 @@ where // 4. Include 'ssh-hardening::client' apply_ssh_hardening_client(system, files)?; + // 5. Include 'pihole' + apply_pihole(system, files)?; + Ok(()) } @@ -97,6 +112,101 @@ where Ok(()) } +fn apply_pihole(system: &S, files: &F) -> Result<(), HardeningError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Applying pihole hardening..."); + let root = "/etc/pihole"; + let logs = "/var/log/pihole"; + + // 1. Ensure directories + files.ensure_directory(Path::new(root), Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + &Path::new(root).join("conf"), + Some(0o755), + Some("root"), + Some("root"), + )?; + files.ensure_directory( + &Path::new(root).join("dnsmasq.d"), + Some(0o755), + Some("root"), + Some("root"), + )?; + files.ensure_directory(Path::new(logs), Some(0o755), Some("root"), Some("root"))?; + + // 2. Custom list template + let mut custom = HashMap::new(); + custom.insert("192.168.1.100".to_string(), "my.custom.domain".to_string()); + + let template = CustomListTemplate { custom }; + ensure_templated_file( + files, + &Path::new(root).join("conf/custom.list"), + template, + Some(0o640), + Some("root"), + Some("root"), + )?; + + // 3. Define container + let user = ContainerUser { + container_uid: 0, // pihole usually runs as root in container + container_gid: 0, + host_user: Some(HostUser::Name("root".to_string())), + }; + + let volumes = vec![ + Volume { + host_path: format!("{root}/conf"), + container_path: "/etc/pihole".to_string(), + options: None, + }, + Volume { + host_path: format!("{root}/dnsmasq.d"), + container_path: "/etc/dnsmasq.d".to_string(), + options: None, + }, + Volume { + host_path: logs.to_string(), + container_path: "/var/log/pihole".to_string(), + options: None, + }, + ]; + + let mut extra_config = BTreeMap::new(); + extra_config.insert( + "Service".to_string(), + vec!["Restart=always".to_string()], + ); + extra_config.insert( + "Unit".to_string(), + vec![ + "Description=Pi. Hole".to_string(), + "After=network-online.target".to_string(), + ], + ); + extra_config.insert( + "Install".to_string(), + vec!["WantedBy=multi-user.target default.target".to_string()], + ); + + skillet_podman::container( + system, + files, + "pihole", + "docker.io/pihole/pihole:latest", + user, + false, + volumes, + extra_config, + )?; + + Ok(()) +} + #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/ublue/skillet/crates/hardening/src/tests.rs b/ublue/skillet/crates/hardening/src/tests.rs index 6ef71625..38e63f29 100644 --- a/ublue/skillet/crates/hardening/src/tests.rs +++ b/ublue/skillet/crates/hardening/src/tests.rs @@ -56,3 +56,39 @@ fn test_hardening_applies_ssh_client() { let content = String::from_utf8(files_map.get("/etc/ssh/ssh_config").unwrap().clone()).unwrap(); assert!(content.contains("StrictHostKeyChecking ask")); } + +#[test] +fn test_hardening_applies_pihole() { + let system = MockSystem::new(); + let files = MockFiles::new(); + apply(&system, &files).unwrap(); + let files_map = files.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + + // Verify directories + let dirs = files + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(dirs.contains("/etc/pihole")); + assert!(dirs.contains("/etc/pihole/conf")); + assert!(dirs.contains("/etc/pihole/dnsmasq.d")); + assert!(dirs.contains("/var/log/pihole")); + + // Verify custom.list + assert!(files_map.contains_key("/etc/pihole/conf/custom.list")); + let custom_list = + String::from_utf8(files_map.get("/etc/pihole/conf/custom.list").unwrap().clone()).unwrap(); + assert!(custom_list.contains("192.168.1.100 my.custom.domain")); + + // Verify Quadlet + assert!(files_map.contains_key("/etc/containers/systemd/pihole.container")); + let quadlet = String::from_utf8( + files_map + .get("/etc/containers/systemd/pihole.container") + .unwrap() + .clone(), + ) + .unwrap(); + assert!(quadlet.contains("Image=docker.io/pihole/pihole:latest")); + assert!(quadlet.contains("Volume=/etc/pihole/conf:/etc/pihole")); +} diff --git a/ublue/skillet/crates/hardening/templates/pihole/custom.list.j2 b/ublue/skillet/crates/hardening/templates/pihole/custom.list.j2 new file mode 100644 index 00000000..f5f7ae67 --- /dev/null +++ b/ublue/skillet/crates/hardening/templates/pihole/custom.list.j2 @@ -0,0 +1,3 @@ +{% for (ip, fqdn) in custom -%} +{{ ip }} {{ fqdn }} +{% endfor -%} diff --git a/ublue/skillet/crates/podman/Cargo.toml b/ublue/skillet/crates/podman/Cargo.toml new file mode 100644 index 00000000..97e8aed1 --- /dev/null +++ b/ublue/skillet/crates/podman/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "skillet_podman" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +thiserror.workspace = true +tracing.workspace = true +serde.workspace = true +askama.workspace = true +users.workspace = true diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs new file mode 100644 index 00000000..1dd3910f --- /dev/null +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -0,0 +1,187 @@ +use askama::Template; +use skillet_core::files::{FileError, FileResource}; +use skillet_core::system::{SystemError, SystemResource}; +use std::collections::BTreeMap; +use std::path::Path; +use thiserror::Error; +use tracing::info; +use users::{get_user_by_name, get_user_by_uid}; + +#[derive(Error, Debug)] +pub enum PodmanError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("User mapping error: {0}")] + UserMapping(String), +} + +#[derive(Template)] +#[template(path = "quadlet.container.j2")] +struct QuadletTemplate { + sections: BTreeMap>, +} + +pub struct ContainerUser { + pub container_uid: u32, + pub container_gid: u32, + pub host_user: Option, +} + +pub enum HostUser { + Name(String), + Uid(u32), +} + +pub struct Volume { + pub host_path: String, + pub container_path: String, + pub options: Option, +} + +pub fn container( + system: &S, + files: &F, + name: &str, + image: &str, + user: ContainerUser, + create_host_user: bool, + volumes: Vec, + mut extra_config: BTreeMap>, +) -> Result +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Ensuring podman container: {name}"); + + // 1. Resolve and ensure host user + let host_uid_gid = if let Some(hu) = user.host_user { + let (username, uid) = match hu { + HostUser::Name(ref n) => { + if create_host_user { + system.ensure_user(n, None, None)?; + } + let u = get_user_by_name(n).ok_or_else(|| { + PodmanError::UserMapping(format!("User {n} not found on host")) + })?; + (n.clone(), u.uid()) + } + HostUser::Uid(u) => { + let u_info = get_user_by_uid(u).ok_or_else(|| { + PodmanError::UserMapping(format!("UID {u} not found on host")) + })?; + (u_info.name().to_string_lossy().to_string(), u) + } + }; + // For simplicity, assuming gid = uid for now, but should ideally resolve gid too + let gid = uid; + Some((uid, gid, username)) + } else { + None + }; + + // 2. Calculate mappings + if let Some((h_uid, h_gid, _)) = host_uid_gid { + let c_uid = user.container_uid; + let c_gid = user.container_gid; + + // Formula: + // UIDMap=0:100000:C + // UIDMap=C:H:1 + // UIDMap=C+1:100000+C+1:65536-C-1 + + let sub_base = 100000; + let sub_size = 65536; + + let container_section = extra_config.entry("Container".to_string()).or_default(); + + container_section.push(format!("User={c_uid}:{c_gid}")); + + // UIDMap + if c_uid > 0 { + container_section.push(format!("UIDMap=0:{sub_base}:{c_uid}")); + } + container_section.push(format!("UIDMap={c_uid}:{h_uid}:1")); + let remaining = sub_size - c_uid - 1; + if remaining > 0 { + container_section.push(format!( + "UIDMap={}:{}:{remaining}", + c_uid + 1, + sub_base + c_uid + 1 + )); + } + + // GIDMap + if c_gid > 0 { + container_section.push(format!("GIDMap=0:{sub_base}:{c_gid}")); + } + container_section.push(format!("GIDMap={c_gid}:{h_gid}:1")); + let remaining_g = sub_size - c_gid - 1; + if remaining_g > 0 { + container_section.push(format!( + "GIDMap={}:{}:{remaining_g}", + c_gid + 1, + sub_base + c_gid + 1 + )); + } + } + + // 3. Ensure volumes + let container_section = extra_config.entry("Container".to_string()).or_default(); + container_section.push(format!("Image={image}")); + + for vol in volumes { + let host_path = Path::new(&vol.host_path); + + let (owner, group) = if let Some((_, _, ref name)) = host_uid_gid { + (Some(name.as_str()), Some(name.as_str())) + } else { + (Some("root"), Some("root")) + }; + + files.ensure_directory(host_path, Some(0o755), owner, group)?; + + let mut vol_line = format!("Volume={}:{}", vol.host_path, vol.container_path); + if let Some(opt) = vol.options { + vol_line.push_str(&format!(":{}", opt)); + } + container_section.push(vol_line); + } + + // Sort lines in each section for deterministic output + for lines in extra_config.values_mut() { + lines.sort(); + } + + // 4. Render Quadlet + let template = QuadletTemplate { + sections: extra_config, + }; + let content = template.render().map_err(|e| { + FileError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Template rendering failed: {e}"), + )) + })?; + + let quadlet_dir = Path::new("/etc/containers/systemd"); + files.ensure_directory(quadlet_dir, Some(0o755), Some("root"), Some("root"))?; + + let quadlet_path = quadlet_dir.join(format!("{name}.container")); + let changed = files.ensure_file( + &quadlet_path, + content.as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?; + + if changed { + info!("Quadlet changed, triggering daemon-reload"); + system.service_restart("daemon-reload")?; // Assuming service_restart handles this or we add service_reload + } + + Ok(changed) +} diff --git a/ublue/skillet/crates/podman/templates/quadlet.container.j2 b/ublue/skillet/crates/podman/templates/quadlet.container.j2 new file mode 100644 index 00000000..e482c84d --- /dev/null +++ b/ublue/skillet/crates/podman/templates/quadlet.container.j2 @@ -0,0 +1,6 @@ +{% for (section, lines) in sections -%} +[{{ section }}] +{% for line in lines -%} +{{ line }} +{% endfor %} +{% endfor -%} diff --git a/ublue/skillet/integration_tests/recordings/beezelbot.yaml b/ublue/skillet/integration_tests/recordings/beezelbot.yaml index 7cc20f21..fd668f8b 100644 --- a/ublue/skillet/integration_tests/recordings/beezelbot.yaml +++ b/ublue/skillet/integration_tests/recordings/beezelbot.yaml @@ -30,3 +30,57 @@ mode: '0o644' owner: root group: root +- !EnsureDirectory + path: /etc/pihole + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /etc/pihole/conf + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /etc/pihole/dnsmasq.d + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /var/log/pihole + mode: '0o755' + owner: root + group: root +- !EnsureFile + path: /etc/pihole/conf/custom.list + content_hash: dbdc31014de7ebb475613c9258d7378edde4bb19fe61da7565cb8056370c8fa5 + mode: '0o640' + owner: root + group: root +- !EnsureDirectory + path: /etc/pihole/conf + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /etc/pihole/dnsmasq.d + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /var/log/pihole + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /etc/containers/systemd + mode: '0o755' + owner: root + group: root +- !EnsureFile + path: /etc/containers/systemd/pihole.container + content_hash: '42061dec5eead432ae4da93ec36e86a525943cf2faea86da52b5d62d476d0e97' + mode: '0o644' + owner: root + group: root +- !ServiceRestart + name: daemon-reload diff --git a/ublue/skillet/integration_tests/recordings/clamps.yaml b/ublue/skillet/integration_tests/recordings/clamps.yaml index 7cc20f21..fd668f8b 100644 --- a/ublue/skillet/integration_tests/recordings/clamps.yaml +++ b/ublue/skillet/integration_tests/recordings/clamps.yaml @@ -30,3 +30,57 @@ mode: '0o644' owner: root group: root +- !EnsureDirectory + path: /etc/pihole + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /etc/pihole/conf + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /etc/pihole/dnsmasq.d + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /var/log/pihole + mode: '0o755' + owner: root + group: root +- !EnsureFile + path: /etc/pihole/conf/custom.list + content_hash: dbdc31014de7ebb475613c9258d7378edde4bb19fe61da7565cb8056370c8fa5 + mode: '0o640' + owner: root + group: root +- !EnsureDirectory + path: /etc/pihole/conf + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /etc/pihole/dnsmasq.d + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /var/log/pihole + mode: '0o755' + owner: root + group: root +- !EnsureDirectory + path: /etc/containers/systemd + mode: '0o755' + owner: root + group: root +- !EnsureFile + path: /etc/containers/systemd/pihole.container + content_hash: '42061dec5eead432ae4da93ec36e86a525943cf2faea86da52b5d62d476d0e97' + mode: '0o644' + owner: root + group: root +- !ServiceRestart + name: daemon-reload From edfcaa89357b6325a5763fe5f78d639f1bf3cde1 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 12:45:44 +0200 Subject: [PATCH 026/252] feat(podman): implement podman_container resource with UID/GID mapping and deterministic Quadlet generation --- ublue/skillet/crates/core/src/templates.rs | 9 +- ublue/skillet/crates/hardening/src/lib.rs | 16 +- ublue/skillet/crates/podman/src/lib.rs | 211 ++++++++++++--------- 3 files changed, 131 insertions(+), 105 deletions(-) diff --git a/ublue/skillet/crates/core/src/templates.rs b/ublue/skillet/crates/core/src/templates.rs index d5973c80..48d0e0c9 100644 --- a/ublue/skillet/crates/core/src/templates.rs +++ b/ublue/skillet/crates/core/src/templates.rs @@ -5,7 +5,7 @@ use std::path::Path; pub fn ensure_templated_file( files: &F, path: &Path, - template: T, + template: &T, mode: Option, owner: Option<&str>, group: Option<&str>, @@ -15,10 +15,9 @@ where F: FileResource + ?Sized, { let content = template.render().map_err(|e| { - FileError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Template rendering failed: {e}"), - )) + FileError::Io(std::io::Error::other(format!( + "Template rendering failed: {e}" + ))) })?; files.ensure_file(path, content.as_bytes(), mode, owner, group) diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index 6256f62a..38d5ceb5 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -145,7 +145,7 @@ where ensure_templated_file( files, &Path::new(root).join("conf/custom.list"), - template, + &template, Some(0o640), Some("root"), Some("root"), @@ -196,12 +196,14 @@ where skillet_podman::container( system, files, - "pihole", - "docker.io/pihole/pihole:latest", - user, - false, - volumes, - extra_config, + skillet_podman::PodmanConfig { + name: "pihole".to_string(), + image: "docker.io/pihole/pihole:latest".to_string(), + user, + create_host_user: false, + volumes, + extra_config, + }, )?; Ok(()) diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 1dd3910f..59f42491 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -40,112 +40,54 @@ pub struct Volume { pub options: Option, } -pub fn container( - system: &S, - files: &F, - name: &str, - image: &str, - user: ContainerUser, - create_host_user: bool, - volumes: Vec, - mut extra_config: BTreeMap>, -) -> Result +pub struct PodmanConfig { + pub name: String, + pub image: String, + pub user: ContainerUser, + pub create_host_user: bool, + pub volumes: Vec, + pub extra_config: BTreeMap>, +} + +pub fn container(system: &S, files: &F, config: PodmanConfig) -> Result where S: SystemResource + ?Sized, F: FileResource + ?Sized, { + let name = &config.name; info!("Ensuring podman container: {name}"); + let mut extra_config = config.extra_config; + // 1. Resolve and ensure host user - let host_uid_gid = if let Some(hu) = user.host_user { - let (username, uid) = match hu { - HostUser::Name(ref n) => { - if create_host_user { - system.ensure_user(n, None, None)?; - } - let u = get_user_by_name(n).ok_or_else(|| { - PodmanError::UserMapping(format!("User {n} not found on host")) - })?; - (n.clone(), u.uid()) - } - HostUser::Uid(u) => { - let u_info = get_user_by_uid(u).ok_or_else(|| { - PodmanError::UserMapping(format!("UID {u} not found on host")) - })?; - (u_info.name().to_string_lossy().to_string(), u) - } - }; - // For simplicity, assuming gid = uid for now, but should ideally resolve gid too - let gid = uid; - Some((uid, gid, username)) - } else { - None - }; + let host_info = resolve_host_user(system, &config.user, config.create_host_user)?; // 2. Calculate mappings - if let Some((h_uid, h_gid, _)) = host_uid_gid { - let c_uid = user.container_uid; - let c_gid = user.container_gid; - - // Formula: - // UIDMap=0:100000:C - // UIDMap=C:H:1 - // UIDMap=C+1:100000+C+1:65536-C-1 - - let sub_base = 100000; - let sub_size = 65536; - - let container_section = extra_config.entry("Container".to_string()).or_default(); - - container_section.push(format!("User={c_uid}:{c_gid}")); - - // UIDMap - if c_uid > 0 { - container_section.push(format!("UIDMap=0:{sub_base}:{c_uid}")); - } - container_section.push(format!("UIDMap={c_uid}:{h_uid}:1")); - let remaining = sub_size - c_uid - 1; - if remaining > 0 { - container_section.push(format!( - "UIDMap={}:{}:{remaining}", - c_uid + 1, - sub_base + c_uid + 1 - )); - } - - // GIDMap - if c_gid > 0 { - container_section.push(format!("GIDMap=0:{sub_base}:{c_gid}")); - } - container_section.push(format!("GIDMap={c_gid}:{h_gid}:1")); - let remaining_g = sub_size - c_gid - 1; - if remaining_g > 0 { - container_section.push(format!( - "GIDMap={}:{}:{remaining_g}", - c_gid + 1, - sub_base + c_gid + 1 - )); - } + if let Some((uid_host, gid_host, _)) = &host_info { + calculate_user_mappings(&config.user, *uid_host, *gid_host, &mut extra_config); } // 3. Ensure volumes let container_section = extra_config.entry("Container".to_string()).or_default(); - container_section.push(format!("Image={image}")); - - for vol in volumes { - let host_path = Path::new(&vol.host_path); + container_section.push(format!("Image={}", config.image)); - let (owner, group) = if let Some((_, _, ref name)) = host_uid_gid { + for vol in config.volumes { + let (owner, group) = if let Some((_, _, ref name)) = host_info { (Some(name.as_str()), Some(name.as_str())) } else { (Some("root"), Some("root")) }; - files.ensure_directory(host_path, Some(0o755), owner, group)?; + files.ensure_directory(Path::new(&vol.host_path), Some(0o755), owner, group)?; let mut vol_line = format!("Volume={}:{}", vol.host_path, vol.container_path); if let Some(opt) = vol.options { - vol_line.push_str(&format!(":{}", opt)); + use std::fmt::Write; + write!(vol_line, ":{opt}").map_err(|e| { + FileError::Io(std::io::Error::other(format!( + "Failed to format volume line: {e}" + ))) + })?; } container_section.push(vol_line); } @@ -155,15 +97,98 @@ where lines.sort(); } - // 4. Render Quadlet - let template = QuadletTemplate { - sections: extra_config, - }; + // 4. Render and ensure Quadlet file + render_and_ensure_quadlet(system, files, name, extra_config) +} + +fn resolve_host_user( + system: &S, + user: &ContainerUser, + create: bool, +) -> Result, PodmanError> { + if let Some(hu) = &user.host_user { + let (username, uid) = match hu { + HostUser::Name(ref n) => { + if create { + system.ensure_user(n, None, None)?; + } + let u = get_user_by_name(n).ok_or_else(|| { + PodmanError::UserMapping(format!("User {n} not found on host")) + })?; + (n.clone(), u.uid()) + } + HostUser::Uid(u) => { + let u_info = get_user_by_uid(*u).ok_or_else(|| { + PodmanError::UserMapping(format!("UID {u} not found on host")) + })?; + (u_info.name().to_string_lossy().to_string(), *u) + } + }; + // For simplicity, assuming gid = uid for now + Ok(Some((uid, uid, username))) + } else { + Ok(None) + } +} + +fn calculate_user_mappings( + user: &ContainerUser, + uid_host: u32, + gid_host: u32, + extra_config: &mut BTreeMap>, +) { + let uid_container = user.container_uid; + let gid_container = user.container_gid; + let sub_base = 100_000; + let sub_size = 65_536; + + let container_section = extra_config.entry("Container".to_string()).or_default(); + container_section.push(format!("User={uid_container}:{gid_container}")); + + // UIDMap + if uid_container > 0 { + container_section.push(format!("UIDMap=0:{sub_base}:{uid_container}")); + } + container_section.push(format!("UIDMap={uid_container}:{uid_host}:1")); + let rem_u = sub_size - uid_container - 1; + if rem_u > 0 { + container_section.push(format!( + "UIDMap={}:{}:{rem_u}", + uid_container + 1, + sub_base + uid_container + 1 + )); + } + + // GIDMap + if gid_container > 0 { + container_section.push(format!("GIDMap=0:{sub_base}:{gid_container}")); + } + container_section.push(format!("GIDMap={gid_container}:{gid_host}:1")); + let rem_g = sub_size - gid_container - 1; + if rem_g > 0 { + container_section.push(format!( + "GIDMap={}:{}:{rem_g}", + gid_container + 1, + sub_base + gid_container + 1 + )); + } +} + +fn render_and_ensure_quadlet( + system: &S, + files: &F, + name: &str, + sections: BTreeMap>, +) -> Result +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + let template = QuadletTemplate { sections }; let content = template.render().map_err(|e| { - FileError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Template rendering failed: {e}"), - )) + FileError::Io(std::io::Error::other(format!( + "Template rendering failed: {e}" + ))) })?; let quadlet_dir = Path::new("/etc/containers/systemd"); @@ -180,7 +205,7 @@ where if changed { info!("Quadlet changed, triggering daemon-reload"); - system.service_restart("daemon-reload")?; // Assuming service_restart handles this or we add service_reload + system.service_restart("daemon-reload")?; } Ok(changed) From bff478bfc6b4d93d68212567ef7490af4a258d68 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 15:41:04 +0200 Subject: [PATCH 027/252] refactor(skillet): implement host-specific configuration and pihole crate --- ublue/skillet/Cargo.lock | 14 +- ublue/skillet/Cargo.toml | 1 + ublue/skillet/crates/cli-common/src/lib.rs | 32 +++-- ublue/skillet/crates/cli/src/main.rs | 6 +- ublue/skillet/crates/hardening/Cargo.toml | 2 - ublue/skillet/crates/hardening/src/lib.rs | 112 ---------------- ublue/skillet/crates/hardening/src/tests.rs | 36 ------ .../skillet/crates/hosts/beezelbot/Cargo.toml | 1 + .../crates/hosts/beezelbot/src/main.rs | 4 +- ublue/skillet/crates/hosts/clamps/Cargo.toml | 2 + ublue/skillet/crates/hosts/clamps/src/main.rs | 6 +- ublue/skillet/crates/pihole/Cargo.toml | 14 ++ ublue/skillet/crates/pihole/src/lib.rs | 122 ++++++++++++++++++ .../templates/pihole/custom.list.j2 | 0 14 files changed, 184 insertions(+), 168 deletions(-) create mode 100644 ublue/skillet/crates/pihole/Cargo.toml create mode 100644 ublue/skillet/crates/pihole/src/lib.rs rename ublue/skillet/crates/{hardening => pihole}/templates/pihole/custom.list.j2 (100%) diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 95580f0c..9feb76d5 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1058,6 +1058,7 @@ version = "0.1.0" dependencies = [ "anyhow", "skillet_cli_common", + "skillet_hardening", ] [[package]] @@ -1066,6 +1067,8 @@ version = "0.1.0" dependencies = [ "anyhow", "skillet_cli_common", + "skillet_hardening", + "skillet_pihole", ] [[package]] @@ -1101,11 +1104,20 @@ dependencies = [ [[package]] name = "skillet_hardening" version = "0.1.0" +dependencies = [ + "skillet_core", + "tempfile", + "thiserror", + "tracing", +] + +[[package]] +name = "skillet_pihole" +version = "0.1.0" dependencies = [ "askama", "skillet_core", "skillet_podman", - "tempfile", "thiserror", "tracing", ] diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 07d8db5c..9aae1024 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -13,6 +13,7 @@ members = [ [workspace.dependencies] skillet_core = { path = "crates/core" } skillet_hardening = { path = "crates/hardening" } +skillet_pihole = { path = "crates/pihole" } skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } thiserror = "1.0" diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 9f5d84cf..c72fd768 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -1,8 +1,7 @@ use clap::Parser; -use skillet_core::files::LocalFileResource; +use skillet_core::files::{FileResource, LocalFileResource}; use skillet_core::recorder::Recorder; -use skillet_core::system::LinuxSystemResource; -use skillet_hardening::apply; +use skillet_core::system::{LinuxSystemResource, SystemResource}; use std::fs; use std::path::PathBuf; use thiserror::Error; @@ -11,8 +10,8 @@ use tracing_subscriber::FmtSubscriber; #[derive(Error, Debug)] pub enum CliCommonError { - #[error("Failed to apply hardening: {0}")] - Hardening(#[from] skillet_hardening::HardeningError), + #[error("Configuration error: {0}")] + Config(String), #[error("System error: {0}")] System(#[from] skillet_core::system::SystemError), #[error("Failed to set default tracing subscriber: {0}")] @@ -44,7 +43,10 @@ pub enum HostCommands { }, } -pub fn run_host(hostname: &str) -> Result<(), CliCommonError> { +pub fn run_host(hostname: &str, apply_fn: F) -> Result<(), CliCommonError> +where + F: Fn(&dyn SystemResource, &dyn FileResource) -> Result<(), String>, +{ let args = HostArgs::parse(); let subscriber = FmtSubscriber::builder() @@ -58,11 +60,18 @@ pub fn run_host(hostname: &str) -> Result<(), CliCommonError> { tracing::subscriber::set_global_default(subscriber)?; match args.command { - HostCommands::Apply { record } => handle_apply(hostname, record), + HostCommands::Apply { record } => handle_apply(hostname, record, apply_fn), } } -pub fn handle_apply(hostname: &str, record_path: Option) -> Result<(), CliCommonError> { +pub fn handle_apply( + hostname: &str, + record_path: Option, + apply_fn: F, +) -> Result<(), CliCommonError> +where + F: Fn(&dyn SystemResource, &dyn FileResource) -> Result<(), String>, +{ info!("Starting Skillet configuration for {}...", hostname); let system = LinuxSystemResource::new(); @@ -72,20 +81,17 @@ pub fn handle_apply(hostname: &str, record_path: Option) -> Result<(), let recorder_system = Recorder::new(system); let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); - apply(&recorder_system, &recorder_files)?; + apply_fn(&recorder_system, &recorder_files).map_err(CliCommonError::Config)?; let ops = recorder_system.get_ops(); let yaml = serde_yml::to_string(&ops)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } fs::write(&path, yaml)?; info!("Recording saved to {}", path.display()); } else { - apply(&system, &files)?; + apply_fn(&system, &files).map_err(CliCommonError::Config)?; } info!("Configuration applied successfully."); diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index a90a7fbb..64867f1f 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -75,8 +75,10 @@ fn main() -> Result<()> { match args.command { Commands::Apply { host, record } => { let hostname = host.as_deref().unwrap_or("(Agent Mode)"); - skillet_cli_common::handle_apply(hostname, record) - .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; + skillet_cli_common::handle_apply(hostname, record, |system, files| { + skillet_hardening::apply(system, files).map_err(|e| e.to_string()) + }) + .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; } Commands::Test { test_command } => handle_test(test_command)?, } diff --git a/ublue/skillet/crates/hardening/Cargo.toml b/ublue/skillet/crates/hardening/Cargo.toml index d7ef4b07..6d818c18 100644 --- a/ublue/skillet/crates/hardening/Cargo.toml +++ b/ublue/skillet/crates/hardening/Cargo.toml @@ -8,10 +8,8 @@ workspace = true [dependencies] skillet_core.workspace = true -skillet_podman.workspace = true thiserror.workspace = true tracing.workspace = true -askama.workspace = true [dev-dependencies] skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index 38d5ceb5..47c47a6d 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -1,9 +1,5 @@ -use askama::Template; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; -use skillet_core::templates::ensure_templated_file; -use skillet_podman::{self, ContainerUser, HostUser, PodmanError, Volume}; -use std::collections::{BTreeMap, HashMap}; use std::path::Path; use thiserror::Error; use tracing::info; @@ -14,14 +10,6 @@ pub enum HardeningError { System(#[from] SystemError), #[error("File error: {0}")] File(#[from] FileError), - #[error("Podman error: {0}")] - Podman(#[from] PodmanError), -} - -#[derive(Template)] -#[template(path = "pihole/custom.list.j2")] -struct CustomListTemplate { - custom: HashMap, } pub fn apply(system: &S, files: &F) -> Result<(), HardeningError> @@ -47,9 +35,6 @@ where // 4. Include 'ssh-hardening::client' apply_ssh_hardening_client(system, files)?; - // 5. Include 'pihole' - apply_pihole(system, files)?; - Ok(()) } @@ -112,103 +97,6 @@ where Ok(()) } -fn apply_pihole(system: &S, files: &F) -> Result<(), HardeningError> -where - S: SystemResource + ?Sized, - F: FileResource + ?Sized, -{ - info!("Applying pihole hardening..."); - let root = "/etc/pihole"; - let logs = "/var/log/pihole"; - - // 1. Ensure directories - files.ensure_directory(Path::new(root), Some(0o755), Some("root"), Some("root"))?; - files.ensure_directory( - &Path::new(root).join("conf"), - Some(0o755), - Some("root"), - Some("root"), - )?; - files.ensure_directory( - &Path::new(root).join("dnsmasq.d"), - Some(0o755), - Some("root"), - Some("root"), - )?; - files.ensure_directory(Path::new(logs), Some(0o755), Some("root"), Some("root"))?; - - // 2. Custom list template - let mut custom = HashMap::new(); - custom.insert("192.168.1.100".to_string(), "my.custom.domain".to_string()); - - let template = CustomListTemplate { custom }; - ensure_templated_file( - files, - &Path::new(root).join("conf/custom.list"), - &template, - Some(0o640), - Some("root"), - Some("root"), - )?; - - // 3. Define container - let user = ContainerUser { - container_uid: 0, // pihole usually runs as root in container - container_gid: 0, - host_user: Some(HostUser::Name("root".to_string())), - }; - - let volumes = vec![ - Volume { - host_path: format!("{root}/conf"), - container_path: "/etc/pihole".to_string(), - options: None, - }, - Volume { - host_path: format!("{root}/dnsmasq.d"), - container_path: "/etc/dnsmasq.d".to_string(), - options: None, - }, - Volume { - host_path: logs.to_string(), - container_path: "/var/log/pihole".to_string(), - options: None, - }, - ]; - - let mut extra_config = BTreeMap::new(); - extra_config.insert( - "Service".to_string(), - vec!["Restart=always".to_string()], - ); - extra_config.insert( - "Unit".to_string(), - vec![ - "Description=Pi. Hole".to_string(), - "After=network-online.target".to_string(), - ], - ); - extra_config.insert( - "Install".to_string(), - vec!["WantedBy=multi-user.target default.target".to_string()], - ); - - skillet_podman::container( - system, - files, - skillet_podman::PodmanConfig { - name: "pihole".to_string(), - image: "docker.io/pihole/pihole:latest".to_string(), - user, - create_host_user: false, - volumes, - extra_config, - }, - )?; - - Ok(()) -} - #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/ublue/skillet/crates/hardening/src/tests.rs b/ublue/skillet/crates/hardening/src/tests.rs index 38e63f29..6ef71625 100644 --- a/ublue/skillet/crates/hardening/src/tests.rs +++ b/ublue/skillet/crates/hardening/src/tests.rs @@ -56,39 +56,3 @@ fn test_hardening_applies_ssh_client() { let content = String::from_utf8(files_map.get("/etc/ssh/ssh_config").unwrap().clone()).unwrap(); assert!(content.contains("StrictHostKeyChecking ask")); } - -#[test] -fn test_hardening_applies_pihole() { - let system = MockSystem::new(); - let files = MockFiles::new(); - apply(&system, &files).unwrap(); - let files_map = files.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - - // Verify directories - let dirs = files - .directories - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert!(dirs.contains("/etc/pihole")); - assert!(dirs.contains("/etc/pihole/conf")); - assert!(dirs.contains("/etc/pihole/dnsmasq.d")); - assert!(dirs.contains("/var/log/pihole")); - - // Verify custom.list - assert!(files_map.contains_key("/etc/pihole/conf/custom.list")); - let custom_list = - String::from_utf8(files_map.get("/etc/pihole/conf/custom.list").unwrap().clone()).unwrap(); - assert!(custom_list.contains("192.168.1.100 my.custom.domain")); - - // Verify Quadlet - assert!(files_map.contains_key("/etc/containers/systemd/pihole.container")); - let quadlet = String::from_utf8( - files_map - .get("/etc/containers/systemd/pihole.container") - .unwrap() - .clone(), - ) - .unwrap(); - assert!(quadlet.contains("Image=docker.io/pihole/pihole:latest")); - assert!(quadlet.contains("Volume=/etc/pihole/conf:/etc/pihole")); -} diff --git a/ublue/skillet/crates/hosts/beezelbot/Cargo.toml b/ublue/skillet/crates/hosts/beezelbot/Cargo.toml index 4292cd45..6320f220 100644 --- a/ublue/skillet/crates/hosts/beezelbot/Cargo.toml +++ b/ublue/skillet/crates/hosts/beezelbot/Cargo.toml @@ -8,4 +8,5 @@ workspace = true [dependencies] skillet_cli_common.workspace = true +skillet_hardening.workspace = true anyhow = "1.0" diff --git a/ublue/skillet/crates/hosts/beezelbot/src/main.rs b/ublue/skillet/crates/hosts/beezelbot/src/main.rs index fdc19c19..cd1ad8cd 100644 --- a/ublue/skillet/crates/hosts/beezelbot/src/main.rs +++ b/ublue/skillet/crates/hosts/beezelbot/src/main.rs @@ -2,6 +2,8 @@ use anyhow::Result; use skillet_cli_common::run_host; fn main() -> Result<()> { - run_host("beezelbot")?; + run_host("beezelbot", |system, files| { + skillet_hardening::apply(system, files).map_err(|e| e.to_string()) + })?; Ok(()) } diff --git a/ublue/skillet/crates/hosts/clamps/Cargo.toml b/ublue/skillet/crates/hosts/clamps/Cargo.toml index 359f4857..8d560dac 100644 --- a/ublue/skillet/crates/hosts/clamps/Cargo.toml +++ b/ublue/skillet/crates/hosts/clamps/Cargo.toml @@ -8,4 +8,6 @@ workspace = true [dependencies] skillet_cli_common.workspace = true +skillet_hardening.workspace = true +skillet_pihole.workspace = true anyhow = "1.0" diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index e844294a..f281f0cd 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -2,6 +2,10 @@ use anyhow::Result; use skillet_cli_common::run_host; fn main() -> Result<()> { - run_host("clamps")?; + run_host("clamps", |system, files| { + skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + skillet_pihole::apply(system, files).map_err(|e| e.to_string())?; + Ok(()) + })?; Ok(()) } diff --git a/ublue/skillet/crates/pihole/Cargo.toml b/ublue/skillet/crates/pihole/Cargo.toml new file mode 100644 index 00000000..1d24fd33 --- /dev/null +++ b/ublue/skillet/crates/pihole/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "skillet_pihole" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +thiserror.workspace = true +tracing.workspace = true +askama.workspace = true diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs new file mode 100644 index 00000000..48388e5f --- /dev/null +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -0,0 +1,122 @@ +use askama::Template; +use skillet_core::files::{FileError, FileResource}; +use skillet_core::system::{SystemError, SystemResource}; +use skillet_core::templates::ensure_templated_file; +use skillet_podman::{self, ContainerUser, HostUser, PodmanError, Volume, PodmanConfig}; +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; +use thiserror::Error; +use tracing::info; + +#[derive(Error, Debug)] +pub enum PiholeError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), +} + +#[derive(Template)] +#[template(path = "pihole/custom.list.j2")] +struct CustomListTemplate { + custom: HashMap, +} + +pub fn apply(system: &S, files: &F) -> Result<(), PiholeError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Applying pihole configuration..."); + let root = "/etc/pihole"; + let logs = "/var/log/pihole"; + + // 1. Ensure directories + files.ensure_directory(Path::new(root), Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + &Path::new(root).join("conf"), + Some(0o755), + Some("root"), + Some("root"), + )?; + files.ensure_directory( + &Path::new(root).join("dnsmasq.d"), + Some(0o755), + Some("root"), + Some("root"), + )?; + files.ensure_directory(Path::new(logs), Some(0o755), Some("root"), Some("root"))?; + + // 2. Custom list template + let mut custom = HashMap::new(); + custom.insert("192.168.1.100".to_string(), "my.custom.domain".to_string()); + + let template = CustomListTemplate { custom }; + ensure_templated_file( + files, + &Path::new(root).join("conf/custom.list"), + &template, + Some(0o640), + Some("root"), + Some("root"), + )?; + + // 3. Define container + let user = ContainerUser { + container_uid: 0, // pihole usually runs as root in container + container_gid: 0, + host_user: Some(HostUser::Name("root".to_string())), + }; + + let volumes = vec![ + Volume { + host_path: format!("{root}/conf"), + container_path: "/etc/pihole".to_string(), + options: None, + }, + Volume { + host_path: format!("{root}/dnsmasq.d"), + container_path: "/etc/dnsmasq.d".to_string(), + options: None, + }, + Volume { + host_path: logs.to_string(), + container_path: "/var/log/pihole".to_string(), + options: None, + }, + ]; + + let mut extra_config = BTreeMap::new(); + extra_config.insert( + "Service".to_string(), + vec!["Restart=always".to_string()], + ); + extra_config.insert( + "Unit".to_string(), + vec![ + "Description=Pi. Hole".to_string(), + "After=network-online.target".to_string(), + ], + ); + extra_config.insert( + "Install".to_string(), + vec!["WantedBy=multi-user.target default.target".to_string()], + ); + + skillet_podman::container( + system, + files, + PodmanConfig { + name: "pihole".to_string(), + image: "docker.io/pihole/pihole:latest".to_string(), + user, + create_host_user: false, + volumes, + extra_config, + }, + )?; + + Ok(()) +} diff --git a/ublue/skillet/crates/hardening/templates/pihole/custom.list.j2 b/ublue/skillet/crates/pihole/templates/pihole/custom.list.j2 similarity index 100% rename from ublue/skillet/crates/hardening/templates/pihole/custom.list.j2 rename to ublue/skillet/crates/pihole/templates/pihole/custom.list.j2 From a424b24942e972007f8f5e4052aa347734d3e073 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 15:43:20 +0200 Subject: [PATCH 028/252] refactor(skillet): implement pihole as standalone crate and support host-specific configs --- .../recordings/beezelbot.yaml | 54 ------------------- 1 file changed, 54 deletions(-) diff --git a/ublue/skillet/integration_tests/recordings/beezelbot.yaml b/ublue/skillet/integration_tests/recordings/beezelbot.yaml index fd668f8b..7cc20f21 100644 --- a/ublue/skillet/integration_tests/recordings/beezelbot.yaml +++ b/ublue/skillet/integration_tests/recordings/beezelbot.yaml @@ -30,57 +30,3 @@ mode: '0o644' owner: root group: root -- !EnsureDirectory - path: /etc/pihole - mode: '0o755' - owner: root - group: root -- !EnsureDirectory - path: /etc/pihole/conf - mode: '0o755' - owner: root - group: root -- !EnsureDirectory - path: /etc/pihole/dnsmasq.d - mode: '0o755' - owner: root - group: root -- !EnsureDirectory - path: /var/log/pihole - mode: '0o755' - owner: root - group: root -- !EnsureFile - path: /etc/pihole/conf/custom.list - content_hash: dbdc31014de7ebb475613c9258d7378edde4bb19fe61da7565cb8056370c8fa5 - mode: '0o640' - owner: root - group: root -- !EnsureDirectory - path: /etc/pihole/conf - mode: '0o755' - owner: root - group: root -- !EnsureDirectory - path: /etc/pihole/dnsmasq.d - mode: '0o755' - owner: root - group: root -- !EnsureDirectory - path: /var/log/pihole - mode: '0o755' - owner: root - group: root -- !EnsureDirectory - path: /etc/containers/systemd - mode: '0o755' - owner: root - group: root -- !EnsureFile - path: /etc/containers/systemd/pihole.container - content_hash: '42061dec5eead432ae4da93ec36e86a525943cf2faea86da52b5d62d476d0e97' - mode: '0o644' - owner: root - group: root -- !ServiceRestart - name: daemon-reload From 551ec40d8d0f78d16dfc0cea4a262b6f6553c208 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 17:03:19 +0200 Subject: [PATCH 029/252] feat(skillet): implement host-specific config dispatch and pihole container resource --- ublue/skillet/crates/core/src/recorder.rs | 7 ++++++ ublue/skillet/crates/core/src/resource_op.rs | 3 +++ ublue/skillet/crates/core/src/system.rs | 25 +++++++++++++++++++ ublue/skillet/crates/core/src/test_utils.rs | 8 ++++++ ublue/skillet/crates/podman/src/lib.rs | 2 +- .../integration_tests/recordings/clamps.yaml | 2 +- 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index 7cea356f..213029fd 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -126,4 +126,11 @@ impl SystemResource for Recorder { }); self.inner.service_restart(name) } + + fn service_reload(&self, name: &str) -> Result<(), SystemError> { + self.record(ResourceOp::ServiceReload { + name: name.to_string(), + }); + self.inner.service_reload(name) + } } diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index ca6e8dfe..df3f79e2 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -35,4 +35,7 @@ pub enum ResourceOp { ServiceRestart { name: String, }, + ServiceReload { + name: String, + }, } diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index bc11849a..b6d8c2e6 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -41,6 +41,7 @@ trait SystemdManager { fn start_unit(&self, name: &str, mode: &str) -> zbus::Result; fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result; fn restart_unit(&self, name: &str, mode: &str) -> zbus::Result; + fn reload_unit(&self, name: &str, mode: &str) -> zbus::Result; } #[derive(Error, Debug)] @@ -66,6 +67,7 @@ pub trait SystemResource { fn service_start(&self, name: &str) -> Result<(), SystemError>; fn service_stop(&self, name: &str) -> Result<(), SystemError>; fn service_restart(&self, name: &str) -> Result<(), SystemError>; + fn service_reload(&self, name: &str) -> Result<(), SystemError>; } pub struct LinuxSystemResource { @@ -218,6 +220,29 @@ impl SystemResource for LinuxSystemResource { fn service_restart(&self, name: &str) -> Result<(), SystemError> { self.run_systemctl("restart", name) } + + fn service_reload(&self, name: &str) -> Result<(), SystemError> { + info!("Running systemctl reload {name} via DBus"); + if let Some(conn) = &self.conn { + let proxy = SystemdManagerProxyBlocking::new(conn)?; + let name_with_suffix = ensure_systemd_suffix(name); + proxy.reload_unit(&name_with_suffix, "replace")?; + return Ok(()); + } + + let output = Command::new("systemctl") + .arg("reload") + .arg(name) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SystemError::Command(format!( + "systemctl reload {name} failed: {stderr}" + ))); + } + Ok(()) + } } #[cfg(test)] #[path = "system/tests.rs"] diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index efbdfb7c..763b3d46 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -75,6 +75,14 @@ impl SystemResource for MockSystem { .insert(name.to_string(), "restarted".to_string()); Ok(()) } + + fn service_reload(&self, name: &str) -> Result<(), SystemError> { + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(name.to_string(), "reloaded".to_string()); + Ok(()) + } } pub type FileMetadata = (Option, Option, Option); diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 59f42491..ab16a914 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -205,7 +205,7 @@ where if changed { info!("Quadlet changed, triggering daemon-reload"); - system.service_restart("daemon-reload")?; + system.service_reload("daemon-reload")?; } Ok(changed) diff --git a/ublue/skillet/integration_tests/recordings/clamps.yaml b/ublue/skillet/integration_tests/recordings/clamps.yaml index fd668f8b..6a8ba961 100644 --- a/ublue/skillet/integration_tests/recordings/clamps.yaml +++ b/ublue/skillet/integration_tests/recordings/clamps.yaml @@ -82,5 +82,5 @@ mode: '0o644' owner: root group: root -- !ServiceRestart +- !ServiceReload name: daemon-reload From 9b8b6ede564ae891bf7fd5bf079c7920404cc78e Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 17:30:54 +0200 Subject: [PATCH 030/252] feat(pihole): pass pihole user configuration from host and run as unprivileged user --- ublue/skillet/crates/hosts/clamps/src/main.rs | 11 ++++++++- ublue/skillet/crates/pihole/src/lib.rs | 23 +++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index f281f0cd..595be4b0 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -4,7 +4,16 @@ use skillet_cli_common::run_host; fn main() -> Result<()> { run_host("clamps", |system, files| { skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; - skillet_pihole::apply(system, files).map_err(|e| e.to_string())?; + skillet_pihole::apply( + system, + files, + skillet_pihole::PiholeUser { + uid: 1001, + gid: 1001, + name: "pihole".to_string(), + }, + ) + .map_err(|e| e.to_string())?; Ok(()) })?; Ok(()) diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 48388e5f..86435d33 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -24,7 +24,13 @@ struct CustomListTemplate { custom: HashMap, } -pub fn apply(system: &S, files: &F) -> Result<(), PiholeError> +pub struct PiholeUser { + pub uid: u32, + pub gid: u32, + pub name: String, +} + +pub fn apply(system: &S, files: &F, user_config: PiholeUser) -> Result<(), PiholeError> where S: SystemResource + ?Sized, F: FileResource + ?Sized, @@ -33,7 +39,10 @@ where let root = "/etc/pihole"; let logs = "/var/log/pihole"; - // 1. Ensure directories + // 1. Ensure user + system.ensure_user(&user_config.name, Some(user_config.uid), Some(user_config.gid))?; + + // 2. Ensure directories files.ensure_directory(Path::new(root), Some(0o755), Some("root"), Some("root"))?; files.ensure_directory( &Path::new(root).join("conf"), @@ -49,7 +58,7 @@ where )?; files.ensure_directory(Path::new(logs), Some(0o755), Some("root"), Some("root"))?; - // 2. Custom list template + // 3. Custom list template let mut custom = HashMap::new(); custom.insert("192.168.1.100".to_string(), "my.custom.domain".to_string()); @@ -63,11 +72,11 @@ where Some("root"), )?; - // 3. Define container + // 4. Define container let user = ContainerUser { - container_uid: 0, // pihole usually runs as root in container - container_gid: 0, - host_user: Some(HostUser::Name("root".to_string())), + container_uid: 1000, + container_gid: 1000, + host_user: Some(HostUser::Name(user_config.name)), }; let volumes = vec![ From ce6007b57fbacf305748498fc8e0cdb37858a073 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 17:43:46 +0200 Subject: [PATCH 031/252] feat(pihole): update pihole UID/GID to 40000 on clamps --- ublue/skillet/crates/hosts/clamps/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index 595be4b0..7238f3e0 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -8,8 +8,8 @@ fn main() -> Result<()> { system, files, skillet_pihole::PiholeUser { - uid: 1001, - gid: 1001, + uid: 40000, + gid: 40000, name: "pihole".to_string(), }, ) From 38a6eb28935ed06f3774fbf1695bd1c2ebaf68f8 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 20:35:07 +0200 Subject: [PATCH 032/252] docs(skillet): add README.md with basic info --- ublue/skillet/README.md | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 ublue/skillet/README.md diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md new file mode 100644 index 00000000..a248bb84 --- /dev/null +++ b/ublue/skillet/README.md @@ -0,0 +1,50 @@ +# Skillet + +Skillet is a Rust-based tool for idempotent host configuration management. It is designed to be highly modular, with core primitives in `skillet_core`, hardening modules in `skillet_hardening`, and host-specific binaries built on top of these. + +## Building + +### Development Build +To build the workspace for development, use the standard cargo command: +```bash +cargo build +``` + +### Production Build +For optimized production builds, use the `--release` flag: +```bash +cargo build --release +``` + +## Running + +The tool provides an `apply` command to execute configuration. +- **Agent Mode**: Run generically on a host: + ```bash + ./target/debug/skillet apply + ``` +- **Host-Specific Configuration**: If a host-specific binary has been built (e.g., `skillet-beezelbot`), you can use it to apply specific configurations. + +## Testing + +Skillet uses containerized integration tests to verify idempotency and state changes. + +### Running Integration Tests +To verify an existing recording for a host (e.g., `beezelbot`): +```bash +./target/debug/skillet test run beezelbot --image fedora:latest +``` + +### Recording Integration Tests +To record a new configuration state for a host: +```bash +./target/debug/skillet test record beezelbot --image fedora:latest +``` + +You can append `--release` to these commands to test against production-optimized binaries. + +## Architectural Mandates +- **Error Handling**: Use `thiserror` in library crates; `anyhow` is reserved for CLI binaries. No `unwrap()` or `expect()` in library code. +- **Idempotency**: All modules must ensure system state idempotently. +- **System Interactions**: Prioritize Rust crates (e.g., `zbus`, `users`) over shelling out to system commands. +- **Linting**: All builds must pass `cargo clippy --pedantic`. From 92bd884f9feccb07a6d7b10bf657730b947fe3e3 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 21:38:03 +0200 Subject: [PATCH 033/252] fix(test): make entrypoint executable and use absolute path for skillet binary --- ublue/skillet/crates/cli/src/main.rs | 2 +- ublue/skillet/crates/cli/src/test_entrypoint.sh | 0 2 files changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 ublue/skillet/crates/cli/src/test_entrypoint.sh diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 64867f1f..20152a48 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -289,7 +289,7 @@ fn prepare_and_run_skillet(container_name: &str) -> Result<()> { "exec", container_name, "/tmp/test_entrypoint.sh", - "skillet", + "/usr/bin/skillet", "apply", "--record", "/tmp/ops.yaml", diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh old mode 100644 new mode 100755 From 706578665b8b55ca7e7192ed9399b47182e1faa9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 21:47:06 +0200 Subject: [PATCH 034/252] fix(test): properly copy and chmod skillet binary in container --- ublue/skillet/crates/cli/src/main.rs | 33 +++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 20152a48..8bd40063 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -283,13 +283,44 @@ fn prepare_and_run_skillet(container_name: &str) -> Result<()> { return Err(anyhow!("Failed to chmod entrypoint in container")); } + // Copy skillet binary to container + info!("Copying skillet binary to container..."); + let cp_bin_status = Command::new("podman") + .args([ + "cp", + binary_path.to_str().unwrap(), + &format!("{container_name}:/tmp/skillet"), + ]) + .status() + .context("Failed to copy skillet binary")?; + + if !cp_bin_status.success() { + return Err(anyhow!("Failed to copy skillet binary to container")); + } + + // Make skillet binary executable + let chmod_bin_status = Command::new("podman") + .args([ + "exec", + container_name, + "chmod", + "+x", + "/tmp/skillet", + ]) + .status() + .context("Failed to chmod skillet binary")?; + + if !chmod_bin_status.success() { + return Err(anyhow!("Failed to chmod skillet binary in container")); + } + info!("Executing skillet inside container..."); let exec_status = Command::new("podman") .args([ "exec", container_name, "/tmp/test_entrypoint.sh", - "/usr/bin/skillet", + "/tmp/skillet", "apply", "--record", "/tmp/ops.yaml", From c26a61f9608be96a628efbee30bfe3cef4999785 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 21:57:51 +0200 Subject: [PATCH 035/252] feat(pihole): implement unprivileged user execution and fix group name issue --- ublue/skillet/crates/core/src/system.rs | 51 +++++++------------ ublue/skillet/crates/hosts/clamps/src/main.rs | 1 + ublue/skillet/crates/pihole/Cargo.toml | 1 + ublue/skillet/crates/pihole/src/lib.rs | 5 +- .../integration_tests/recordings/clamps.yaml | 20 +++++--- 5 files changed, 36 insertions(+), 42 deletions(-) diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index b6d8c2e6..ac436878 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -57,7 +57,7 @@ pub enum SystemError { } pub trait SystemResource { - fn ensure_group(&self, name: &str) -> Result; + fn ensure_group(&self, name: &str, gid: Option) -> Result; fn ensure_user( &self, name: &str, @@ -96,6 +96,7 @@ impl LinuxSystemResource { "start" => proxy.start_unit(&name_with_suffix, "replace"), "stop" => proxy.stop_unit(&name_with_suffix, "replace"), "restart" => proxy.restart_unit(&name_with_suffix, "replace"), + "reload" => proxy.reload_unit(&name_with_suffix, "replace"), _ => { return Err(SystemError::Command(format!("Unsupported action: {action}"))); } @@ -135,7 +136,7 @@ const EXIT_CODE_GROUP_EXISTS: i32 = 9; const EXIT_CODE_USER_EXISTS: i32 = 9; impl SystemResource for LinuxSystemResource { - fn ensure_group(&self, name: &str) -> Result { + fn ensure_group(&self, name: &str, gid: Option) -> Result { // 1. Check if group exists using `users` crate if get_group_by_name(name).is_some() { debug!("Group {name} already exists"); @@ -144,7 +145,12 @@ impl SystemResource for LinuxSystemResource { // 2. Create group using `groupadd` info!("Creating group {name}"); - let output = Command::new("groupadd").arg(name).output()?; + let mut cmd = Command::new("groupadd"); + if let Some(g) = gid { + cmd.arg("-g").arg(g.to_string()); + } + cmd.arg(name); + let output = cmd.output()?; if !output.status.success() { // Check if group was created by another process in the meantime (exit code 9 for groupadd) @@ -168,21 +174,17 @@ impl SystemResource for LinuxSystemResource { gid: Option, ) -> Result { // 1. Check if user exists using `users` crate - if let Some(user) = get_user_by_name(name) { + if let Some(_user) = get_user_by_name(name) { debug!("User {name} already exists"); - if let Some(desired_uid) = uid { - if user.uid() != desired_uid { - warn!( - "User {name} exists but UID {} does not match desired {}", - user.uid(), - desired_uid - ); - } - } return Ok(false); } - // 2. Create user using `useradd` + // 2. Ensure group exists + if let Some(gid_val) = gid { + self.ensure_group(name, Some(gid_val))?; + } + + // 3. Create user using `useradd` info!("Creating user {name}"); let mut cmd = Command::new("useradd"); if let Some(u) = uid { @@ -222,26 +224,7 @@ impl SystemResource for LinuxSystemResource { } fn service_reload(&self, name: &str) -> Result<(), SystemError> { - info!("Running systemctl reload {name} via DBus"); - if let Some(conn) = &self.conn { - let proxy = SystemdManagerProxyBlocking::new(conn)?; - let name_with_suffix = ensure_systemd_suffix(name); - proxy.reload_unit(&name_with_suffix, "replace")?; - return Ok(()); - } - - let output = Command::new("systemctl") - .arg("reload") - .arg(name) - .output()?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(SystemError::Command(format!( - "systemctl reload {name} failed: {stderr}" - ))); - } - Ok(()) + self.run_systemctl("reload", name) } } #[cfg(test)] diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index 7238f3e0..ea2ff904 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -11,6 +11,7 @@ fn main() -> Result<()> { uid: 40000, gid: 40000, name: "pihole".to_string(), + group_name: "pihole".to_string(), }, ) .map_err(|e| e.to_string())?; diff --git a/ublue/skillet/crates/pihole/Cargo.toml b/ublue/skillet/crates/pihole/Cargo.toml index 1d24fd33..c33bc683 100644 --- a/ublue/skillet/crates/pihole/Cargo.toml +++ b/ublue/skillet/crates/pihole/Cargo.toml @@ -12,3 +12,4 @@ skillet_podman.workspace = true thiserror.workspace = true tracing.workspace = true askama.workspace = true +users.workspace = true diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 86435d33..08364c36 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeMap, HashMap}; use std::path::Path; use thiserror::Error; use tracing::info; +use users::{get_user_by_name, get_user_by_uid}; #[derive(Error, Debug)] pub enum PiholeError { @@ -28,6 +29,7 @@ pub struct PiholeUser { pub uid: u32, pub gid: u32, pub name: String, + pub group_name: String, } pub fn apply(system: &S, files: &F, user_config: PiholeUser) -> Result<(), PiholeError> @@ -39,7 +41,8 @@ where let root = "/etc/pihole"; let logs = "/var/log/pihole"; - // 1. Ensure user + // 1. Ensure user and group + system.ensure_group(&user_config.group_name, Some(user_config.gid))?; system.ensure_user(&user_config.name, Some(user_config.uid), Some(user_config.gid))?; // 2. Ensure directories diff --git a/ublue/skillet/integration_tests/recordings/clamps.yaml b/ublue/skillet/integration_tests/recordings/clamps.yaml index 6a8ba961..83ff8b2f 100644 --- a/ublue/skillet/integration_tests/recordings/clamps.yaml +++ b/ublue/skillet/integration_tests/recordings/clamps.yaml @@ -30,6 +30,12 @@ mode: '0o644' owner: root group: root +- !EnsureGroup + name: pihole +- !EnsureUser + name: pihole + uid: 40000 + gid: 40000 - !EnsureDirectory path: /etc/pihole mode: '0o755' @@ -59,18 +65,18 @@ - !EnsureDirectory path: /etc/pihole/conf mode: '0o755' - owner: root - group: root + owner: pihole + group: pihole - !EnsureDirectory path: /etc/pihole/dnsmasq.d mode: '0o755' - owner: root - group: root + owner: pihole + group: pihole - !EnsureDirectory path: /var/log/pihole mode: '0o755' - owner: root - group: root + owner: pihole + group: pihole - !EnsureDirectory path: /etc/containers/systemd mode: '0o755' @@ -78,7 +84,7 @@ group: root - !EnsureFile path: /etc/containers/systemd/pihole.container - content_hash: '42061dec5eead432ae4da93ec36e86a525943cf2faea86da52b5d62d476d0e97' + content_hash: '4f05daa46a380f678f4a77397b3422355ec21ff8e993aace450cf2360b56857d' mode: '0o644' owner: root group: root From 9a3cd849aaa15ba44d2d4bbebc69fd0db659e1c9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 22:04:11 +0200 Subject: [PATCH 036/252] feat(pihole): update pihole UID to 40000 --- ublue/skillet/Cargo.lock | 1 + ublue/skillet/crates/cli/src/main.rs | 11 ++++------- ublue/skillet/crates/core/src/recorder.rs | 5 +++-- ublue/skillet/crates/core/src/system/tests.rs | 4 ++-- ublue/skillet/crates/core/src/test_utils.rs | 2 +- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 9feb76d5..ce8256ba 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1120,6 +1120,7 @@ dependencies = [ "skillet_podman", "thiserror", "tracing", + "users", ] [[package]] diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 8bd40063..155f61fd 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -124,7 +124,7 @@ fn run_container_test( setup_container(&container_name, image, &binary_path)?; let result = (|| -> Result<()> { - prepare_and_run_skillet(&container_name)?; + prepare_and_run_skillet(&container_name, &binary_path)?; verify_or_record(hostname, &container_name, is_record)?; Ok(()) })(); @@ -136,12 +136,9 @@ fn run_container_test( fn stop_container(container_name: &str) { info!("Stopping container {container_name}..."); - let status = Command::new("podman") + let _ = Command::new("podman") .args(["rm", "-f", container_name]) - .status(); - if let Err(e) = status { - error!("Failed to execute podman to stop container {}: {}", container_name, e); - } + .output(); } fn build_workspace(release: bool) -> Result<()> { @@ -242,7 +239,7 @@ fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Res Ok(()) } -fn prepare_and_run_skillet(container_name: &str) -> Result<()> { +fn prepare_and_run_skillet(container_name: &str, binary_path: &Path) -> Result<()> { // Prepare entrypoint script let entrypoint_content = include_str!("test_entrypoint.sh"); let mut temp_entrypoint = tempfile::Builder::new().suffix(".sh").tempfile()?; diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index 213029fd..ade6c432 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -85,13 +85,14 @@ impl FileResource for Recorder { } impl SystemResource for Recorder { - fn ensure_group(&self, name: &str) -> Result { + fn ensure_group(&self, name: &str, gid: Option) -> Result { self.record(ResourceOp::EnsureGroup { name: name.to_string(), }); - self.inner.ensure_group(name) + self.inner.ensure_group(name, gid) } + fn ensure_user( &self, name: &str, diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 8c50d2ae..389035c8 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -6,7 +6,7 @@ use crate::test_utils::MockSystem; #[cfg(feature = "test-utils")] fn test_mock_system_resource() { let system = MockSystem::new(); - let changed = system.ensure_group("syslog").unwrap(); + let changed = system.ensure_group("syslog", None).unwrap(); assert!(changed); assert!(system .groups @@ -14,7 +14,7 @@ fn test_mock_system_resource() { .unwrap_or_else(std::sync::PoisonError::into_inner) .contains("syslog")); - let changed_again = system.ensure_group("syslog").unwrap(); + let changed_again = system.ensure_group("syslog", None).unwrap(); assert!(!changed_again); } diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 763b3d46..a1ae4ee3 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -27,7 +27,7 @@ impl Default for MockSystem { } impl SystemResource for MockSystem { - fn ensure_group(&self, name: &str) -> Result { + fn ensure_group(&self, name: &str, _gid: Option) -> Result { let mut groups = self.groups.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if groups.contains(name) { Ok(false) From b3f7dc404b0cade7f8aa81552ea5a191c9506b0a Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 8 Apr 2026 22:11:49 +0200 Subject: [PATCH 037/252] Remove unused code --- ublue/skillet/crates/pihole/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 08364c36..30e854d6 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -7,7 +7,6 @@ use std::collections::{BTreeMap, HashMap}; use std::path::Path; use thiserror::Error; use tracing::info; -use users::{get_user_by_name, get_user_by_uid}; #[derive(Error, Debug)] pub enum PiholeError { From 51fc85e710b83313ea7d23a6d2b74c22e78a27b4 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 9 Apr 2026 21:07:16 +0200 Subject: [PATCH 038/252] feat(cli): add --inspect flag to test subcommands and fix binary copy in container --- ublue/skillet/crates/cli/src/main.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 155f61fd..28c3a4a0 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -47,6 +47,9 @@ enum TestCommands { /// Build in release mode #[arg(long)] release: bool, + /// Inspect the container after application (interactive shell) + #[arg(long)] + inspect: bool, }, Run { hostname: String, @@ -55,6 +58,9 @@ enum TestCommands { /// Build in release mode #[arg(long)] release: bool, + /// Inspect the container after application (interactive shell) + #[arg(long)] + inspect: bool, }, } @@ -91,20 +97,22 @@ fn handle_test(cmd: TestCommands) -> Result<()> { hostname, image, release, + inspect, } => { info!("Recording integration test for host: {}", hostname); - run_container_test(&hostname, &image, true, release)?; + run_container_test(&hostname, &image, true, release, inspect)?; } TestCommands::Run { hostname, image, release, + inspect, } => { info!( "Running integration test verification for host: {}", hostname ); - run_container_test(&hostname, &image, false, release)?; + run_container_test(&hostname, &image, false, release, inspect)?; } } Ok(()) @@ -115,6 +123,7 @@ fn run_container_test( image: &str, is_record: bool, release: bool, + inspect: bool, ) -> Result<()> { build_workspace(release)?; @@ -129,11 +138,24 @@ fn run_container_test( Ok(()) })(); + if inspect { + inspect_container(&container_name)?; + } + stop_container(&container_name); result } +fn inspect_container(container_name: &str) -> Result<()> { + info!("Starting interactive inspection shell in {container_name}..."); + let _ = Command::new("podman") + .args(["exec", "-it", container_name, "/bin/bash"]) + .status() + .context("Failed to run inspection shell")?; + Ok(()) +} + fn stop_container(container_name: &str) { info!("Stopping container {container_name}..."); let _ = Command::new("podman") From b68fc5f683837c8434b1b94001bb01c5293ccdb7 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 11 Apr 2026 15:10:17 +0200 Subject: [PATCH 039/252] feat(skillet): implement secure secret management and quadlet integration --- ublue/skillet/Cargo.lock | 2 + ublue/skillet/crates/cli/src/main.rs | 10 ++++ .../skillet/crates/cli/src/test_entrypoint.sh | 21 +++++++ ublue/skillet/crates/core/src/credentials.rs | 35 +++++++++++ ublue/skillet/crates/core/src/lib.rs | 1 + ublue/skillet/crates/core/src/recorder.rs | 12 ++++ ublue/skillet/crates/core/src/resource_op.rs | 4 ++ ublue/skillet/crates/core/src/system.rs | 36 ++++++++++- ublue/skillet/crates/core/src/test_utils.rs | 12 ++++ ublue/skillet/crates/hosts/clamps/Cargo.toml | 2 + ublue/skillet/crates/hosts/clamps/src/main.rs | 27 +++++++++ ublue/skillet/crates/pihole/src/lib.rs | 10 +++- ublue/skillet/crates/podman/src/lib.rs | 60 ++++++++++++++++--- .../integration_tests/recordings/clamps.yaml | 5 +- 14 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 ublue/skillet/crates/core/src/credentials.rs diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index ce8256ba..8351f3c8 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1067,8 +1067,10 @@ version = "0.1.0" dependencies = [ "anyhow", "skillet_cli_common", + "skillet_core", "skillet_hardening", "skillet_pihole", + "skillet_podman", ] [[package]] diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 28c3a4a0..812f54a6 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -239,6 +239,12 @@ fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Res .args(["rm", "-f", container_name]) .output(); + // Create a mock credentials directory + let root = find_workspace_root()?; + let mock_creds_dir = root.join("target/mock_creds"); + fs::create_dir_all(&mock_creds_dir)?; + fs::write(mock_creds_dir.join("test_secret"), "supersecret_payload")?; + let run_status = Command::new("podman") .args([ "run", @@ -248,6 +254,10 @@ fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Res container_name, "-v", &format!("{}:/usr/bin/skillet:ro", binary_path.display()), + "-v", + &format!("{}:/run/credentials:ro", mock_creds_dir.display()), + "-e", + "CREDENTIALS_DIRECTORY=/run/credentials", image, "sleep", "infinity", diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh index 285d649d..ce92a99a 100755 --- a/ublue/skillet/crates/cli/src/test_entrypoint.sh +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -15,5 +15,26 @@ EOF chmod +x /usr/bin/systemctl fi +# Mock podman if it doesn't exist +if [ ! -x /usr/bin/podman ]; then + echo "Mocking podman..." + cat < /usr/bin/podman +#!/bin/sh +case "\$*" in + "secret inspect"*) + exit 1 # Secret doesn't exist + ;; + "secret create"*) + exit 0 # Successfully created + ;; + *) + echo "Mock podman: \$@" + exit 0 + ;; +esac +EOF + chmod +x /usr/bin/podman +fi + # Execute the passed command (skillet apply) exec "$@" diff --git a/ublue/skillet/crates/core/src/credentials.rs b/ublue/skillet/crates/core/src/credentials.rs new file mode 100644 index 00000000..4dfb6e01 --- /dev/null +++ b/ublue/skillet/crates/core/src/credentials.rs @@ -0,0 +1,35 @@ +use std::io::Read as _; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CredentialError { + #[error("CREDENTIALS_DIRECTORY environment variable not set")] + NoDirectory, + #[error("Failed to read secret {0}: {1}")] + ReadError(String, std::io::Error), +} + +pub struct CredentialManager { + base_path: PathBuf, +} + +impl CredentialManager { + pub fn new() -> Result { + let path = std::env::var("CREDENTIALS_DIRECTORY") + .map(|s| PathBuf::from(s.trim())) + .map_err(|_| CredentialError::NoDirectory)?; + Ok(Self { base_path: path }) + } + + pub fn read_secret(&self, name: &str) -> Result { + let secret_path = self.base_path.join(name); + + let mut file = std::fs::File::open(&secret_path) + .map_err(|e| CredentialError::ReadError(name.to_string(), e))?; + let mut content = String::new(); + file.read_to_string(&mut content) + .map_err(|e| CredentialError::ReadError(name.to_string(), e))?; + Ok(content) + } +} diff --git a/ublue/skillet/crates/core/src/lib.rs b/ublue/skillet/crates/core/src/lib.rs index 07e024c2..9b606425 100644 --- a/ublue/skillet/crates/core/src/lib.rs +++ b/ublue/skillet/crates/core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod credentials; pub mod files; pub mod recorder; pub mod resource_op; diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index ade6c432..e341c354 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -107,6 +107,18 @@ impl SystemResource for Recorder { self.inner.ensure_user(name, uid, gid) } + fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { + let mut hasher = Sha256::new(); + hasher.update(payload.as_bytes()); + let hash = hex::encode(hasher.finalize()); + + self.record(ResourceOp::EnsurePodmanSecret { + name: name.to_string(), + payload_hash: hash, + }); + self.inner.ensure_podman_secret(name, payload) + } + fn service_start(&self, name: &str) -> Result<(), SystemError> { self.record(ResourceOp::ServiceStart { name: name.to_string(), diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index df3f79e2..630ebd42 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -26,6 +26,10 @@ pub enum ResourceOp { uid: Option, gid: Option, }, + EnsurePodmanSecret { + name: String, + payload_hash: String, + }, ServiceStart { name: String, }, diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index ac436878..ae77e28a 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -1,4 +1,5 @@ -use std::process::Command; +use std::io::Write as _; +use std::process::{Command, Stdio}; use std::sync::LazyLock; use thiserror::Error; use tracing::{debug, info, warn}; @@ -64,6 +65,7 @@ pub trait SystemResource { uid: Option, gid: Option, ) -> Result; + fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result; fn service_start(&self, name: &str) -> Result<(), SystemError>; fn service_stop(&self, name: &str) -> Result<(), SystemError>; fn service_restart(&self, name: &str) -> Result<(), SystemError>; @@ -211,6 +213,38 @@ impl SystemResource for LinuxSystemResource { Ok(true) } + fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { + // 1. Check if secret exists + let inspect_output = Command::new("podman") + .args(["secret", "inspect", name]) + .output()?; + + if inspect_output.status.success() { + debug!("Podman secret {name} already exists"); + return Ok(false); + } + + // 2. Create secret by piping payload to stdin + info!("Creating podman secret {name}"); + let mut child = Command::new("podman") + .args(["secret", "create", name, "-"]) + .stdin(Stdio::piped()) + .spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(payload.as_bytes())?; + } + + let status = child.wait()?; + if !status.success() { + return Err(SystemError::Command(format!( + "podman secret create {name} failed" + ))); + } + + Ok(true) + } + fn service_start(&self, name: &str) -> Result<(), SystemError> { self.run_systemctl("start", name) } diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index a1ae4ee3..b2a2f684 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex}; pub struct MockSystem { pub groups: Arc>>, pub users: Arc>>, + pub podman_secrets: Arc>>, pub services: Arc>>, // name -> state (started, stopped, restarted) } @@ -15,6 +16,7 @@ impl MockSystem { Self { groups: Arc::new(Mutex::new(HashSet::new())), users: Arc::new(Mutex::new(HashSet::new())), + podman_secrets: Arc::new(Mutex::new(HashSet::new())), services: Arc::new(Mutex::new(HashMap::new())), } } @@ -52,6 +54,16 @@ impl SystemResource for MockSystem { } } + fn ensure_podman_secret(&self, name: &str, _payload: &str) -> Result { + let mut secrets = self.podman_secrets.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if secrets.contains(name) { + Ok(false) + } else { + secrets.insert(name.to_string()); + Ok(true) + } + } + fn service_start(&self, name: &str) -> Result<(), SystemError> { self.services .lock() diff --git a/ublue/skillet/crates/hosts/clamps/Cargo.toml b/ublue/skillet/crates/hosts/clamps/Cargo.toml index 8d560dac..6e623ef1 100644 --- a/ublue/skillet/crates/hosts/clamps/Cargo.toml +++ b/ublue/skillet/crates/hosts/clamps/Cargo.toml @@ -10,4 +10,6 @@ workspace = true skillet_cli_common.workspace = true skillet_hardening.workspace = true skillet_pihole.workspace = true +skillet_core.workspace = true +skillet_podman.workspace = true anyhow = "1.0" diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index ea2ff904..c829449f 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -1,9 +1,34 @@ use anyhow::Result; use skillet_cli_common::run_host; +use skillet_core::credentials::CredentialManager; +use skillet_podman::{QuadletSecret, SecretTarget}; fn main() -> Result<()> { run_host("clamps", |system, files| { skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + + // 1. Ingest secret from systemd + let cred_manager = CredentialManager::new().map_err(|e: skillet_core::credentials::CredentialError| e.to_string())?; + let secret_payload = cred_manager + .read_secret("test_secret") + .map_err(|e: skillet_core::credentials::CredentialError| e.to_string())?; + + // 2. Provision to Podman + system + .ensure_podman_secret("pihole_web_password", &secret_payload) + .map_err(|e| e.to_string())?; + + // 3. Apply pihole with the secret + let secrets = vec![QuadletSecret { + secret_name: "pihole_web_password".to_string(), + target: SecretTarget::File { + target_path: "/etc/pihole/webpassword".to_string(), + mode: Some("0400".to_string()), + uid: Some(40000), + gid: Some(40000), + }, + }]; + skillet_pihole::apply( system, files, @@ -13,8 +38,10 @@ fn main() -> Result<()> { name: "pihole".to_string(), group_name: "pihole".to_string(), }, + secrets, ) .map_err(|e| e.to_string())?; + Ok(()) })?; Ok(()) diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 30e854d6..63972953 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -2,7 +2,7 @@ use askama::Template; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; use skillet_core::templates::ensure_templated_file; -use skillet_podman::{self, ContainerUser, HostUser, PodmanError, Volume, PodmanConfig}; +use skillet_podman::{self, ContainerUser, HostUser, PodmanError, Volume, PodmanConfig, QuadletSecret}; use std::collections::{BTreeMap, HashMap}; use std::path::Path; use thiserror::Error; @@ -31,7 +31,12 @@ pub struct PiholeUser { pub group_name: String, } -pub fn apply(system: &S, files: &F, user_config: PiholeUser) -> Result<(), PiholeError> +pub fn apply( + system: &S, + files: &F, + user_config: PiholeUser, + secrets: Vec, +) -> Result<(), PiholeError> where S: SystemResource + ?Sized, F: FileResource + ?Sized, @@ -125,6 +130,7 @@ where user, create_host_user: false, volumes, + secrets, extra_config, }, )?; diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index ab16a914..cf146976 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -2,6 +2,7 @@ use askama::Template; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; use std::collections::BTreeMap; +use std::fmt::Write as _; use std::path::Path; use thiserror::Error; use tracing::info; @@ -40,12 +41,58 @@ pub struct Volume { pub options: Option, } +pub enum SecretTarget { + File { + target_path: String, + mode: Option, + uid: Option, + gid: Option, + }, + Environment { + env_var_name: String, + }, +} + +pub struct QuadletSecret { + pub secret_name: String, + pub target: SecretTarget, +} + +impl QuadletSecret { + pub fn to_directive(&self) -> String { + match &self.target { + SecretTarget::File { + target_path, + mode, + uid, + gid, + } => { + let mut s = format!("Secret={},target={}", self.secret_name, target_path); + if let Some(m) = mode { + let _ = write!(s, ",mode={m}"); + } + if let Some(u) = uid { + let _ = write!(s, ",uid={u}"); + } + if let Some(g) = gid { + let _ = write!(s, ",gid={g}"); + } + s + } + SecretTarget::Environment { env_var_name } => { + format!("Secret={},type=env,target={}", self.secret_name, env_var_name) + } + } + } +} + pub struct PodmanConfig { pub name: String, pub image: String, pub user: ContainerUser, pub create_host_user: bool, pub volumes: Vec, + pub secrets: Vec, pub extra_config: BTreeMap>, } @@ -67,7 +114,7 @@ where calculate_user_mappings(&config.user, *uid_host, *gid_host, &mut extra_config); } - // 3. Ensure volumes + // 3. Ensure volumes and secrets let container_section = extra_config.entry("Container".to_string()).or_default(); container_section.push(format!("Image={}", config.image)); @@ -82,16 +129,15 @@ where let mut vol_line = format!("Volume={}:{}", vol.host_path, vol.container_path); if let Some(opt) = vol.options { - use std::fmt::Write; - write!(vol_line, ":{opt}").map_err(|e| { - FileError::Io(std::io::Error::other(format!( - "Failed to format volume line: {e}" - ))) - })?; + let _ = write!(vol_line, ":{opt}"); } container_section.push(vol_line); } + for secret in config.secrets { + container_section.push(secret.to_directive()); + } + // Sort lines in each section for deterministic output for lines in extra_config.values_mut() { lines.sort(); diff --git a/ublue/skillet/integration_tests/recordings/clamps.yaml b/ublue/skillet/integration_tests/recordings/clamps.yaml index 83ff8b2f..121b4284 100644 --- a/ublue/skillet/integration_tests/recordings/clamps.yaml +++ b/ublue/skillet/integration_tests/recordings/clamps.yaml @@ -30,6 +30,9 @@ mode: '0o644' owner: root group: root +- !EnsurePodmanSecret + name: pihole_web_password + payload_hash: '08c11e0cf8665e346fb2058d8e896a4a315ad839b0db208cd90fc92112071f79' - !EnsureGroup name: pihole - !EnsureUser @@ -84,7 +87,7 @@ group: root - !EnsureFile path: /etc/containers/systemd/pihole.container - content_hash: '4f05daa46a380f678f4a77397b3422355ec21ff8e993aace450cf2360b56857d' + content_hash: '8183a070ffaa22adefeb374aa059355890d1a4adf37ec4f863aae3e14d34115c' mode: '0o644' owner: root group: root From 32f8f3d2e8f92c950b4927057f95359464166b00 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 11 Apr 2026 15:43:36 +0200 Subject: [PATCH 040/252] refactor(core): implement robust resource management and address PR comments --- ublue/skillet/crates/cli/src/main.rs | 126 ++++------------------ ublue/skillet/crates/core/src/files.rs | 106 +++++++++--------- ublue/skillet/crates/core/src/recorder.rs | 11 +- ublue/skillet/crates/core/src/system.rs | 19 ++-- 4 files changed, 88 insertions(+), 174 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 812f54a6..d4272809 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -23,9 +23,6 @@ struct Args { enum Commands { /// Apply configuration (Agent Mode) Apply { - /// Optional: Hostname to apply configuration for - #[arg(long)] - host: Option, /// Optional: Output recorded actions to this file path #[arg(long)] record: Option, @@ -44,23 +41,11 @@ enum TestCommands { /// Container image to use #[arg(long, default_value = "fedora:latest")] image: String, - /// Build in release mode - #[arg(long)] - release: bool, - /// Inspect the container after application (interactive shell) - #[arg(long)] - inspect: bool, }, Run { hostname: String, #[arg(long, default_value = "fedora:latest")] image: String, - /// Build in release mode - #[arg(long)] - release: bool, - /// Inspect the container after application (interactive shell) - #[arg(long)] - inspect: bool, }, } @@ -79,9 +64,8 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { - Commands::Apply { host, record } => { - let hostname = host.as_deref().unwrap_or("(Agent Mode)"); - skillet_cli_common::handle_apply(hostname, record, |system, files| { + Commands::Apply { record } => { + skillet_cli_common::handle_apply("(Agent Mode)", record, |system, files| { skillet_hardening::apply(system, files).map_err(|e| e.to_string()) }) .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; @@ -93,39 +77,23 @@ fn main() -> Result<()> { fn handle_test(cmd: TestCommands) -> Result<()> { match cmd { - TestCommands::Record { - hostname, - image, - release, - inspect, - } => { + TestCommands::Record { hostname, image } => { info!("Recording integration test for host: {}", hostname); - run_container_test(&hostname, &image, true, release, inspect)?; + run_container_test(&hostname, &image, true)?; } - TestCommands::Run { - hostname, - image, - release, - inspect, - } => { + TestCommands::Run { hostname, image } => { info!( "Running integration test verification for host: {}", hostname ); - run_container_test(&hostname, &image, false, release, inspect)?; + run_container_test(&hostname, &image, false)?; } } Ok(()) } -fn run_container_test( - hostname: &str, - image: &str, - is_record: bool, - release: bool, - inspect: bool, -) -> Result<()> { - build_workspace(release)?; +fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<()> { + build_workspace()?; let binary_path = locate_binary(hostname)?; let container_name = format!("skillet-test-{hostname}"); @@ -133,44 +101,23 @@ fn run_container_test( setup_container(&container_name, image, &binary_path)?; let result = (|| -> Result<()> { - prepare_and_run_skillet(&container_name, &binary_path)?; + prepare_and_run_skillet(&container_name)?; verify_or_record(hostname, &container_name, is_record)?; Ok(()) })(); - if inspect { - inspect_container(&container_name)?; - } - - stop_container(&container_name); - - result -} - -fn inspect_container(container_name: &str) -> Result<()> { - info!("Starting interactive inspection shell in {container_name}..."); - let _ = Command::new("podman") - .args(["exec", "-it", container_name, "/bin/bash"]) - .status() - .context("Failed to run inspection shell")?; - Ok(()) -} - -fn stop_container(container_name: &str) { - info!("Stopping container {container_name}..."); + info!("Stopping container..."); let _ = Command::new("podman") - .args(["rm", "-f", container_name]) + .args(["rm", "-f", &container_name]) .output(); + + result } -fn build_workspace(release: bool) -> Result<()> { - info!("Building skillet workspace (release={})...", release); - let mut args = vec!["build"]; - if release { - args.push("--release"); - } +fn build_workspace() -> Result<()> { + info!("Building skillet workspace..."); let build_status = Command::new("cargo") - .args(args) + .args(["build"]) .status() .context("Failed to run cargo build")?; @@ -210,12 +157,6 @@ fn locate_binary(hostname: &str) -> Result { let host_binary_name = format!("skillet-{hostname}"); let root = find_workspace_root()?; - // Ordered search: - // 1. host-specific release - // 2. host-specific debug - // 3. generic skillet release - // 4. generic skillet debug - let binary_path = [ root.join("target/release").join(&host_binary_name), root.join("target/debug").join(&host_binary_name), @@ -271,7 +212,7 @@ fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Res Ok(()) } -fn prepare_and_run_skillet(container_name: &str, binary_path: &Path) -> Result<()> { +fn prepare_and_run_skillet(container_name: &str) -> Result<()> { // Prepare entrypoint script let entrypoint_content = include_str!("test_entrypoint.sh"); let mut temp_entrypoint = tempfile::Builder::new().suffix(".sh").tempfile()?; @@ -312,44 +253,13 @@ fn prepare_and_run_skillet(container_name: &str, binary_path: &Path) -> Result<( return Err(anyhow!("Failed to chmod entrypoint in container")); } - // Copy skillet binary to container - info!("Copying skillet binary to container..."); - let cp_bin_status = Command::new("podman") - .args([ - "cp", - binary_path.to_str().unwrap(), - &format!("{container_name}:/tmp/skillet"), - ]) - .status() - .context("Failed to copy skillet binary")?; - - if !cp_bin_status.success() { - return Err(anyhow!("Failed to copy skillet binary to container")); - } - - // Make skillet binary executable - let chmod_bin_status = Command::new("podman") - .args([ - "exec", - container_name, - "chmod", - "+x", - "/tmp/skillet", - ]) - .status() - .context("Failed to chmod skillet binary")?; - - if !chmod_bin_status.success() { - return Err(anyhow!("Failed to chmod skillet binary in container")); - } - info!("Executing skillet inside container..."); let exec_status = Command::new("podman") .args([ "exec", container_name, "/tmp/test_entrypoint.sh", - "/tmp/skillet", + "skillet", "apply", "--record", "/tmp/ops.yaml", diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 63f85c9b..98985097 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -1,8 +1,8 @@ use nix::unistd::{chown, Gid, Uid}; use sha2::{Digest, Sha256}; -use std::fs::{self}; -use std::io::{self, Write}; -use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::fs::{self, File}; +use std::io::{self, BufReader, Write}; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; use std::path::Path; use tempfile::NamedTempFile; use thiserror::Error; @@ -31,6 +31,8 @@ pub enum FileError { GroupNotFound(String), #[error("Path {0} exists but is not a directory")] NotADirectory(String), + #[error("Metadata mismatch for {0}")] + Metadata(String), } pub trait FileResource { @@ -60,12 +62,13 @@ impl LocalFileResource { } fn check_metadata( - _path: &Path, - metadata: &fs::Metadata, + path: &Path, mode: Option, owner: Option<&str>, group: Option<&str>, ) -> Result { + let metadata = + fs::symlink_metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; let mut changed = false; if let Some(desired_mode) = mode { @@ -95,13 +98,14 @@ impl LocalFileResource { fn apply_metadata( path: &Path, - metadata: &fs::Metadata, mode: Option, owner: Option<&str>, group: Option<&str>, ) -> Result<(), FileError> { if let Some(desired_mode) = mode { - let mut perms = metadata.permissions(); + let mut perms = fs::symlink_metadata(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))? + .permissions(); perms.set_mode(desired_mode); fs::set_permissions(path, perms) .map_err(|e| FileError::SetPermissions(path.display().to_string(), e))?; @@ -126,6 +130,15 @@ impl LocalFileResource { Ok(()) } + + fn get_file_hash(path: &Path) -> Result, FileError> { + let file = File::open(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + io::copy(&mut reader, &mut hasher) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; + Ok(hasher.finalize().to_vec()) + } } impl Default for LocalFileResource { @@ -155,22 +168,23 @@ impl FileResource for LocalFileResource { let mut changed = false; // 2. Check content - let mut metadata = fs::symlink_metadata(path).ok(); - let content_changed = if let Some(meta) = &metadata { - // Treat non-regular files (like symlinks) as "changed" to trigger replacement - if !meta.is_file() { - true - } else if meta.len() == content.len() as u64 { - let mut file = fs::File::open(path) - .map_err(|e| FileError::Read(path.display().to_string(), e))?; - let mut hasher = Sha256::new(); - std::io::copy(&mut file, &mut hasher) - .map_err(|e| FileError::Read(path.display().to_string(), e))?; - let existing_hash = hasher.finalize(); - let new_hash = Sha256::digest(content); + let content_changed = if path.exists() { + let metadata = fs::symlink_metadata(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; - existing_hash != new_hash + // If it's a symlink, we replace it with a regular file + if metadata.is_file() { + if metadata.len() == content.len() as u64 { + let existing_hash = Self::get_file_hash(path)?; + let mut hasher = Sha256::new(); + hasher.update(content); + let new_hash = hasher.finalize(); + existing_hash != new_hash.as_slice() + } else { + true + } } else { + // Not a regular file (symlink or dir), definitely changed true } } else { @@ -186,19 +200,13 @@ impl FileResource for LocalFileResource { .map_err(|e| FileError::Persist(path.display().to_string(), e.error))?; changed = true; info!("Updated file content for {}", path.display()); - // Fetch metadata for the newly created file - metadata = Some( - fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?, - ); } // 3. Check and apply metadata - if let Some(meta) = metadata { - if Self::check_metadata(path, &meta, mode, owner, group)? { - Self::apply_metadata(path, &meta, mode, owner, group)?; - changed = true; - info!("Updated file metadata for {}", path.display()); - } + if path.exists() && Self::check_metadata(path, mode, owner, group)? { + Self::apply_metadata(path, mode, owner, group)?; + changed = true; + info!("Updated file metadata for {}", path.display()); } Ok(changed) @@ -213,14 +221,13 @@ impl FileResource for LocalFileResource { ) -> Result { let mut changed = false; - // Use metadata() instead of symlink_metadata() to follow symlinks to directories - let mut metadata = fs::metadata(path).ok(); - if let Some(meta) = &metadata { - if !meta.is_dir() { + if path.exists() { + let metadata = fs::metadata(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; + if !metadata.is_dir() { return Err(FileError::NotADirectory(path.display().to_string())); } } else { - use std::os::unix::fs::DirBuilderExt; let mut builder = fs::DirBuilder::new(); builder.recursive(true); if let Some(m) = mode { @@ -229,28 +236,27 @@ impl FileResource for LocalFileResource { builder.create(path).map_err(FileError::Io)?; changed = true; info!("Created directory {}", path.display()); - // Fetch metadata for the newly created directory - metadata = Some( - fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?, - ); } - if let Some(meta) = metadata { - if Self::check_metadata(path, &meta, mode, owner, group)? { - Self::apply_metadata(path, &meta, mode, owner, group)?; - changed = true; - info!("Updated directory metadata for {}", path.display()); - } + if path.exists() && Self::check_metadata(path, mode, owner, group)? { + Self::apply_metadata(path, mode, owner, group)?; + changed = true; + info!("Updated directory metadata for {}", path.display()); } Ok(changed) } fn delete_file(&self, path: &Path) -> Result { - // Use symlink_metadata to correctly detect and remove broken symlinks - if path.symlink_metadata().is_ok() { - fs::remove_file(path).map_err(FileError::Io)?; - info!("Deleted file {}", path.display()); + if path.exists() { + let metadata = fs::symlink_metadata(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; + if metadata.is_dir() { + fs::remove_dir_all(path).map_err(FileError::Io)?; + } else { + fs::remove_file(path).map_err(FileError::Io)?; + } + info!("Deleted {}", path.display()); Ok(true) } else { Ok(false) diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index e341c354..6cc43dfe 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -23,7 +23,10 @@ impl Recorder { } pub fn get_ops(&self) -> Vec { - self.ops.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() + self.ops + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() } pub fn shared_ops(&self) -> Arc>> { @@ -31,7 +34,10 @@ impl Recorder { } fn record(&self, op: ResourceOp) { - self.ops.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(op); + self.ops + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(op); } } @@ -92,7 +98,6 @@ impl SystemResource for Recorder { self.inner.ensure_group(name, gid) } - fn ensure_user( &self, name: &str, diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index ae77e28a..c7fee57e 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -1,9 +1,8 @@ -use std::io::Write as _; -use std::process::{Command, Stdio}; +use std::process::Command; use std::sync::LazyLock; use thiserror::Error; use tracing::{debug, info, warn}; -use users::{get_group_by_name, get_user_by_name}; +use users::get_group_by_name; use zbus::proxy; static SYSTEMD_UNIT_SUFFIXES: LazyLock> = LazyLock::new(|| { @@ -139,13 +138,11 @@ const EXIT_CODE_USER_EXISTS: i32 = 9; impl SystemResource for LinuxSystemResource { fn ensure_group(&self, name: &str, gid: Option) -> Result { - // 1. Check if group exists using `users` crate if get_group_by_name(name).is_some() { debug!("Group {name} already exists"); return Ok(false); } - // 2. Create group using `groupadd` info!("Creating group {name}"); let mut cmd = Command::new("groupadd"); if let Some(g) = gid { @@ -155,7 +152,6 @@ impl SystemResource for LinuxSystemResource { let output = cmd.output()?; if !output.status.success() { - // Check if group was created by another process in the meantime (exit code 9 for groupadd) if output.status.code() == Some(EXIT_CODE_GROUP_EXISTS) { debug!("Group {name} was created by another process"); return Ok(false); @@ -175,18 +171,15 @@ impl SystemResource for LinuxSystemResource { uid: Option, gid: Option, ) -> Result { - // 1. Check if user exists using `users` crate - if let Some(_user) = get_user_by_name(name) { + if users::get_user_by_name(name).is_some() { debug!("User {name} already exists"); return Ok(false); } - // 2. Ensure group exists if let Some(gid_val) = gid { self.ensure_group(name, Some(gid_val))?; } - // 3. Create user using `useradd` info!("Creating user {name}"); let mut cmd = Command::new("useradd"); if let Some(u) = uid { @@ -214,7 +207,6 @@ impl SystemResource for LinuxSystemResource { } fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { - // 1. Check if secret exists let inspect_output = Command::new("podman") .args(["secret", "inspect", name]) .output()?; @@ -224,14 +216,14 @@ impl SystemResource for LinuxSystemResource { return Ok(false); } - // 2. Create secret by piping payload to stdin info!("Creating podman secret {name}"); let mut child = Command::new("podman") .args(["secret", "create", name, "-"]) - .stdin(Stdio::piped()) + .stdin(std::process::Stdio::piped()) .spawn()?; if let Some(mut stdin) = child.stdin.take() { + use std::io::Write as _; stdin.write_all(payload.as_bytes())?; } @@ -261,6 +253,7 @@ impl SystemResource for LinuxSystemResource { self.run_systemctl("reload", name) } } + #[cfg(test)] #[path = "system/tests.rs"] mod tests; From 657708896255eb93ec524da73713d825396d7feb Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 11 Apr 2026 15:58:51 +0200 Subject: [PATCH 041/252] refactor(core): improve idempotency and address PR review comments --- ublue/skillet/Cargo.lock | 2 + ublue/skillet/crates/cli/Cargo.toml | 2 + ublue/skillet/crates/cli/src/main.rs | 59 ++++++++++++++++- ublue/skillet/crates/core/src/credentials.rs | 2 +- ublue/skillet/crates/core/src/files.rs | 38 +++++++---- ublue/skillet/crates/core/src/system.rs | 68 +++++++++++++++++--- 6 files changed, 145 insertions(+), 26 deletions(-) diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 8351f3c8..febb03f3 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1047,6 +1047,8 @@ dependencies = [ "skillet_cli_common", "skillet_core", "skillet_hardening", + "skillet_pihole", + "skillet_podman", "tempfile", "tracing", "tracing-subscriber", diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 5cc13aaf..7a279ace 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -9,6 +9,8 @@ workspace = true [dependencies] skillet_core.workspace = true skillet_hardening.workspace = true +skillet_pihole.workspace = true +skillet_podman.workspace = true skillet_cli_common.workspace = true clap.workspace = true tracing.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index d4272809..0547abbd 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,6 +1,8 @@ use anyhow::{anyhow, Context, Result}; use clap::{Parser, Subcommand}; +use skillet_core::credentials::CredentialManager; use skillet_core::resource_op::ResourceOp; +use skillet_podman::{QuadletSecret, SecretTarget}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -23,6 +25,9 @@ struct Args { enum Commands { /// Apply configuration (Agent Mode) Apply { + /// Optional: Hostname to apply configuration for + #[arg(long)] + host: Option, /// Optional: Output recorded actions to this file path #[arg(long)] record: Option, @@ -64,9 +69,57 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { - Commands::Apply { record } => { - skillet_cli_common::handle_apply("(Agent Mode)", record, |system, files| { - skillet_hardening::apply(system, files).map_err(|e| e.to_string()) + Commands::Apply { host, record } => { + let hostname = host.as_deref().unwrap_or("(Agent Mode)"); + skillet_cli_common::handle_apply(hostname, record, |system, files| { + match hostname { + "beezelbot" => { + skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + } + "clamps" => { + skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + + // 1. Ingest secret from systemd + let cred_manager = CredentialManager::new().map_err(|e| e.to_string())?; + let secret_payload = cred_manager + .read_secret("test_secret") + .map_err(|e| e.to_string())?; + + // 2. Provision to Podman + system + .ensure_podman_secret("pihole_web_password", &secret_payload) + .map_err(|e| e.to_string())?; + + // 3. Apply pihole with the secret + let secrets = vec![QuadletSecret { + secret_name: "pihole_web_password".to_string(), + target: SecretTarget::File { + target_path: "/etc/pihole/webpassword".to_string(), + mode: Some("0400".to_string()), + uid: Some(40000), + gid: Some(40000), + }, + }]; + + skillet_pihole::apply( + system, + files, + skillet_pihole::PiholeUser { + uid: 40000, + gid: 40000, + name: "pihole".to_string(), + group_name: "pihole".to_string(), + }, + secrets, + ) + .map_err(|e: skillet_pihole::PiholeError| e.to_string())?; + } + _ => { + // Default fallback: just hardening + skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + } + } + Ok(()) }) .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; } diff --git a/ublue/skillet/crates/core/src/credentials.rs b/ublue/skillet/crates/core/src/credentials.rs index 4dfb6e01..590980ab 100644 --- a/ublue/skillet/crates/core/src/credentials.rs +++ b/ublue/skillet/crates/core/src/credentials.rs @@ -30,6 +30,6 @@ impl CredentialManager { let mut content = String::new(); file.read_to_string(&mut content) .map_err(|e| CredentialError::ReadError(name.to_string(), e))?; - Ok(content) + Ok(content.trim().to_string()) } } diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 98985097..c3948948 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -221,21 +221,33 @@ impl FileResource for LocalFileResource { ) -> Result { let mut changed = false; - if path.exists() { - let metadata = fs::metadata(path) - .map_err(|e| FileError::Read(path.display().to_string(), e))?; - if !metadata.is_dir() { - return Err(FileError::NotADirectory(path.display().to_string())); + let metadata_res = fs::symlink_metadata(path); + match metadata_res { + Ok(metadata) => { + // Path exists, check if it's a directory + if !metadata.is_dir() { + // If it's a symlink, follow it to see if it points to a directory + let followed_metadata = fs::metadata(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; + if !followed_metadata.is_dir() { + return Err(FileError::NotADirectory(path.display().to_string())); + } + } } - } else { - let mut builder = fs::DirBuilder::new(); - builder.recursive(true); - if let Some(m) = mode { - builder.mode(m); + Err(e) if e.kind() == io::ErrorKind::NotFound => { + // Path does not exist, create it + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + if let Some(m) = mode { + builder.mode(m); + } + builder.create(path).map_err(FileError::Io)?; + changed = true; + info!("Created directory {}", path.display()); + } + Err(e) => { + return Err(FileError::Read(path.display().to_string(), e)); } - builder.create(path).map_err(FileError::Io)?; - changed = true; - info!("Created directory {}", path.display()); } if path.exists() && Self::check_metadata(path, mode, owner, group)? { diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index c7fee57e..829e9947 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -1,8 +1,9 @@ -use std::process::Command; +use sha2::{Digest, Sha256}; +use std::process::{Command, Stdio}; use std::sync::LazyLock; use thiserror::Error; use tracing::{debug, info, warn}; -use users::get_group_by_name; +use users::{get_group_by_name, get_user_by_name}; use zbus::proxy; static SYSTEMD_UNIT_SUFFIXES: LazyLock> = LazyLock::new(|| { @@ -138,8 +139,16 @@ const EXIT_CODE_USER_EXISTS: i32 = 9; impl SystemResource for LinuxSystemResource { fn ensure_group(&self, name: &str, gid: Option) -> Result { - if get_group_by_name(name).is_some() { + if let Some(grp) = get_group_by_name(name) { debug!("Group {name} already exists"); + if let Some(desired_gid) = gid { + if grp.gid() != desired_gid { + warn!( + "Group {name} exists but GID {} does not match desired {desired_gid}", + grp.gid() + ); + } + } return Ok(false); } @@ -171,8 +180,24 @@ impl SystemResource for LinuxSystemResource { uid: Option, gid: Option, ) -> Result { - if users::get_user_by_name(name).is_some() { + if let Some(user) = get_user_by_name(name) { debug!("User {name} already exists"); + if let Some(desired_uid) = uid { + if user.uid() != desired_uid { + warn!( + "User {name} exists but UID {} does not match desired {desired_uid}", + user.uid() + ); + } + } + if let Some(desired_gid) = gid { + if user.primary_group_id() != desired_gid { + warn!( + "User {name} exists but GID {} does not match desired {desired_gid}", + user.primary_group_id() + ); + } + } return Ok(false); } @@ -207,19 +232,44 @@ impl SystemResource for LinuxSystemResource { } fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { + let mut hasher = Sha256::new(); + hasher.update(payload.as_bytes()); + let hash = hex::encode(hasher.finalize()); + let inspect_output = Command::new("podman") - .args(["secret", "inspect", name]) + .args([ + "secret", + "inspect", + "--format", + "{{ index .Spec.Labels \"skillet.payload_hash\" }}", + name, + ]) .output()?; if inspect_output.status.success() { - debug!("Podman secret {name} already exists"); - return Ok(false); + let existing_hash = String::from_utf8_lossy(&inspect_output.stdout).trim().to_string(); + if existing_hash == hash { + debug!("Podman secret {name} already exists with correct hash"); + return Ok(false); + } + warn!("Podman secret {name} exists but hash mismatch. Deleting and recreating."); + let rm_status = Command::new("podman").args(["secret", "rm", name]).status()?; + if !rm_status.success() { + return Err(SystemError::Command(format!("Failed to remove old secret {name}"))); + } } info!("Creating podman secret {name}"); let mut child = Command::new("podman") - .args(["secret", "create", name, "-"]) - .stdin(std::process::Stdio::piped()) + .args([ + "secret", + "create", + "--label", + &format!("skillet.payload_hash={hash}"), + name, + "-", + ]) + .stdin(Stdio::piped()) .spawn()?; if let Some(mut stdin) = child.stdin.take() { From 18c826f3ab6e3e4984ef64f059eb4eaffcd3e76b Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 11 Apr 2026 16:31:25 +0200 Subject: [PATCH 042/252] refactor(core): refine symlink and metadata handling in files module --- ublue/skillet/crates/core/src/files.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index c3948948..2920c834 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -31,6 +31,8 @@ pub enum FileError { GroupNotFound(String), #[error("Path {0} exists but is not a directory")] NotADirectory(String), + #[error("Path {0} exists but is not a regular file")] + NotAFile(String), #[error("Metadata mismatch for {0}")] Metadata(String), } @@ -68,7 +70,7 @@ impl LocalFileResource { group: Option<&str>, ) -> Result { let metadata = - fs::symlink_metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; let mut changed = false; if let Some(desired_mode) = mode { @@ -103,7 +105,7 @@ impl LocalFileResource { group: Option<&str>, ) -> Result<(), FileError> { if let Some(desired_mode) = mode { - let mut perms = fs::symlink_metadata(path) + let mut perms = fs::metadata(path) .map_err(|e| FileError::Read(path.display().to_string(), e))? .permissions(); perms.set_mode(desired_mode); @@ -184,7 +186,8 @@ impl FileResource for LocalFileResource { true } } else { - // Not a regular file (symlink or dir), definitely changed + // Not a regular file (symlink or dir), we will delete and replace + self.delete_file(path)?; true } } else { @@ -227,10 +230,15 @@ impl FileResource for LocalFileResource { // Path exists, check if it's a directory if !metadata.is_dir() { // If it's a symlink, follow it to see if it points to a directory - let followed_metadata = fs::metadata(path) - .map_err(|e| FileError::Read(path.display().to_string(), e))?; - if !followed_metadata.is_dir() { - return Err(FileError::NotADirectory(path.display().to_string())); + let followed_metadata_res = fs::metadata(path); + match followed_metadata_res { + Ok(fm) if fm.is_dir() => { + // Points to a directory, fine + } + _ => { + // Doesn't exist or not a directory + return Err(FileError::NotADirectory(path.display().to_string())); + } } } } From 408fecaa957e1a6f228284941db9b6415f08b860 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 11 Apr 2026 21:43:28 +0200 Subject: [PATCH 043/252] feat(cli): restore --inspect flag to test subcommands --- ublue/skillet/crates/cli/src/main.rs | 42 ++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 0547abbd..05a30380 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -46,11 +46,17 @@ enum TestCommands { /// Container image to use #[arg(long, default_value = "fedora:latest")] image: String, + /// Inspect the container after application (interactive shell) + #[arg(long)] + inspect: bool, }, Run { hostname: String, #[arg(long, default_value = "fedora:latest")] image: String, + /// Inspect the container after application (interactive shell) + #[arg(long)] + inspect: bool, }, } @@ -130,22 +136,35 @@ fn main() -> Result<()> { fn handle_test(cmd: TestCommands) -> Result<()> { match cmd { - TestCommands::Record { hostname, image } => { + TestCommands::Record { + hostname, + image, + inspect, + } => { info!("Recording integration test for host: {}", hostname); - run_container_test(&hostname, &image, true)?; + run_container_test(&hostname, &image, true, inspect)?; } - TestCommands::Run { hostname, image } => { + TestCommands::Run { + hostname, + image, + inspect, + } => { info!( "Running integration test verification for host: {}", hostname ); - run_container_test(&hostname, &image, false)?; + run_container_test(&hostname, &image, false, inspect)?; } } Ok(()) } -fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<()> { +fn run_container_test( + hostname: &str, + image: &str, + is_record: bool, + inspect: bool, +) -> Result<()> { build_workspace()?; let binary_path = locate_binary(hostname)?; @@ -159,6 +178,10 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool) -> Result<() Ok(()) })(); + if inspect { + inspect_container(&container_name)?; + } + info!("Stopping container..."); let _ = Command::new("podman") .args(["rm", "-f", &container_name]) @@ -180,6 +203,15 @@ fn build_workspace() -> Result<()> { Ok(()) } +fn inspect_container(container_name: &str) -> Result<()> { + info!("Starting interactive inspection shell in {container_name}..."); + let _ = Command::new("podman") + .args(["exec", "-it", container_name, "/bin/bash"]) + .status() + .context("Failed to run inspection shell")?; + Ok(()) +} + fn find_workspace_root() -> Result { let mut current = std::env::current_exe()? .parent() From fd6c829db2e07fe5ed543c7ca40574cb713c36e7 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 14 Apr 2026 10:52:39 +0200 Subject: [PATCH 044/252] refactor: address PR review comments for skillet configuration tool - Refactor CLI entry point to move host-specific logic to 'host_applies' module - Implement atomic recording file writes using temporary files and rename - Improve secret reading logic to preserve whitespace except for trailing newline - Fix 'daemon-reload' handling and ensure systemctl commands use non-blocking flags - Implement dynamic discovery of subuid/subgid ranges for Podman user mapping - Enable dynamic UID/GID assignment for Pi-hole user and group management --- ublue/skillet/Cargo.lock | 1 + ublue/skillet/crates/cli/Cargo.toml | 1 + ublue/skillet/crates/cli/src/host_applies.rs | 93 +++++++++++++++++++ ublue/skillet/crates/cli/src/main.rs | 89 +++++------------- ublue/skillet/crates/core/src/credentials.rs | 9 +- ublue/skillet/crates/core/src/system.rs | 72 +++++++++++--- ublue/skillet/crates/hosts/clamps/src/main.rs | 11 ++- ublue/skillet/crates/pihole/src/lib.rs | 8 +- ublue/skillet/crates/podman/src/lib.rs | 36 +++++-- 9 files changed, 226 insertions(+), 94 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/host_applies.rs diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index febb03f3..1c10e39d 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1052,6 +1052,7 @@ dependencies = [ "tempfile", "tracing", "tracing-subscriber", + "users", ] [[package]] diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 7a279ace..3497afd0 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -20,3 +20,4 @@ serde.workspace = true serde_yml.workspace = true hex.workspace = true tempfile.workspace = true +users.workspace = true diff --git a/ublue/skillet/crates/cli/src/host_applies.rs b/ublue/skillet/crates/cli/src/host_applies.rs new file mode 100644 index 00000000..5417e39a --- /dev/null +++ b/ublue/skillet/crates/cli/src/host_applies.rs @@ -0,0 +1,93 @@ +use skillet_core::{credentials::CredentialManager, files::FileResource, system::SystemResource}; +use skillet_pihole; +use skillet_podman::{QuadletSecret, SecretTarget}; + +mod user_lookup { + use skillet_core::system::SystemError; + use users::{get_group_by_name, get_user_by_name}; + + /// Look up UID for a username, returns None if user doesn't exist + pub fn lookup_uid(username: &str) -> Result, SystemError> { + match get_user_by_name(username) { + Some(user) => Ok(Some(user.uid())), + None => Ok(None), + } + } + + /// Look up GID for a group name, returns None if group doesn't exist + pub fn lookup_gid(groupname: &str) -> Result, SystemError> { + match get_group_by_name(groupname) { + Some(group) => Ok(Some(group.gid())), + None => Ok(None), + } + } +} + +/// Apply configuration for a specific host +pub fn apply_host( + hostname: &str, + system: &(impl SystemResource + ?Sized), + files: &(impl FileResource + ?Sized), + credentials: &CredentialManager, +) -> Result<(), String> { + match hostname { + "beezelbot" => { + skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + } + "clamps" => { + skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + + // 1. Ingest secret from systemd + let secret_payload = credentials + .read_secret("test_secret") + .map_err(|e| e.to_string())?; + + // 2. Provision to Podman + system + .ensure_podman_secret("pihole_web_password", &secret_payload) + .map_err(|e| e.to_string())?; + + // Look up pihole user and group IDs + let (pihole_uid_opt, pihole_gid_opt) = match ( + user_lookup::lookup_uid("pihole"), + user_lookup::lookup_gid("pihole"), + ) { + (Ok(uid), Ok(gid)) => (uid, gid), + _ => { + // If user/group doesn't exist yet, we'll create them with dynamic IDs + // For now, use placeholders that will be replaced during ensure_user/group + (None, None) + } + }; + + // 3. Apply pihole with the secret + let secrets = vec![QuadletSecret { + secret_name: "pihole_web_password".to_string(), + target: SecretTarget::File { + target_path: "/etc/pihole/webpassword".to_string(), + mode: Some("0400".to_string()), + uid: pihole_uid_opt, + gid: pihole_gid_opt, + }, + }]; + + skillet_pihole::apply( + system, + files, + skillet_pihole::PiholeUser { + uid: pihole_uid_opt, + gid: pihole_gid_opt, + name: "pihole".to_string(), + group_name: "pihole".to_string(), + }, + secrets, + ) + .map_err(|e: skillet_pihole::PiholeError| e.to_string())?; + } + _ => { + // Default fallback: just hardening + skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + } + } + Ok(()) +} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 05a30380..f377556b 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,8 +1,8 @@ use anyhow::{anyhow, Context, Result}; use clap::{Parser, Subcommand}; +use skillet_cli_common; use skillet_core::credentials::CredentialManager; use skillet_core::resource_op::ResourceOp; -use skillet_podman::{QuadletSecret, SecretTarget}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -10,6 +10,8 @@ use std::process::Command; use tracing::{error, info, Level}; use tracing_subscriber::FmtSubscriber; +mod host_applies; + #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { @@ -78,54 +80,9 @@ fn main() -> Result<()> { Commands::Apply { host, record } => { let hostname = host.as_deref().unwrap_or("(Agent Mode)"); skillet_cli_common::handle_apply(hostname, record, |system, files| { - match hostname { - "beezelbot" => { - skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; - } - "clamps" => { - skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; - - // 1. Ingest secret from systemd - let cred_manager = CredentialManager::new().map_err(|e| e.to_string())?; - let secret_payload = cred_manager - .read_secret("test_secret") - .map_err(|e| e.to_string())?; - - // 2. Provision to Podman - system - .ensure_podman_secret("pihole_web_password", &secret_payload) - .map_err(|e| e.to_string())?; - - // 3. Apply pihole with the secret - let secrets = vec![QuadletSecret { - secret_name: "pihole_web_password".to_string(), - target: SecretTarget::File { - target_path: "/etc/pihole/webpassword".to_string(), - mode: Some("0400".to_string()), - uid: Some(40000), - gid: Some(40000), - }, - }]; - - skillet_pihole::apply( - system, - files, - skillet_pihole::PiholeUser { - uid: 40000, - gid: 40000, - name: "pihole".to_string(), - group_name: "pihole".to_string(), - }, - secrets, - ) - .map_err(|e: skillet_pihole::PiholeError| e.to_string())?; - } - _ => { - // Default fallback: just hardening - skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; - } - } - Ok(()) + // Initialize credential manager once + let cred_manager = CredentialManager::new().map_err(|e| e.to_string())?; + host_applies::apply_host(hostname, system, files, &cred_manager) }) .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; } @@ -159,12 +116,7 @@ fn handle_test(cmd: TestCommands) -> Result<()> { Ok(()) } -fn run_container_test( - hostname: &str, - image: &str, - is_record: bool, - inspect: bool, -) -> Result<()> { +fn run_container_test(hostname: &str, image: &str, is_record: bool, inspect: bool) -> Result<()> { build_workspace()?; let binary_path = locate_binary(hostname)?; @@ -250,9 +202,7 @@ fn locate_binary(hostname: &str) -> Result { ] .into_iter() .find(|p| p.exists()) - .ok_or_else(|| { - anyhow!("No suitable skillet binary found in target/release or target/debug") - })?; + .ok_or_else(|| anyhow!("No suitable skillet binary found in target/release or target/debug"))?; info!("Using binary: {}", binary_path.display()); fs::canonicalize(&binary_path).context("Failed to canonicalize binary path") @@ -366,19 +316,28 @@ fn verify_or_record(hostname: &str, container_name: &str, is_record: bool) -> Re if is_record { info!("Copying recording to {}", dest_file.display()); + // Use atomic write via temporary file + let temp_file = tempfile::Builder::new() + .suffix(".yaml") + .tempfile_in(&dest_dir)?; + let temp_path = temp_file + .path() + .to_str() + .ok_or_else(|| anyhow!("Temporary path is not valid UTF-8"))?; + let cp_status = Command::new("podman") - .args([ - "cp", - &format!("{container_name}:/tmp/ops.yaml"), - dest_file - .to_str() - .ok_or_else(|| anyhow!("Destination path is not valid UTF-8"))?, - ]) + .args(["cp", &format!("{container_name}:/tmp/ops.yaml"), temp_path]) .status()?; if !cp_status.success() { return Err(anyhow!("Failed to copy recording from container")); } + + // Atomic rename + std::fs::rename(temp_path, &dest_file).context(format!( + "Failed to rename temporary recording file to {}", + dest_file.display() + ))?; } else { info!("Verifying recording..."); let temp_dest = tempfile::Builder::new().suffix(".yaml").tempfile()?; diff --git a/ublue/skillet/crates/core/src/credentials.rs b/ublue/skillet/crates/core/src/credentials.rs index 590980ab..0339a079 100644 --- a/ublue/skillet/crates/core/src/credentials.rs +++ b/ublue/skillet/crates/core/src/credentials.rs @@ -30,6 +30,13 @@ impl CredentialManager { let mut content = String::new(); file.read_to_string(&mut content) .map_err(|e| CredentialError::ReadError(name.to_string(), e))?; - Ok(content.trim().to_string()) + // Remove only trailing newline if present, preserving other whitespace + if content.ends_with('\n') { + content.pop(); + if content.ends_with('\r') { + content.pop(); + } + } + Ok(content) } } diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 829e9947..c13c5b87 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -41,8 +41,10 @@ fn ensure_systemd_suffix(name: &str) -> String { trait SystemdManager { fn start_unit(&self, name: &str, mode: &str) -> zbus::Result; fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result; - fn restart_unit(&self, name: &str, mode: &str) -> zbus::Result; + fn restart_unit(&self, name: &str, mode: &str) + -> zbus::Result; fn reload_unit(&self, name: &str, mode: &str) -> zbus::Result; + fn reload(&self) -> zbus::Result<()>; } #[derive(Error, Debug)] @@ -89,6 +91,10 @@ impl LinuxSystemResource { } fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { + if name == "daemon-reload" && action == "reload" { + return self.daemon_reload(); + } + let name_with_suffix = ensure_systemd_suffix(name); if let Some(conn) = &self.conn { @@ -100,7 +106,9 @@ impl LinuxSystemResource { "restart" => proxy.restart_unit(&name_with_suffix, "replace"), "reload" => proxy.reload_unit(&name_with_suffix, "replace"), _ => { - return Err(SystemError::Command(format!("Unsupported action: {action}"))); + return Err(SystemError::Command(format!( + "Unsupported action: {action}" + ))); } }; @@ -115,6 +123,7 @@ impl LinuxSystemResource { info!("Running systemctl {action} {name_with_suffix} via CLI"); let output = Command::new("systemctl") .arg(action) + .arg("--no-block") .arg(&name_with_suffix) .output()?; @@ -126,6 +135,28 @@ impl LinuxSystemResource { } Ok(()) } + + fn daemon_reload(&self) -> Result<(), SystemError> { + if let Some(conn) = &self.conn { + info!("Running systemctl daemon-reload via DBus"); + let proxy = SystemdManagerProxyBlocking::new(conn)?; + match proxy.reload() { + Ok(_) => return Ok(()), + Err(e) => { + warn!("DBus daemon-reload failed, falling back to CLI: {e}"); + } + } + } + + info!("Running systemctl daemon-reload via CLI"); + let status = Command::new("systemctl").arg("daemon-reload").status()?; + if !status.success() { + return Err(SystemError::Command( + "systemctl daemon-reload failed".to_string(), + )); + } + Ok(()) + } } impl Default for LinuxSystemResource { @@ -247,15 +278,34 @@ impl SystemResource for LinuxSystemResource { .output()?; if inspect_output.status.success() { - let existing_hash = String::from_utf8_lossy(&inspect_output.stdout).trim().to_string(); - if existing_hash == hash { - debug!("Podman secret {name} already exists with correct hash"); - return Ok(false); - } - warn!("Podman secret {name} exists but hash mismatch. Deleting and recreating."); - let rm_status = Command::new("podman").args(["secret", "rm", name]).status()?; - if !rm_status.success() { - return Err(SystemError::Command(format!("Failed to remove old secret {name}"))); + let existing_hash = String::from_utf8_lossy(&inspect_output.stdout) + .trim() + .to_string(); + // If the label is missing, the output will be empty + if !existing_hash.is_empty() { + if existing_hash == hash { + debug!("Podman secret {name} already exists with correct hash"); + return Ok(false); + } + warn!("Podman secret {name} exists but hash mismatch. Deleting and recreating."); + let rm_status = Command::new("podman") + .args(["secret", "rm", name]) + .status()?; + if !rm_status.success() { + return Err(SystemError::Command(format!( + "Failed to remove old secret {name}" + ))); + } + } else { + warn!("Podman secret {name} exists but lacks required label. Deleting and recreating."); + let rm_status = Command::new("podman") + .args(["secret", "rm", name]) + .status()?; + if !rm_status.success() { + return Err(SystemError::Command(format!( + "Failed to remove old secret {name}" + ))); + } } } diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index c829449f..216cb726 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -8,7 +8,8 @@ fn main() -> Result<()> { skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; // 1. Ingest secret from systemd - let cred_manager = CredentialManager::new().map_err(|e: skillet_core::credentials::CredentialError| e.to_string())?; + let cred_manager = CredentialManager::new() + .map_err(|e: skillet_core::credentials::CredentialError| e.to_string())?; let secret_payload = cred_manager .read_secret("test_secret") .map_err(|e: skillet_core::credentials::CredentialError| e.to_string())?; @@ -24,8 +25,8 @@ fn main() -> Result<()> { target: SecretTarget::File { target_path: "/etc/pihole/webpassword".to_string(), mode: Some("0400".to_string()), - uid: Some(40000), - gid: Some(40000), + uid: None, + gid: None, }, }]; @@ -33,8 +34,8 @@ fn main() -> Result<()> { system, files, skillet_pihole::PiholeUser { - uid: 40000, - gid: 40000, + uid: Some(0), + gid: Some(0), name: "pihole".to_string(), group_name: "pihole".to_string(), }, diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 63972953..56482174 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -25,8 +25,8 @@ struct CustomListTemplate { } pub struct PiholeUser { - pub uid: u32, - pub gid: u32, + pub uid: Option, + pub gid: Option, pub name: String, pub group_name: String, } @@ -46,8 +46,8 @@ where let logs = "/var/log/pihole"; // 1. Ensure user and group - system.ensure_group(&user_config.group_name, Some(user_config.gid))?; - system.ensure_user(&user_config.name, Some(user_config.uid), Some(user_config.gid))?; + system.ensure_group(&user_config.group_name, user_config.gid)?; + system.ensure_user(&user_config.name, user_config.uid, user_config.gid)?; // 2. Ensure directories files.ensure_directory(Path::new(root), Some(0o755), Some("root"), Some("root"))?; diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index cf146976..b7de5d6b 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -185,41 +185,61 @@ fn calculate_user_mappings( ) { let uid_container = user.container_uid; let gid_container = user.container_gid; - let sub_base = 100_000; - let sub_size = 65_536; + + let (sub_uid_base, sub_uid_size) = discover_subid_range("/etc/subuid").unwrap_or((100_000, 65_536)); + let (sub_gid_base, sub_gid_size) = discover_subid_range("/etc/subgid").unwrap_or((100_000, 65_536)); let container_section = extra_config.entry("Container".to_string()).or_default(); container_section.push(format!("User={uid_container}:{gid_container}")); // UIDMap if uid_container > 0 { - container_section.push(format!("UIDMap=0:{sub_base}:{uid_container}")); + container_section.push(format!("UIDMap=0:{sub_uid_base}:{uid_container}")); } container_section.push(format!("UIDMap={uid_container}:{uid_host}:1")); - let rem_u = sub_size - uid_container - 1; + let rem_u = sub_uid_size - uid_container - 1; if rem_u > 0 { container_section.push(format!( "UIDMap={}:{}:{rem_u}", uid_container + 1, - sub_base + uid_container + 1 + sub_uid_base + uid_container + 1 )); } // GIDMap if gid_container > 0 { - container_section.push(format!("GIDMap=0:{sub_base}:{gid_container}")); + container_section.push(format!("GIDMap=0:{sub_gid_base}:{gid_container}")); } container_section.push(format!("GIDMap={gid_container}:{gid_host}:1")); - let rem_g = sub_size - gid_container - 1; + let rem_g = sub_gid_size - gid_container - 1; if rem_g > 0 { container_section.push(format!( "GIDMap={}:{}:{rem_g}", gid_container + 1, - sub_base + gid_container + 1 + sub_gid_base + gid_container + 1 )); } } +fn discover_subid_range(path: &str) -> Option<(u32, u32)> { + use std::fs::File; + use std::io::{BufRead, BufReader}; + + let user = users::get_current_username()?.to_string_lossy().to_string(); + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + + for line in reader.lines().map_while(Result::ok) { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() == 3 && parts[0] == user { + let start = parts[1].parse().ok()?; + let size = parts[2].parse().ok()?; + return Some((start, size)); + } + } + None +} + fn render_and_ensure_quadlet( system: &S, files: &F, From 8dbf31004fff441a8fbdbdb5d37d68f2c05b8468 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 14 Apr 2026 19:49:52 +0200 Subject: [PATCH 045/252] refactor: implement architectural and security improvements from PR review - Refactor SystemResource to include a dedicated 'daemon_reload' method - Implement atomic metadata application during file creation to eliminate security race conditions - Enforce strict UID/GID matching in user/group management (error on mismatch) - Improve recording file atomicity in cli-common - Secure systemd unit command execution using --host-file and --no-block - Dynamic discovery of subuid/subgid ranges for specific host users in Podman - Use cargo_metadata for robust workspace root discovery - Refine secret trimming and error handling in host application logic --- ublue/butane/includes/skillet.bu | 2 +- ublue/skillet/Cargo.lock | 72 +++++++++++++++++-- ublue/skillet/Cargo.toml | 1 + ublue/skillet/crates/cli-common/Cargo.toml | 1 + ublue/skillet/crates/cli-common/src/lib.rs | 12 +++- ublue/skillet/crates/cli/Cargo.toml | 2 + ublue/skillet/crates/cli/src/host_applies.rs | 39 +++++++--- ublue/skillet/crates/cli/src/main.rs | 48 ++++++------- ublue/skillet/crates/core/src/credentials.rs | 10 +-- ublue/skillet/crates/core/src/files.rs | 56 ++++++++++++--- ublue/skillet/crates/core/src/recorder.rs | 5 ++ ublue/skillet/crates/core/src/resource_op.rs | 1 + ublue/skillet/crates/core/src/system.rs | 21 +++--- ublue/skillet/crates/core/src/test_utils.rs | 8 +++ ublue/skillet/crates/hosts/clamps/src/main.rs | 4 +- ublue/skillet/crates/podman/src/lib.rs | 24 ++++--- 16 files changed, 225 insertions(+), 81 deletions(-) diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index 2c3e957a..70f40e8e 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -13,7 +13,7 @@ systemd: [Service] Type=oneshot # Use the generic skillet binary and pass the hostname from /etc/hostname - ExecStart=/usr/bin/sh -c "/usr/bin/skillet apply --host $(cat /etc/hostname)" + ExecStart=/usr/bin/skillet apply --host-file /etc/hostname RemainAfterExit=yes [Install] diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 1c10e39d..322ec76a 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -293,6 +293,39 @@ dependencies = [ "piper", ] +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -924,6 +957,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -1040,6 +1077,7 @@ name = "skillet" version = "0.1.0" dependencies = [ "anyhow", + "cargo_metadata", "clap", "hex", "serde", @@ -1050,6 +1088,7 @@ dependencies = [ "skillet_pihole", "skillet_podman", "tempfile", + "thiserror 1.0.69", "tracing", "tracing-subscriber", "users", @@ -1085,7 +1124,8 @@ dependencies = [ "serde_yml", "skillet_core", "skillet_hardening", - "thiserror", + "tempfile", + "thiserror 1.0.69", "tracing", "tracing-subscriber", ] @@ -1100,7 +1140,7 @@ dependencies = [ "serde", "sha2", "tempfile", - "thiserror", + "thiserror 1.0.69", "tracing", "users", "zbus", @@ -1112,7 +1152,7 @@ version = "0.1.0" dependencies = [ "skillet_core", "tempfile", - "thiserror", + "thiserror 1.0.69", "tracing", ] @@ -1123,7 +1163,7 @@ dependencies = [ "askama", "skillet_core", "skillet_podman", - "thiserror", + "thiserror 1.0.69", "tracing", "users", ] @@ -1135,7 +1175,7 @@ dependencies = [ "askama", "serde", "skillet_core", - "thiserror", + "thiserror 1.0.69", "tracing", "users", ] @@ -1194,7 +1234,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1208,6 +1257,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 9aae1024..125d09e5 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/core", "crates/hardening", + "crates/pihole", "crates/podman", "crates/cli", "crates/hosts/beezelbot", diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index e91d6281..3b76ec3d 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -15,3 +15,4 @@ tracing-subscriber.workspace = true thiserror.workspace = true serde.workspace = true serde_yml.workspace = true +tempfile.workspace = true diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index c72fd768..81f9dc35 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -88,7 +88,17 @@ where if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - fs::write(&path, yaml)?; + let mut temp = tempfile::NamedTempFile::new_in( + path.parent().unwrap_or_else(|| std::path::Path::new(".")), + )?; + use std::io::Write as _; + temp.write_all(yaml.as_bytes())?; + temp.persist(&path).map_err(|e| { + CliCommonError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to persist recording to {}: {}", path.display(), e), + )) + })?; info!("Recording saved to {}", path.display()); } else { apply_fn(&system, &files).map_err(CliCommonError::Config)?; diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 3497afd0..ba9a5b10 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -21,3 +21,5 @@ serde_yml.workspace = true hex.workspace = true tempfile.workspace = true users.workspace = true +cargo_metadata = "0.23.1" +thiserror.workspace = true diff --git a/ublue/skillet/crates/cli/src/host_applies.rs b/ublue/skillet/crates/cli/src/host_applies.rs index 5417e39a..ad2b3c20 100644 --- a/ublue/skillet/crates/cli/src/host_applies.rs +++ b/ublue/skillet/crates/cli/src/host_applies.rs @@ -1,6 +1,26 @@ -use skillet_core::{credentials::CredentialManager, files::FileResource, system::SystemResource}; +use skillet_core::{ + credentials::{CredentialError, CredentialManager}, + files::{FileError, FileResource}, + system::{SystemError, SystemResource}, +}; +use skillet_hardening; use skillet_pihole; use skillet_podman::{QuadletSecret, SecretTarget}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ApplyError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Credential error: {0}")] + Credential(#[from] CredentialError), + #[error("Hardening apply error: {0}")] + Hardening(String), + #[error("Pihole apply error: {0}")] + Pihole(#[from] skillet_pihole::PiholeError), +} mod user_lookup { use skillet_core::system::SystemError; @@ -29,23 +49,21 @@ pub fn apply_host( system: &(impl SystemResource + ?Sized), files: &(impl FileResource + ?Sized), credentials: &CredentialManager, -) -> Result<(), String> { +) -> Result<(), ApplyError> { match hostname { "beezelbot" => { - skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string()))?; } "clamps" => { - skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string()))?; // 1. Ingest secret from systemd let secret_payload = credentials - .read_secret("test_secret") - .map_err(|e| e.to_string())?; + .read_secret("test_secret")?; // 2. Provision to Podman system - .ensure_podman_secret("pihole_web_password", &secret_payload) - .map_err(|e| e.to_string())?; + .ensure_podman_secret("pihole_web_password", &secret_payload)?; // Look up pihole user and group IDs let (pihole_uid_opt, pihole_gid_opt) = match ( @@ -81,12 +99,11 @@ pub fn apply_host( group_name: "pihole".to_string(), }, secrets, - ) - .map_err(|e: skillet_pihole::PiholeError| e.to_string())?; + )?; } _ => { // Default fallback: just hardening - skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; + skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string()))?; } } Ok(()) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index f377556b..8226c18f 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -30,6 +30,9 @@ enum Commands { /// Optional: Hostname to apply configuration for #[arg(long)] host: Option, + /// Optional: File to read hostname from + #[arg(long)] + host_file: Option, /// Optional: Output recorded actions to this file path #[arg(long)] record: Option, @@ -77,12 +80,24 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { - Commands::Apply { host, record } => { - let hostname = host.as_deref().unwrap_or("(Agent Mode)"); - skillet_cli_common::handle_apply(hostname, record, |system, files| { + Commands::Apply { + host, + host_file, + record, + } => { + let mut hostname = host.unwrap_or_else(|| "(Agent Mode)".to_string()); + if let Some(p) = host_file { + hostname = std::fs::read_to_string(p) + .context("Failed to read host file")? + .trim() + .to_string(); + } + + skillet_cli_common::handle_apply(&hostname, record, |system, files| { // Initialize credential manager once let cred_manager = CredentialManager::new().map_err(|e| e.to_string())?; - host_applies::apply_host(hostname, system, files, &cred_manager) + host_applies::apply_host(&hostname, system, files, &cred_manager) + .map_err(|e| e.to_string()) }) .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; } @@ -165,29 +180,8 @@ fn inspect_container(container_name: &str) -> Result<()> { } fn find_workspace_root() -> Result { - let mut current = std::env::current_exe()? - .parent() - .ok_or_else(|| anyhow!("Failed to get executable directory"))? - .to_path_buf(); - - loop { - if current.join("Cargo.toml").exists() { - return Ok(current); - } - if !current.pop() { - break; - } - } - - // Fallback to CWD if not found relative to exe - let cwd = std::env::current_dir()?; - if cwd.join("Cargo.toml").exists() { - return Ok(cwd); - } - - Err(anyhow!( - "Failed to locate workspace root (looking for Cargo.toml)" - )) + let metadata = cargo_metadata::MetadataCommand::new().exec()?; + Ok(metadata.workspace_root.into_std_path_buf()) } fn locate_binary(hostname: &str) -> Result { diff --git a/ublue/skillet/crates/core/src/credentials.rs b/ublue/skillet/crates/core/src/credentials.rs index 0339a079..5002bb4b 100644 --- a/ublue/skillet/crates/core/src/credentials.rs +++ b/ublue/skillet/crates/core/src/credentials.rs @@ -30,13 +30,9 @@ impl CredentialManager { let mut content = String::new(); file.read_to_string(&mut content) .map_err(|e| CredentialError::ReadError(name.to_string(), e))?; - // Remove only trailing newline if present, preserving other whitespace - if content.ends_with('\n') { - content.pop(); - if content.ends_with('\r') { - content.pop(); - } - } + // Remove only trailing whitespace, preserving other characters + let new_len = content.trim_end().len(); + content.truncate(new_len); Ok(content) } } diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 2920c834..450aa4d6 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -1,4 +1,4 @@ -use nix::unistd::{chown, Gid, Uid}; +use nix::unistd::{chown, fchown, Gid, Uid}; use sha2::{Digest, Sha256}; use std::fs::{self, File}; use std::io::{self, BufReader, Write}; @@ -98,6 +98,44 @@ impl LocalFileResource { Ok(changed) } + fn apply_metadata_to_file( + file: &File, + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result<(), FileError> { + use std::os::unix::io::AsRawFd; + + if let Some(m) = mode { + let mut perms = file + .metadata() + .map_err(|e| FileError::Io(e))? + .permissions(); + perms.set_mode(m); + file.set_permissions(perms) + .map_err(|e| FileError::Io(e))?; + } + + if owner.is_some() || group.is_some() { + let uid = owner + .map(|u| get_user_by_name(u).ok_or_else(|| FileError::UserNotFound(u.to_string()))) + .transpose()? + .map(|u| Uid::from_raw(u.uid())); + + let gid = group + .map(|g| { + get_group_by_name(g).ok_or_else(|| FileError::GroupNotFound(g.to_string())) + }) + .transpose()? + .map(|g| Gid::from_raw(g.gid())); + + fchown(file.as_raw_fd(), uid, gid) + .map_err(|e| FileError::SetOwnership("temp file".to_string(), e.to_string()))?; + } + + Ok(()) + } + fn apply_metadata( path: &Path, mode: Option, @@ -198,18 +236,20 @@ impl FileResource for LocalFileResource { // Write to temp file in same directory (for atomic rename) let mut temp_file = NamedTempFile::new_in(parent)?; temp_file.write_all(content)?; + // Apply metadata to temp file before persist + Self::apply_metadata_to_file(temp_file.as_file(), mode, owner, group)?; temp_file .persist(path) .map_err(|e| FileError::Persist(path.display().to_string(), e.error))?; changed = true; info!("Updated file content for {}", path.display()); - } - - // 3. Check and apply metadata - if path.exists() && Self::check_metadata(path, mode, owner, group)? { - Self::apply_metadata(path, mode, owner, group)?; - changed = true; - info!("Updated file metadata for {}", path.display()); + } else { + // Even if content didn't change, we might need to update metadata + if path.exists() && Self::check_metadata(path, mode, owner, group)? { + Self::apply_metadata(path, mode, owner, group)?; + changed = true; + info!("Updated file metadata for {}", path.display()); + } } Ok(changed) diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index 6cc43dfe..6c222600 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -151,4 +151,9 @@ impl SystemResource for Recorder { }); self.inner.service_reload(name) } + + fn daemon_reload(&self) -> Result<(), SystemError> { + self.record(ResourceOp::DaemonReload); + self.inner.daemon_reload() + } } diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index 630ebd42..1f40ea93 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -42,4 +42,5 @@ pub enum ResourceOp { ServiceReload { name: String, }, + DaemonReload, } diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index c13c5b87..353db4de 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -72,6 +72,7 @@ pub trait SystemResource { fn service_stop(&self, name: &str) -> Result<(), SystemError>; fn service_restart(&self, name: &str) -> Result<(), SystemError>; fn service_reload(&self, name: &str) -> Result<(), SystemError>; + fn daemon_reload(&self) -> Result<(), SystemError>; } pub struct LinuxSystemResource { @@ -91,10 +92,6 @@ impl LinuxSystemResource { } fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { - if name == "daemon-reload" && action == "reload" { - return self.daemon_reload(); - } - let name_with_suffix = ensure_systemd_suffix(name); if let Some(conn) = &self.conn { @@ -174,10 +171,10 @@ impl SystemResource for LinuxSystemResource { debug!("Group {name} already exists"); if let Some(desired_gid) = gid { if grp.gid() != desired_gid { - warn!( + return Err(SystemError::GroupCheck(format!( "Group {name} exists but GID {} does not match desired {desired_gid}", grp.gid() - ); + ))); } } return Ok(false); @@ -215,18 +212,18 @@ impl SystemResource for LinuxSystemResource { debug!("User {name} already exists"); if let Some(desired_uid) = uid { if user.uid() != desired_uid { - warn!( + return Err(SystemError::Command(format!( "User {name} exists but UID {} does not match desired {desired_uid}", user.uid() - ); + ))); } } if let Some(desired_gid) = gid { if user.primary_group_id() != desired_gid { - warn!( + return Err(SystemError::Command(format!( "User {name} exists but GID {} does not match desired {desired_gid}", user.primary_group_id() - ); + ))); } } return Ok(false); @@ -352,6 +349,10 @@ impl SystemResource for LinuxSystemResource { fn service_reload(&self, name: &str) -> Result<(), SystemError> { self.run_systemctl("reload", name) } + + fn daemon_reload(&self) -> Result<(), SystemError> { + self.daemon_reload() + } } #[cfg(test)] diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index b2a2f684..3d998e35 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -95,6 +95,14 @@ impl SystemResource for MockSystem { .insert(name.to_string(), "reloaded".to_string()); Ok(()) } + + fn daemon_reload(&self) -> Result<(), SystemError> { + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert("daemon-reload".to_string(), "reloaded".to_string()); + Ok(()) + } } pub type FileMetadata = (Option, Option, Option); diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index 216cb726..aad29aa3 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -34,8 +34,8 @@ fn main() -> Result<()> { system, files, skillet_pihole::PiholeUser { - uid: Some(0), - gid: Some(0), + uid: None, + gid: None, name: "pihole".to_string(), group_name: "pihole".to_string(), }, diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index b7de5d6b..c07f1a8f 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -110,8 +110,14 @@ where let host_info = resolve_host_user(system, &config.user, config.create_host_user)?; // 2. Calculate mappings - if let Some((uid_host, gid_host, _)) = &host_info { - calculate_user_mappings(&config.user, *uid_host, *gid_host, &mut extra_config); + if let Some((uid_host, gid_host, username)) = &host_info { + calculate_user_mappings( + &config.user, + *uid_host, + *gid_host, + username, + &mut extra_config, + ); } // 3. Ensure volumes and secrets @@ -181,13 +187,16 @@ fn calculate_user_mappings( user: &ContainerUser, uid_host: u32, gid_host: u32, + username: &str, extra_config: &mut BTreeMap>, ) { let uid_container = user.container_uid; let gid_container = user.container_gid; - let (sub_uid_base, sub_uid_size) = discover_subid_range("/etc/subuid").unwrap_or((100_000, 65_536)); - let (sub_gid_base, sub_gid_size) = discover_subid_range("/etc/subgid").unwrap_or((100_000, 65_536)); + let (sub_uid_base, sub_uid_size) = + discover_subid_range("/etc/subuid", username).unwrap_or((100_000, 65_536)); + let (sub_gid_base, sub_gid_size) = + discover_subid_range("/etc/subgid", username).unwrap_or((100_000, 65_536)); let container_section = extra_config.entry("Container".to_string()).or_default(); container_section.push(format!("User={uid_container}:{gid_container}")); @@ -221,17 +230,16 @@ fn calculate_user_mappings( } } -fn discover_subid_range(path: &str) -> Option<(u32, u32)> { +fn discover_subid_range(path: &str, username: &str) -> Option<(u32, u32)> { use std::fs::File; use std::io::{BufRead, BufReader}; - let user = users::get_current_username()?.to_string_lossy().to_string(); let file = File::open(path).ok()?; let reader = BufReader::new(file); for line in reader.lines().map_while(Result::ok) { let parts: Vec<&str> = line.split(':').collect(); - if parts.len() == 3 && parts[0] == user { + if parts.len() == 3 && parts[0] == username { let start = parts[1].parse().ok()?; let size = parts[2].parse().ok()?; return Some((start, size)); @@ -271,7 +279,7 @@ where if changed { info!("Quadlet changed, triggering daemon-reload"); - system.service_reload("daemon-reload")?; + system.daemon_reload()?; } Ok(changed) From 750a91f15f6dd90954abe2a5b963a3dbb02c9578 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 14:18:16 +0200 Subject: [PATCH 046/252] build: default to static musl target for skillet binaries Set x86_64-unknown-linux-musl as the default cargo target with explicit +crt-static, so every host binary is fully static and runs on any x86_64 Linux target (e.g. immutable uCore) with no glibc dependency. - Add ublue/skillet/.cargo/config.toml with the musl default target - Teach the integration test runner (locate_binary) to look under target/x86_64-unknown-linux-musl/{release,debug} - Install the musl target in CI, add skillet to workflow triggers, and fix the integration test binary path - Document the one-time rustup target add in the README --- .github/workflows/ci.yml | 8 +++++--- ublue/skillet/.cargo/config.toml | 12 ++++++++++++ ublue/skillet/README.md | 13 +++++++++++++ ublue/skillet/crates/cli/src/main.rs | 9 ++++++++- 4 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 ublue/skillet/.cargo/config.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f5621f5..8ed90dd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main, master ] + branches: [ main, master, skillet ] pull_request: - branches: [ main, master ] + branches: [ main, master, skillet ] jobs: chef-lint: @@ -66,6 +66,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt + targets: x86_64-unknown-linux-musl - name: Rust Cache if: steps.filter.outputs.rust == 'true' @@ -86,5 +87,6 @@ jobs: run: | cd ublue/skillet # Build binary explicitly for the test runner to find it + # (default target is x86_64-unknown-linux-musl, see .cargo/config.toml) cargo build - ./target/debug/skillet test run beezelbot --image fedora:latest + ./target/x86_64-unknown-linux-musl/debug/skillet test run beezelbot --image fedora:latest diff --git a/ublue/skillet/.cargo/config.toml b/ublue/skillet/.cargo/config.toml new file mode 100644 index 00000000..01e5eccf --- /dev/null +++ b/ublue/skillet/.cargo/config.toml @@ -0,0 +1,12 @@ +# Static musl builds: every host binary is a fully static executable with no +# glibc or other system library dependencies, so it can be dropped onto any +# x86_64 Linux target (e.g. an immutable uCore host) and just run. +# +# One-time setup: rustup target add x86_64-unknown-linux-musl +[build] +target = "x86_64-unknown-linux-musl" + +[target.x86_64-unknown-linux-musl] +# crt-static is already the default for the musl target; spelled out here so +# the intent (fully static, no dynamic libc) is explicit and auditable. +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index a248bb84..b13eaf55 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -4,6 +4,15 @@ Skillet is a Rust-based tool for idempotent host configuration management. It is ## Building +Skillet builds fully static musl binaries by default (see `.cargo/config.toml`), +so each host binary runs on any x86_64 Linux target with no glibc or system +library dependencies — ideal for dropping onto an immutable host. + +One-time setup: +```bash +rustup target add x86_64-unknown-linux-musl +``` + ### Development Build To build the workspace for development, use the standard cargo command: ```bash @@ -15,6 +24,10 @@ For optimized production builds, use the `--release` flag: ```bash cargo build --release ``` +The per-host binaries land at +`target/x86_64-unknown-linux-musl/release/skillet-` +(e.g. `skillet-clamps`). Verify with `file`, which should report +"statically linked". ## Running diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 8226c18f..89ccc348 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -188,15 +188,22 @@ fn locate_binary(hostname: &str) -> Result { let host_binary_name = format!("skillet-{hostname}"); let root = find_workspace_root()?; + let musl = "x86_64-unknown-linux-musl"; let binary_path = [ + root.join(format!("target/{musl}/release")).join(&host_binary_name), + root.join(format!("target/{musl}/debug")).join(&host_binary_name), root.join("target/release").join(&host_binary_name), root.join("target/debug").join(&host_binary_name), + root.join(format!("target/{musl}/release")).join("skillet"), + root.join(format!("target/{musl}/debug")).join("skillet"), root.join("target/release").join("skillet"), root.join("target/debug").join("skillet"), ] .into_iter() .find(|p| p.exists()) - .ok_or_else(|| anyhow!("No suitable skillet binary found in target/release or target/debug"))?; + .ok_or_else(|| { + anyhow!("No suitable skillet binary found under target/ (checked release, debug, and x86_64-unknown-linux-musl subdirs)") + })?; info!("Using binary: {}", binary_path.display()); fs::canonicalize(&binary_path).context("Failed to canonicalize binary path") From ab6b3bfa1d6b98267d2ee234c5e84cab142b7c85 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 14:24:43 +0200 Subject: [PATCH 047/252] Fix clippy lints blocking skillet-checks redundant_closure (x2), ignored_unit_patterns, if_not_else in skillet_core. These pre-date the musl change (skillet branch CI has been failing on them since April); newer stable clippy fires them and -D warnings denies them. --- ublue/skillet/crates/core/src/files.rs | 4 ++-- ublue/skillet/crates/core/src/system.rs | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 450aa4d6..163c89a6 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -109,11 +109,11 @@ impl LocalFileResource { if let Some(m) = mode { let mut perms = file .metadata() - .map_err(|e| FileError::Io(e))? + .map_err(FileError::Io)? .permissions(); perms.set_mode(m); file.set_permissions(perms) - .map_err(|e| FileError::Io(e))?; + .map_err(FileError::Io)?; } if owner.is_some() || group.is_some() { diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 353db4de..57734e42 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -138,7 +138,7 @@ impl LinuxSystemResource { info!("Running systemctl daemon-reload via DBus"); let proxy = SystemdManagerProxyBlocking::new(conn)?; match proxy.reload() { - Ok(_) => return Ok(()), + Ok(()) => return Ok(()), Err(e) => { warn!("DBus daemon-reload failed, falling back to CLI: {e}"); } @@ -279,12 +279,8 @@ impl SystemResource for LinuxSystemResource { .trim() .to_string(); // If the label is missing, the output will be empty - if !existing_hash.is_empty() { - if existing_hash == hash { - debug!("Podman secret {name} already exists with correct hash"); - return Ok(false); - } - warn!("Podman secret {name} exists but hash mismatch. Deleting and recreating."); + if existing_hash.is_empty() { + warn!("Podman secret {name} exists but lacks required label. Deleting and recreating."); let rm_status = Command::new("podman") .args(["secret", "rm", name]) .status()?; @@ -293,8 +289,12 @@ impl SystemResource for LinuxSystemResource { "Failed to remove old secret {name}" ))); } - } else { - warn!("Podman secret {name} exists but lacks required label. Deleting and recreating."); + } else { + if existing_hash == hash { + debug!("Podman secret {name} already exists with correct hash"); + return Ok(false); + } + warn!("Podman secret {name} exists but hash mismatch. Deleting and recreating."); let rm_status = Command::new("podman") .args(["secret", "rm", name]) .status()?; @@ -303,7 +303,7 @@ impl SystemResource for LinuxSystemResource { "Failed to remove old secret {name}" ))); } - } + } } info!("Creating podman secret {name}"); From 3ed81b14d81b7ff51ec11e12acb281509fa02911 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 14:27:59 +0200 Subject: [PATCH 048/252] Fix next batch of clippy lints blocking skillet-checks similar_names (uid/gid subid ranges are intentionally parallel), items_after_statements, io_other_error. Same story as the previous commit: pre-existing on the skillet branch, newly fired by stable clippy, denied by -D warnings. --- ublue/skillet/crates/cli-common/src/lib.rs | 12 +++++++----- ublue/skillet/crates/podman/src/lib.rs | 2 ++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 81f9dc35..ed9d6206 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -72,6 +72,8 @@ pub fn handle_apply( where F: Fn(&dyn SystemResource, &dyn FileResource) -> Result<(), String>, { + use std::io::Write as _; + info!("Starting Skillet configuration for {}...", hostname); let system = LinuxSystemResource::new(); @@ -91,13 +93,13 @@ where let mut temp = tempfile::NamedTempFile::new_in( path.parent().unwrap_or_else(|| std::path::Path::new(".")), )?; - use std::io::Write as _; temp.write_all(yaml.as_bytes())?; temp.persist(&path).map_err(|e| { - CliCommonError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Failed to persist recording to {}: {}", path.display(), e), - )) + CliCommonError::Io(std::io::Error::other(format!( + "Failed to persist recording to {}: {}", + path.display(), + e + ))) })?; info!("Recording saved to {}", path.display()); } else { diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index c07f1a8f..941bdd17 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -195,6 +195,8 @@ fn calculate_user_mappings( let (sub_uid_base, sub_uid_size) = discover_subid_range("/etc/subuid", username).unwrap_or((100_000, 65_536)); + // uid/gid subid ranges are intentionally parallel + #[allow(clippy::similar_names)] let (sub_gid_base, sub_gid_size) = discover_subid_range("/etc/subgid", username).unwrap_or((100_000, 65_536)); From 0b4e806c69e2bda113bf87d9edb6465d0a82f365 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 14:31:16 +0200 Subject: [PATCH 049/252] Hoist similar_names allow to fn level The lint evaluates bindings at block scope, so a statement-level #[allow] does not suppress it. The uid/gid subid ranges are intentionally parallel. --- ublue/skillet/crates/podman/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 941bdd17..f982f9bb 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -183,6 +183,9 @@ fn resolve_host_user( } } +// uid/gid subid ranges are intentionally parallel; the one-letter +// difference is the whole point +#[allow(clippy::similar_names)] fn calculate_user_mappings( user: &ContainerUser, uid_host: u32, @@ -195,8 +198,6 @@ fn calculate_user_mappings( let (sub_uid_base, sub_uid_size) = discover_subid_range("/etc/subuid", username).unwrap_or((100_000, 65_536)); - // uid/gid subid ranges are intentionally parallel - #[allow(clippy::similar_names)] let (sub_gid_base, sub_gid_size) = discover_subid_range("/etc/subgid", username).unwrap_or((100_000, 65_536)); From def96e7c2569cb7cefae47ebd059763a642b9b73 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 14:35:49 +0200 Subject: [PATCH 050/252] Fix clippy lints in skillet bin crate single_component_path_imports (x3), unnecessary_wraps on lookup_uid/lookup_gid (now plain Option, call site simplified), similar_names on the parallel pihole uid/gid lookups (fn-level allow). Same pre-existing/new-clippy story as the previous lint commits. --- ublue/skillet/crates/cli/src/host_applies.rs | 35 ++++++-------------- ublue/skillet/crates/cli/src/main.rs | 1 - 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/ublue/skillet/crates/cli/src/host_applies.rs b/ublue/skillet/crates/cli/src/host_applies.rs index ad2b3c20..82152117 100644 --- a/ublue/skillet/crates/cli/src/host_applies.rs +++ b/ublue/skillet/crates/cli/src/host_applies.rs @@ -3,8 +3,6 @@ use skillet_core::{ files::{FileError, FileResource}, system::{SystemError, SystemResource}, }; -use skillet_hardening; -use skillet_pihole; use skillet_podman::{QuadletSecret, SecretTarget}; use thiserror::Error; @@ -23,27 +21,22 @@ pub enum ApplyError { } mod user_lookup { - use skillet_core::system::SystemError; use users::{get_group_by_name, get_user_by_name}; /// Look up UID for a username, returns None if user doesn't exist - pub fn lookup_uid(username: &str) -> Result, SystemError> { - match get_user_by_name(username) { - Some(user) => Ok(Some(user.uid())), - None => Ok(None), - } + pub fn lookup_uid(username: &str) -> Option { + get_user_by_name(username).map(|user| user.uid()) } /// Look up GID for a group name, returns None if group doesn't exist - pub fn lookup_gid(groupname: &str) -> Result, SystemError> { - match get_group_by_name(groupname) { - Some(group) => Ok(Some(group.gid())), - None => Ok(None), - } + pub fn lookup_gid(groupname: &str) -> Option { + get_group_by_name(groupname).map(|group| group.gid()) } } /// Apply configuration for a specific host +// pihole uid/gid lookups are intentionally parallel +#[allow(clippy::similar_names)] pub fn apply_host( hostname: &str, system: &(impl SystemResource + ?Sized), @@ -65,18 +58,10 @@ pub fn apply_host( system .ensure_podman_secret("pihole_web_password", &secret_payload)?; - // Look up pihole user and group IDs - let (pihole_uid_opt, pihole_gid_opt) = match ( - user_lookup::lookup_uid("pihole"), - user_lookup::lookup_gid("pihole"), - ) { - (Ok(uid), Ok(gid)) => (uid, gid), - _ => { - // If user/group doesn't exist yet, we'll create them with dynamic IDs - // For now, use placeholders that will be replaced during ensure_user/group - (None, None) - } - }; + // Look up pihole user and group IDs; None if the user/group + // doesn't exist yet (ensure_user/group will assign dynamic IDs) + let pihole_uid_opt = user_lookup::lookup_uid("pihole"); + let pihole_gid_opt = user_lookup::lookup_gid("pihole"); // 3. Apply pihole with the secret let secrets = vec![QuadletSecret { diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 89ccc348..5b647a2a 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,6 +1,5 @@ use anyhow::{anyhow, Context, Result}; use clap::{Parser, Subcommand}; -use skillet_cli_common; use skillet_core::credentials::CredentialManager; use skillet_core::resource_op::ResourceOp; use std::fs; From d3c6bc92498a1f7f5b4b908524fede3e627ed9d3 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:54:32 +0000 Subject: [PATCH 051/252] skillet: unify per-host apply logic in skillet_cli_common::hosts The generic 'skillet apply --host clamps' path (host_applies.rs) and the skillet-clamps binary each carried their own copy of the clamps composition logic, and the two had already diverged: the generic path looked up the existing pihole UID/GID and passed them through to the user record and the quadlet secret target, while the host binary passed None. There is now exactly one apply function per host (skillet_cli_common::hosts::{apply_beezelbot, apply_clamps, apply_host}), called both by the per-host binaries and by the generic dispatcher, so the two entry points cannot diverge again. Reconciliation choice: kept the lookup-based UID/GID handling from the generic path. When the pihole account already exists (e.g. pre-created by Ignition with fixed IDs), the looked-up IDs keep the secret file ownership and the ensure_user record consistent instead of silently ignoring them; when it does not exist yet, None still means 'assign dynamic IDs'. --- ublue/skillet/Cargo.lock | 3 + ublue/skillet/crates/cli-common/Cargo.toml | 3 + ublue/skillet/crates/cli-common/src/hosts.rs | 116 ++++++++++++++++++ ublue/skillet/crates/cli-common/src/lib.rs | 5 +- ublue/skillet/crates/cli/src/host_applies.rs | 95 -------------- ublue/skillet/crates/cli/src/main.rs | 4 +- .../crates/hosts/beezelbot/src/main.rs | 4 +- ublue/skillet/crates/hosts/clamps/src/main.rs | 45 +------ 8 files changed, 132 insertions(+), 143 deletions(-) create mode 100644 ublue/skillet/crates/cli-common/src/hosts.rs delete mode 100644 ublue/skillet/crates/cli/src/host_applies.rs diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 322ec76a..476d1883 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1124,10 +1124,13 @@ dependencies = [ "serde_yml", "skillet_core", "skillet_hardening", + "skillet_pihole", + "skillet_podman", "tempfile", "thiserror 1.0.69", "tracing", "tracing-subscriber", + "users", ] [[package]] diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index 3b76ec3d..e0887cd4 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -9,6 +9,9 @@ workspace = true [dependencies] skillet_core.workspace = true skillet_hardening.workspace = true +skillet_pihole.workspace = true +skillet_podman.workspace = true +users.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs new file mode 100644 index 00000000..7ba2b4a7 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -0,0 +1,116 @@ +//! Canonical per-host apply functions. +//! +//! Each supported host has exactly one apply function here. The per-host +//! binaries (`skillet-`) and the generic `skillet apply --host ` +//! dispatcher both call these, so host logic can never diverge between the +//! two entry points again. + +use skillet_core::{ + credentials::{CredentialError, CredentialManager}, + files::{FileError, FileResource}, + system::{SystemError, SystemResource}, +}; +use skillet_podman::{QuadletSecret, SecretTarget}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ApplyError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Credential error: {0}")] + Credential(#[from] CredentialError), + #[error("Hardening apply error: {0}")] + Hardening(String), + #[error("Pihole apply error: {0}")] + Pihole(#[from] skillet_pihole::PiholeError), +} + +mod user_lookup { + use users::{get_group_by_name, get_user_by_name}; + + /// Look up UID for a username, returns None if user doesn't exist + pub fn lookup_uid(username: &str) -> Option { + get_user_by_name(username).map(|user| user.uid()) + } + + /// Look up GID for a group name, returns None if group doesn't exist + pub fn lookup_gid(groupname: &str) -> Option { + get_group_by_name(groupname).map(|group| group.gid()) + } +} + +/// Apply the beezelbot host configuration (hardening baseline). +pub fn apply_beezelbot( + system: &dyn SystemResource, + files: &dyn FileResource, +) -> Result<(), ApplyError> { + skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) +} + +/// Apply the clamps host configuration (hardening + Pi-hole). +// pihole uid/gid lookups are intentionally parallel +#[allow(clippy::similar_names)] +pub fn apply_clamps( + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialManager, +) -> Result<(), ApplyError> { + skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string()))?; + + // 1. Ingest secret from systemd + let secret_payload = credentials.read_secret("test_secret")?; + + // 2. Provision to Podman + system.ensure_podman_secret("pihole_web_password", &secret_payload)?; + + // Look up pihole user and group IDs; None if the user/group + // doesn't exist yet (ensure_user/group will assign dynamic IDs). + // Passing the looked-up IDs through (rather than None) keeps the + // secret file ownership and the user record consistent when the + // account already exists, e.g. pre-created by Ignition. + let pihole_uid = user_lookup::lookup_uid("pihole"); + let pihole_gid = user_lookup::lookup_gid("pihole"); + + // 3. Apply pihole with the secret + let secrets = vec![QuadletSecret { + secret_name: "pihole_web_password".to_string(), + target: SecretTarget::File { + target_path: "/etc/pihole/webpassword".to_string(), + mode: Some("0400".to_string()), + uid: pihole_uid, + gid: pihole_gid, + }, + }]; + + skillet_pihole::apply( + system, + files, + skillet_pihole::PiholeUser { + uid: pihole_uid, + gid: pihole_gid, + name: "pihole".to_string(), + group_name: "pihole".to_string(), + }, + secrets, + )?; + Ok(()) +} + +/// Dispatch to the canonical apply function for a hostname. +/// +/// Unknown hostnames fall back to the hardening-only baseline, matching the +/// previous "(Agent Mode)" default behaviour. +pub fn apply_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialManager, +) -> Result<(), ApplyError> { + match hostname { + "beezelbot" => apply_beezelbot(system, files), + "clamps" => apply_clamps(system, files, credentials), + _ => apply_beezelbot(system, files), + } +} diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index ed9d6206..77833046 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -1,6 +1,5 @@ use clap::Parser; -use skillet_core::files::{FileResource, LocalFileResource}; -use skillet_core::recorder::Recorder; +use skillet_core::files::{FileResource, LocalFileResource};use skillet_core::recorder::Recorder; use skillet_core::system::{LinuxSystemResource, SystemResource}; use std::fs; use std::path::PathBuf; @@ -8,6 +7,8 @@ use thiserror::Error; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; +pub mod hosts; + #[derive(Error, Debug)] pub enum CliCommonError { #[error("Configuration error: {0}")] diff --git a/ublue/skillet/crates/cli/src/host_applies.rs b/ublue/skillet/crates/cli/src/host_applies.rs deleted file mode 100644 index 82152117..00000000 --- a/ublue/skillet/crates/cli/src/host_applies.rs +++ /dev/null @@ -1,95 +0,0 @@ -use skillet_core::{ - credentials::{CredentialError, CredentialManager}, - files::{FileError, FileResource}, - system::{SystemError, SystemResource}, -}; -use skillet_podman::{QuadletSecret, SecretTarget}; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum ApplyError { - #[error("System error: {0}")] - System(#[from] SystemError), - #[error("File error: {0}")] - File(#[from] FileError), - #[error("Credential error: {0}")] - Credential(#[from] CredentialError), - #[error("Hardening apply error: {0}")] - Hardening(String), - #[error("Pihole apply error: {0}")] - Pihole(#[from] skillet_pihole::PiholeError), -} - -mod user_lookup { - use users::{get_group_by_name, get_user_by_name}; - - /// Look up UID for a username, returns None if user doesn't exist - pub fn lookup_uid(username: &str) -> Option { - get_user_by_name(username).map(|user| user.uid()) - } - - /// Look up GID for a group name, returns None if group doesn't exist - pub fn lookup_gid(groupname: &str) -> Option { - get_group_by_name(groupname).map(|group| group.gid()) - } -} - -/// Apply configuration for a specific host -// pihole uid/gid lookups are intentionally parallel -#[allow(clippy::similar_names)] -pub fn apply_host( - hostname: &str, - system: &(impl SystemResource + ?Sized), - files: &(impl FileResource + ?Sized), - credentials: &CredentialManager, -) -> Result<(), ApplyError> { - match hostname { - "beezelbot" => { - skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string()))?; - } - "clamps" => { - skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string()))?; - - // 1. Ingest secret from systemd - let secret_payload = credentials - .read_secret("test_secret")?; - - // 2. Provision to Podman - system - .ensure_podman_secret("pihole_web_password", &secret_payload)?; - - // Look up pihole user and group IDs; None if the user/group - // doesn't exist yet (ensure_user/group will assign dynamic IDs) - let pihole_uid_opt = user_lookup::lookup_uid("pihole"); - let pihole_gid_opt = user_lookup::lookup_gid("pihole"); - - // 3. Apply pihole with the secret - let secrets = vec![QuadletSecret { - secret_name: "pihole_web_password".to_string(), - target: SecretTarget::File { - target_path: "/etc/pihole/webpassword".to_string(), - mode: Some("0400".to_string()), - uid: pihole_uid_opt, - gid: pihole_gid_opt, - }, - }]; - - skillet_pihole::apply( - system, - files, - skillet_pihole::PiholeUser { - uid: pihole_uid_opt, - gid: pihole_gid_opt, - name: "pihole".to_string(), - group_name: "pihole".to_string(), - }, - secrets, - )?; - } - _ => { - // Default fallback: just hardening - skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string()))?; - } - } - Ok(()) -} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 5b647a2a..9a87746e 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -9,8 +9,6 @@ use std::process::Command; use tracing::{error, info, Level}; use tracing_subscriber::FmtSubscriber; -mod host_applies; - #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { @@ -95,7 +93,7 @@ fn main() -> Result<()> { skillet_cli_common::handle_apply(&hostname, record, |system, files| { // Initialize credential manager once let cred_manager = CredentialManager::new().map_err(|e| e.to_string())?; - host_applies::apply_host(&hostname, system, files, &cred_manager) + skillet_cli_common::hosts::apply_host(&hostname, system, files, &cred_manager) .map_err(|e| e.to_string()) }) .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; diff --git a/ublue/skillet/crates/hosts/beezelbot/src/main.rs b/ublue/skillet/crates/hosts/beezelbot/src/main.rs index cd1ad8cd..0c56465e 100644 --- a/ublue/skillet/crates/hosts/beezelbot/src/main.rs +++ b/ublue/skillet/crates/hosts/beezelbot/src/main.rs @@ -1,9 +1,9 @@ use anyhow::Result; -use skillet_cli_common::run_host; +use skillet_cli_common::{hosts, run_host}; fn main() -> Result<()> { run_host("beezelbot", |system, files| { - skillet_hardening::apply(system, files).map_err(|e| e.to_string()) + hosts::apply_beezelbot(system, files).map_err(|e| e.to_string()) })?; Ok(()) } diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index aad29aa3..ddf7f298 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -1,49 +1,12 @@ use anyhow::Result; -use skillet_cli_common::run_host; +use skillet_cli_common::{hosts, run_host}; use skillet_core::credentials::CredentialManager; -use skillet_podman::{QuadletSecret, SecretTarget}; fn main() -> Result<()> { run_host("clamps", |system, files| { - skillet_hardening::apply(system, files).map_err(|e| e.to_string())?; - - // 1. Ingest secret from systemd - let cred_manager = CredentialManager::new() - .map_err(|e: skillet_core::credentials::CredentialError| e.to_string())?; - let secret_payload = cred_manager - .read_secret("test_secret") - .map_err(|e: skillet_core::credentials::CredentialError| e.to_string())?; - - // 2. Provision to Podman - system - .ensure_podman_secret("pihole_web_password", &secret_payload) - .map_err(|e| e.to_string())?; - - // 3. Apply pihole with the secret - let secrets = vec![QuadletSecret { - secret_name: "pihole_web_password".to_string(), - target: SecretTarget::File { - target_path: "/etc/pihole/webpassword".to_string(), - mode: Some("0400".to_string()), - uid: None, - gid: None, - }, - }]; - - skillet_pihole::apply( - system, - files, - skillet_pihole::PiholeUser { - uid: None, - gid: None, - name: "pihole".to_string(), - group_name: "pihole".to_string(), - }, - secrets, - ) - .map_err(|e| e.to_string())?; - - Ok(()) + // Initialize credential manager once + let cred_manager = CredentialManager::new().map_err(|e| e.to_string())?; + hosts::apply_clamps(system, files, &cred_manager).map_err(|e| e.to_string()) })?; Ok(()) } From 22a90cf9a953594c8b7f13bef024d135c0ec0aa3 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:54:38 +0000 Subject: [PATCH 052/252] skillet: construct CredentialManager lazily, only for hosts that need secrets 'skillet apply --host beezelbot' used to fail when CREDENTIALS_DIRECTORY was unset because the generic CLI built a CredentialManager unconditionally, even though beezelbot consumes no secrets. The manager is now constructed inside apply_clamps(), the only host apply path that reads secrets. --- ublue/skillet/crates/cli-common/src/hosts.rs | 10 ++++++---- ublue/skillet/crates/cli/src/main.rs | 5 +---- ublue/skillet/crates/hosts/clamps/src/main.rs | 5 +---- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 7ba2b4a7..cfda419e 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -50,16 +50,19 @@ pub fn apply_beezelbot( } /// Apply the clamps host configuration (hardening + Pi-hole). +/// +/// The credential manager is constructed lazily here: hosts that need no +/// secrets (e.g. beezelbot) never touch CREDENTIALS_DIRECTORY. // pihole uid/gid lookups are intentionally parallel #[allow(clippy::similar_names)] pub fn apply_clamps( system: &dyn SystemResource, files: &dyn FileResource, - credentials: &CredentialManager, ) -> Result<(), ApplyError> { skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string()))?; - // 1. Ingest secret from systemd + // 1. Ingest secret from systemd (lazy: only this host needs secrets) + let credentials = CredentialManager::new()?; let secret_payload = credentials.read_secret("test_secret")?; // 2. Provision to Podman @@ -106,11 +109,10 @@ pub fn apply_host( hostname: &str, system: &dyn SystemResource, files: &dyn FileResource, - credentials: &CredentialManager, ) -> Result<(), ApplyError> { match hostname { "beezelbot" => apply_beezelbot(system, files), - "clamps" => apply_clamps(system, files, credentials), + "clamps" => apply_clamps(system, files), _ => apply_beezelbot(system, files), } } diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 9a87746e..21560bd5 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,6 +1,5 @@ use anyhow::{anyhow, Context, Result}; use clap::{Parser, Subcommand}; -use skillet_core::credentials::CredentialManager; use skillet_core::resource_op::ResourceOp; use std::fs; use std::io::Write; @@ -91,9 +90,7 @@ fn main() -> Result<()> { } skillet_cli_common::handle_apply(&hostname, record, |system, files| { - // Initialize credential manager once - let cred_manager = CredentialManager::new().map_err(|e| e.to_string())?; - skillet_cli_common::hosts::apply_host(&hostname, system, files, &cred_manager) + skillet_cli_common::hosts::apply_host(&hostname, system, files) .map_err(|e| e.to_string()) }) .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index ddf7f298..6d96bedc 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -1,12 +1,9 @@ use anyhow::Result; use skillet_cli_common::{hosts, run_host}; -use skillet_core::credentials::CredentialManager; fn main() -> Result<()> { run_host("clamps", |system, files| { - // Initialize credential manager once - let cred_manager = CredentialManager::new().map_err(|e| e.to_string())?; - hosts::apply_clamps(system, files, &cred_manager).map_err(|e| e.to_string()) + hosts::apply_clamps(system, files).map_err(|e| e.to_string()) })?; Ok(()) } From 0a49871ce77fc25ec4ab66f7d91d70c0a1ad7274 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:55:05 +0000 Subject: [PATCH 053/252] skillet: move Pi-hole placeholders into per-host config, use real credential name - skillet_pihole::apply() now takes the custom DNS records as a parameter (BTreeMap for deterministic template rendering) instead of hardcoding 192.168.1.100 / my.custom.domain in the shared crate. The clamps values live in skillet_cli_common::hosts as CLAMPS_CUSTOM_DNS_RECORDS, still placeholders but now trivially adjustable per host (TODO marks them for the production cutover). - Rename the 'test_secret' placeholder to the real systemd credential / podman secret name 'pihole_web_password' (new PIHOLE_WEB_PASSWORD_CREDENTIAL const); the integration-test mock credentials dir follows the rename. - Wire LoadCredential=pihole_web_password into the skillet-apply.service unit in ublue/butane/includes/skillet.bu so systemd exposes it via $CREDENTIALS_DIRECTORY. The credential file itself (/etc/skillet/credentials/pihole_web_password) still has to be provisioned by Ignition with the real secret value. --- ublue/butane/includes/skillet.bu | 4 +++ ublue/skillet/crates/cli-common/src/hosts.rs | 29 ++++++++++++++++---- ublue/skillet/crates/cli/src/main.rs | 5 +++- ublue/skillet/crates/pihole/src/lib.rs | 17 +++++++----- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index 70f40e8e..1fc831bb 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -14,6 +14,10 @@ systemd: Type=oneshot # Use the generic skillet binary and pass the hostname from /etc/hostname ExecStart=/usr/bin/skillet apply --host-file /etc/hostname + # Expose the Pi-hole web password to skillet via $CREDENTIALS_DIRECTORY. + # The credential file itself must be provisioned separately (Ignition + # storage.files); the service intentionally fails if it is missing. + LoadCredential=pihole_web_password:/etc/skillet/credentials/pihole_web_password RemainAfterExit=yes [Install] diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index cfda419e..275b1161 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -11,6 +11,7 @@ use skillet_core::{ system::{SystemError, SystemResource}, }; use skillet_podman::{QuadletSecret, SecretTarget}; +use std::collections::BTreeMap; use thiserror::Error; #[derive(Error, Debug)] @@ -27,8 +28,7 @@ pub enum ApplyError { Pihole(#[from] skillet_pihole::PiholeError), } -mod user_lookup { - use users::{get_group_by_name, get_user_by_name}; +mod user_lookup { use users::{get_group_by_name, get_user_by_name}; /// Look up UID for a username, returns None if user doesn't exist pub fn lookup_uid(username: &str) -> Option { @@ -49,6 +49,19 @@ pub fn apply_beezelbot( skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) } +/// Name of the systemd credential (and podman secret) holding the Pi-hole +/// web UI password. Wired into `skillet-apply.service` via `LoadCredential=` +/// in `ublue/butane/includes/skillet.bu`; the credential file itself +/// (`/etc/skillet/credentials/pihole_web_password`) must be provisioned by +/// Ignition separately. +pub const PIHOLE_WEB_PASSWORD_CREDENTIAL: &str = "pihole_web_password"; + +/// Custom DNS records for the clamps Pi-hole (`ip -> fqdn`). +// TODO: replace with the real LAN IP and domain before the production +// cutover; these are still the old placeholder values. +const CLAMPS_CUSTOM_DNS_RECORDS: &[(&str, &str)] = + &[("192.168.1.100", "my.custom.domain")]; + /// Apply the clamps host configuration (hardening + Pi-hole). /// /// The credential manager is constructed lazily here: hosts that need no @@ -63,10 +76,10 @@ pub fn apply_clamps( // 1. Ingest secret from systemd (lazy: only this host needs secrets) let credentials = CredentialManager::new()?; - let secret_payload = credentials.read_secret("test_secret")?; + let secret_payload = credentials.read_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL)?; // 2. Provision to Podman - system.ensure_podman_secret("pihole_web_password", &secret_payload)?; + system.ensure_podman_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL, &secret_payload)?; // Look up pihole user and group IDs; None if the user/group // doesn't exist yet (ensure_user/group will assign dynamic IDs). @@ -78,7 +91,7 @@ pub fn apply_clamps( // 3. Apply pihole with the secret let secrets = vec![QuadletSecret { - secret_name: "pihole_web_password".to_string(), + secret_name: PIHOLE_WEB_PASSWORD_CREDENTIAL.to_string(), target: SecretTarget::File { target_path: "/etc/pihole/webpassword".to_string(), mode: Some("0400".to_string()), @@ -87,6 +100,11 @@ pub fn apply_clamps( }, }]; + let custom_records: BTreeMap = CLAMPS_CUSTOM_DNS_RECORDS + .iter() + .map(|(ip, fqdn)| ((*ip).to_string(), (*fqdn).to_string())) + .collect(); + skillet_pihole::apply( system, files, @@ -97,6 +115,7 @@ pub fn apply_clamps( group_name: "pihole".to_string(), }, secrets, + custom_records, )?; Ok(()) } diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 21560bd5..7b22fc9b 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -214,7 +214,10 @@ fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Res let root = find_workspace_root()?; let mock_creds_dir = root.join("target/mock_creds"); fs::create_dir_all(&mock_creds_dir)?; - fs::write(mock_creds_dir.join("test_secret"), "supersecret_payload")?; + fs::write( + mock_creds_dir.join(skillet_cli_common::hosts::PIHOLE_WEB_PASSWORD_CREDENTIAL), + "supersecret_payload", + )?; let run_status = Command::new("podman") .args([ diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 56482174..c7dc1133 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -3,7 +3,7 @@ use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; use skillet_core::templates::ensure_templated_file; use skillet_podman::{self, ContainerUser, HostUser, PodmanError, Volume, PodmanConfig, QuadletSecret}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use std::path::Path; use thiserror::Error; use tracing::info; @@ -21,7 +21,7 @@ pub enum PiholeError { #[derive(Template)] #[template(path = "pihole/custom.list.j2")] struct CustomListTemplate { - custom: HashMap, + custom: BTreeMap, } pub struct PiholeUser { @@ -31,11 +31,15 @@ pub struct PiholeUser { pub group_name: String, } +/// Host-specific Pi-hole configuration: custom DNS records (`ip -> fqdn`) +/// rendered into `custom.list`. Kept out of the shared crate so each host +/// supplies its own LAN topology. pub fn apply( system: &S, files: &F, user_config: PiholeUser, secrets: Vec, + custom_records: BTreeMap, ) -> Result<(), PiholeError> where S: SystemResource + ?Sized, @@ -65,11 +69,10 @@ where )?; files.ensure_directory(Path::new(logs), Some(0o755), Some("root"), Some("root"))?; - // 3. Custom list template - let mut custom = HashMap::new(); - custom.insert("192.168.1.100".to_string(), "my.custom.domain".to_string()); - - let template = CustomListTemplate { custom }; + // 3. Custom list template (records supplied by the host) + let template = CustomListTemplate { + custom: custom_records, + }; ensure_templated_file( files, &Path::new(root).join("conf/custom.list"), From 40d1f40d1ce672c39e97c69dfeb553e1bb5a67fa Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:55:08 +0000 Subject: [PATCH 054/252] skillet: add SELinux :z relabeling to Pi-hole volume mounts On enforcing systems (uCore) the container would be denied access to /etc/pihole/conf, /etc/pihole/dnsmasq.d and /var/log/pihole without relabeling. Uses the existing Volume.options field; :z (shared) rather than :Z since host tooling may also touch these paths. --- ublue/skillet/crates/pihole/src/lib.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index c7dc1133..2f26e087 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -89,21 +89,23 @@ where host_user: Some(HostUser::Name(user_config.name)), }; + // SELinux relabeling (:z, shared) so the container can access these + // host paths on enforcing systems such as uCore. let volumes = vec![ Volume { host_path: format!("{root}/conf"), container_path: "/etc/pihole".to_string(), - options: None, + options: Some("z".to_string()), }, Volume { host_path: format!("{root}/dnsmasq.d"), container_path: "/etc/dnsmasq.d".to_string(), - options: None, + options: Some("z".to_string()), }, Volume { host_path: logs.to_string(), container_path: "/var/log/pihole".to_string(), - options: None, + options: Some("z".to_string()), }, ]; From 0e56062cb463d0a3abcc5b5fb0fc5f02fb3ac9d8 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:55:10 +0000 Subject: [PATCH 055/252] skillet: restart the unit when its quadlet file changes render_and_ensure_quadlet() already ran daemon-reload on change, but the previously started container kept running with the old definition until some later restart. Now a changed quadlet triggers daemon-reload followed by a (blocking, verified) restart of the unit, so the new definition takes effect immediately. service_restart on a not-yet-running unit starts it, which also covers first installs. --- ublue/skillet/crates/podman/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index f982f9bb..20365be4 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -283,6 +283,10 @@ where if changed { info!("Quadlet changed, triggering daemon-reload"); system.daemon_reload()?; + // A daemon-reload alone leaves the old container running; restart + // so the new definition takes effect immediately. + info!("Restarting {name} to pick up the new quadlet definition"); + system.service_restart(name)?; } Ok(changed) From 708c4164e3d99c41b3a0dfed41189221127b32dd Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:55:20 +0000 Subject: [PATCH 056/252] skillet: add service_enable and auto-enable quadlets with [Install] - New SystemResource::service_enable() (implemented for LinuxSystemResource via 'systemctl enable', plus Recorder and MockSystem impls and a new ResourceOp::ServiceEnable recording variant). - skillet_podman::container() now enables the generated unit whenever the quadlet ships an [Install] section, so containers like pihole actually come back after a reboot. Previously dropping the .container file did not guarantee boot activation. --- ublue/skillet/crates/core/src/recorder.rs | 7 +++++++ ublue/skillet/crates/core/src/resource_op.rs | 3 +++ ublue/skillet/crates/core/src/system.rs | 20 ++++++++++++++++++++ ublue/skillet/crates/core/src/test_utils.rs | 8 ++++++++ ublue/skillet/crates/podman/src/lib.rs | 13 ++++++++++++- 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index 6c222600..893962a4 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -152,6 +152,13 @@ impl SystemResource for Recorder { self.inner.service_reload(name) } + fn service_enable(&self, name: &str) -> Result<(), SystemError> { + self.record(ResourceOp::ServiceEnable { + name: name.to_string(), + }); + self.inner.service_enable(name) + } + fn daemon_reload(&self) -> Result<(), SystemError> { self.record(ResourceOp::DaemonReload); self.inner.daemon_reload() diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index 1f40ea93..d4666e6f 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -42,5 +42,8 @@ pub enum ResourceOp { ServiceReload { name: String, }, + ServiceEnable { + name: String, + }, DaemonReload, } diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 57734e42..7082d030 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -72,6 +72,7 @@ pub trait SystemResource { fn service_stop(&self, name: &str) -> Result<(), SystemError>; fn service_restart(&self, name: &str) -> Result<(), SystemError>; fn service_reload(&self, name: &str) -> Result<(), SystemError>; + fn service_enable(&self, name: &str) -> Result<(), SystemError>; fn daemon_reload(&self) -> Result<(), SystemError>; } @@ -350,6 +351,25 @@ impl SystemResource for LinuxSystemResource { self.run_systemctl("reload", name) } + fn service_enable(&self, name: &str) -> Result<(), SystemError> { + let name_with_suffix = ensure_systemd_suffix(name); + // No DBus fast-path here: `systemctl enable` is idempotent and + // cheap, and the CLI keeps this simple. + info!("Enabling {name_with_suffix}"); + let output = Command::new("systemctl") + .arg("enable") + .arg(&name_with_suffix) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SystemError::Command(format!( + "systemctl enable {name_with_suffix} failed: {stderr}" + ))); + } + Ok(()) + } + fn daemon_reload(&self) -> Result<(), SystemError> { self.daemon_reload() } diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 3d998e35..6edd4ebd 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -96,6 +96,14 @@ impl SystemResource for MockSystem { Ok(()) } + fn service_enable(&self, name: &str) -> Result<(), SystemError> { + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(name.to_string(), "enabled".to_string()); + Ok(()) + } + fn daemon_reload(&self) -> Result<(), SystemError> { self.services .lock() diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 20365be4..73385313 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -150,7 +150,18 @@ where } // 4. Render and ensure Quadlet file - render_and_ensure_quadlet(system, files, name, extra_config) + let wants_enable = extra_config.contains_key("Install"); + let changed = render_and_ensure_quadlet(system, files, name, extra_config)?; + + // 5. A quadlet shipping an [Install] section is meant to persist across + // reboots; ensure it is enabled. `systemctl enable` is idempotent, so + // this is safe to run on every apply. + if wants_enable { + info!("Quadlet has [Install] section, enabling {name}"); + system.service_enable(name)?; + } + + Ok(changed) } fn resolve_host_user( From 77817810f00bb6da67875f799fa2c95d9e1209b5 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:55:31 +0000 Subject: [PATCH 057/252] skillet: make service start/restart blocking and verify with is-active Every systemctl invocation used --no-block, so a service that failed to start still reported success to skillet. Now: - the CLI fallback path no longer passes --no-block (blocking start job); - after 'start'/'restart' (DBus or CLI path), skillet polls 'systemctl is-active --quiet' for up to 60s and returns an error if the unit never becomes active. This also covers the DBus path, which only queues the start job. The integration-test entrypoint mock now answers 'is-active' with success explicitly. --- .../skillet/crates/cli/src/test_entrypoint.sh | 13 ++++- ublue/skillet/crates/core/src/system.rs | 54 +++++++++++++++++-- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh index ce92a99a..3fc58d60 100755 --- a/ublue/skillet/crates/cli/src/test_entrypoint.sh +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -9,8 +9,17 @@ if [ ! -x /usr/bin/systemctl ]; then echo "Mocking systemctl..." cat < /usr/bin/systemctl #!/bin/sh -echo "Mock systemctl: \$@" -exit 0 +case "\$*" in + "is-active"*) + # Pretend every unit ends up active so skillet's post-start + # verification passes. + exit 0 + ;; + *) + echo "Mock systemctl: \$@" + exit 0 + ;; +esac EOF chmod +x /usr/bin/systemctl fi diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 7082d030..e98366f3 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -1,6 +1,7 @@ use sha2::{Digest, Sha256}; use std::process::{Command, Stdio}; use std::sync::LazyLock; +use std::time::{Duration, Instant}; use thiserror::Error; use tracing::{debug, info, warn}; use users::{get_group_by_name, get_user_by_name}; @@ -92,6 +93,11 @@ impl LinuxSystemResource { Self { conn } } + /// Run a systemctl action via DBus (preferred) or the CLI (fallback). + /// + /// Actions are blocking: the CLI path no longer passes `--no-block`, + /// and `start`/`restart` additionally wait until the unit reports + /// active, so a failed service can no longer look successful. fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { let name_with_suffix = ensure_systemd_suffix(name); @@ -111,18 +117,31 @@ impl LinuxSystemResource { }; match res { - Ok(_) => return Ok(()), + Ok(_) => {} Err(e) => { warn!("DBus call failed, falling back to CLI: {e}"); + self.run_systemctl_cli(action, &name_with_suffix)?; } } + } else { + self.run_systemctl_cli(action, &name_with_suffix)?; } + // DBus only queues the job, so verify the end state for the + // actions that are supposed to leave the unit running. + if matches!(action, "start" | "restart") { + self.wait_until_active(&name_with_suffix)?; + } + + Ok(()) + } + + /// Blocking `systemctl ` via the CLI. + fn run_systemctl_cli(&self, action: &str, name_with_suffix: &str) -> Result<(), SystemError> { info!("Running systemctl {action} {name_with_suffix} via CLI"); let output = Command::new("systemctl") .arg(action) - .arg("--no-block") - .arg(&name_with_suffix) + .arg(name_with_suffix) .output()?; if !output.status.success() { @@ -134,6 +153,35 @@ impl LinuxSystemResource { Ok(()) } + /// Poll `systemctl is-active` until the unit is active or we time out. + fn wait_until_active(&self, name_with_suffix: &str) -> Result<(), SystemError> { + const TIMEOUT: Duration = Duration::from_secs(60); + const POLL_INTERVAL: Duration = Duration::from_millis(500); + + let deadline = Instant::now() + TIMEOUT; + loop { + let status = Command::new("systemctl") + .arg("is-active") + .arg("--quiet") + .arg(name_with_suffix) + .status()?; + + if status.success() { + debug!("Service {name_with_suffix} is active"); + return Ok(()); + } + + if Instant::now() >= deadline { + return Err(SystemError::Command(format!( + "Service {name_with_suffix} did not become active within {}s of start/restart", + TIMEOUT.as_secs() + ))); + } + + std::thread::sleep(POLL_INTERVAL); + } + } + fn daemon_reload(&self) -> Result<(), SystemError> { if let Some(conn) = &self.conn { info!("Running systemctl daemon-reload via DBus"); From 9197de2e831db0dd7a6f93b53ef2179d63173416 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:55:37 +0000 Subject: [PATCH 058/252] skillet: fix UID/GID mapping arithmetic and host GID lookup - calculate_user_mappings(): 'sub_uid_size - uid_container - 1' (and the GID equivalent) could underflow and panic; use saturating_sub so a too-small subordinate range degrades to no remainder mapping instead. - resolve_host_user(): stop assuming host GID == UID. The host GID is now read from the user's primary group (primary_group_id()), which is the correct value for the GIDMap line when they differ. --- ublue/skillet/crates/podman/src/lib.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 73385313..1bd6d909 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -170,7 +170,7 @@ fn resolve_host_user( create: bool, ) -> Result, PodmanError> { if let Some(hu) = &user.host_user { - let (username, uid) = match hu { + let (username, uid, gid) = match hu { HostUser::Name(ref n) => { if create { system.ensure_user(n, None, None)?; @@ -178,17 +178,20 @@ fn resolve_host_user( let u = get_user_by_name(n).ok_or_else(|| { PodmanError::UserMapping(format!("User {n} not found on host")) })?; - (n.clone(), u.uid()) + (n.clone(), u.uid(), u.primary_group_id()) } HostUser::Uid(u) => { let u_info = get_user_by_uid(*u).ok_or_else(|| { PodmanError::UserMapping(format!("UID {u} not found on host")) })?; - (u_info.name().to_string_lossy().to_string(), *u) + ( + u_info.name().to_string_lossy().to_string(), + *u, + u_info.primary_group_id(), + ) } }; - // For simplicity, assuming gid = uid for now - Ok(Some((uid, uid, username))) + Ok(Some((uid, gid, username))) } else { Ok(None) } @@ -220,7 +223,10 @@ fn calculate_user_mappings( container_section.push(format!("UIDMap=0:{sub_uid_base}:{uid_container}")); } container_section.push(format!("UIDMap={uid_container}:{uid_host}:1")); - let rem_u = sub_uid_size - uid_container - 1; + // Remaining subordinate UIDs after the container UID; saturating so a + // too-small subuid range degrades to no remainder mapping instead of + // panicking on underflow. + let rem_u = sub_uid_size.saturating_sub(uid_container).saturating_sub(1); if rem_u > 0 { container_section.push(format!( "UIDMap={}:{}:{rem_u}", @@ -234,7 +240,8 @@ fn calculate_user_mappings( container_section.push(format!("GIDMap=0:{sub_gid_base}:{gid_container}")); } container_section.push(format!("GIDMap={gid_container}:{gid_host}:1")); - let rem_g = sub_gid_size - gid_container - 1; + // Same underflow protection as the UID remainder above. + let rem_g = sub_gid_size.saturating_sub(gid_container).saturating_sub(1); if rem_g > 0 { container_section.push(format!( "GIDMap={}:{}:{rem_g}", From f2046d45c5cb86606b416ebed51709755ca14e9e Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:56:29 +0000 Subject: [PATCH 059/252] skillet: reject path traversal in CredentialManager::read_secret read_secret() joined an unrestricted name onto the credentials base directory. Names containing '/', '\\', '..' or empty names are now rejected with a new CredentialError::InvalidName before any filesystem access, so a non-constant name can never escape the credentials directory. Adds unit tests for the guard (in crates/core/src/credentials/tests.rs, per the project's test-layout convention). --- ublue/skillet/crates/core/src/credentials.rs | 16 +++++++ .../crates/core/src/credentials/tests.rs | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 ublue/skillet/crates/core/src/credentials/tests.rs diff --git a/ublue/skillet/crates/core/src/credentials.rs b/ublue/skillet/crates/core/src/credentials.rs index 5002bb4b..e8134610 100644 --- a/ublue/skillet/crates/core/src/credentials.rs +++ b/ublue/skillet/crates/core/src/credentials.rs @@ -6,6 +6,8 @@ use thiserror::Error; pub enum CredentialError { #[error("CREDENTIALS_DIRECTORY environment variable not set")] NoDirectory, + #[error("Invalid secret name {0:?}: must be a plain file name")] + InvalidName(String), #[error("Failed to read secret {0}: {1}")] ReadError(String, std::io::Error), } @@ -22,7 +24,17 @@ impl CredentialManager { Ok(Self { base_path: path }) } + /// Read a secret by name from the credentials directory. + /// + /// The name must be a plain file name: path separators, parent + /// references and absolute paths are rejected so a caller can never + /// escape the credentials directory, even if the name ever stops + /// being a hardcoded constant. pub fn read_secret(&self, name: &str) -> Result { + if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") { + return Err(CredentialError::InvalidName(name.to_string())); + } + let secret_path = self.base_path.join(name); let mut file = std::fs::File::open(&secret_path) @@ -36,3 +48,7 @@ impl CredentialManager { Ok(content) } } + +#[cfg(test)] +#[path = "credentials/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/credentials/tests.rs b/ublue/skillet/crates/core/src/credentials/tests.rs new file mode 100644 index 00000000..a2bdb8ae --- /dev/null +++ b/ublue/skillet/crates/core/src/credentials/tests.rs @@ -0,0 +1,45 @@ +use super::*; +use std::env; + +fn manager_with_secret(name: &str, payload: &str) -> (CredentialManager, tempfile::TempDir) { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join(name), payload).expect("write secret"); + env::set_var("CREDENTIALS_DIRECTORY", dir.path()); + let manager = CredentialManager::new().expect("manager"); + (manager, dir) +} + +#[test] +fn read_secret_happy_path() { + let (manager, _dir) = manager_with_secret("pihole_web_password", "s3cret\n"); + assert_eq!(manager.read_secret("pihole_web_password").unwrap(), "s3cret"); +} + +#[test] +fn read_secret_rejects_traversal() { + let (manager, _dir) = manager_with_secret("pihole_web_password", "s3cret"); + for evil in [ + "../pihole_web_password", + "sub/dir", + "..\\pihole_web_password", + "/etc/passwd", + "..", + "", + ] { + let err = manager.read_secret(evil).unwrap_err(); + assert!( + matches!(err, CredentialError::InvalidName(_)), + "expected InvalidName for {evil:?}, got {err:?}" + ); + } +} + +#[test] +fn read_secret_missing_file_is_read_error() { + let (manager, _dir) = manager_with_secret("pihole_web_password", "s3cret"); + let err = manager.read_secret("does_not_exist").unwrap_err(); + assert!( + matches!(err, CredentialError::ReadError(_, _)), + "expected ReadError, got {err:?}" + ); +} From da7b3c119ed79463b4c686c44bff23b5e9830258 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:56:43 +0000 Subject: [PATCH 060/252] skillet: pass --host explicitly when integration tests use the generic CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit locate_binary() preferred skillet- but silently fell back to the generic 'skillet' binary, whose apply defaults to '(Agent Mode)' — the test would then verify the wrong host's configuration without failing (for beezelbot the ops happen to coincide, masking the bug). locate_binary() now also reports whether the binary it found is host-specific, and the test runner adds '--host ' when invoking the generic CLI. --- ublue/skillet/crates/cli/src/main.rs | 88 ++++++++++++++++++---------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 7b22fc9b..a3d4b05f 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -131,10 +131,10 @@ fn run_container_test(hostname: &str, image: &str, is_record: bool, inspect: boo let binary_path = locate_binary(hostname)?; let container_name = format!("skillet-test-{hostname}"); - setup_container(&container_name, image, &binary_path)?; + setup_container(&container_name, image, &binary_path.0)?; let result = (|| -> Result<()> { - prepare_and_run_skillet(&container_name)?; + prepare_and_run_skillet(&container_name, hostname, binary_path.1)?; verify_or_record(hostname, &container_name, is_record)?; Ok(()) })(); @@ -178,29 +178,51 @@ fn find_workspace_root() -> Result { Ok(metadata.workspace_root.into_std_path_buf()) } -fn locate_binary(hostname: &str) -> Result { +fn locate_binary(hostname: &str) -> Result<(PathBuf, bool)> { let host_binary_name = format!("skillet-{hostname}"); let root = find_workspace_root()?; let musl = "x86_64-unknown-linux-musl"; - let binary_path = [ - root.join(format!("target/{musl}/release")).join(&host_binary_name), - root.join(format!("target/{musl}/debug")).join(&host_binary_name), - root.join("target/release").join(&host_binary_name), - root.join("target/debug").join(&host_binary_name), - root.join(format!("target/{musl}/release")).join("skillet"), - root.join(format!("target/{musl}/debug")).join("skillet"), - root.join("target/release").join("skillet"), - root.join("target/debug").join("skillet"), - ] - .into_iter() - .find(|p| p.exists()) - .ok_or_else(|| { - anyhow!("No suitable skillet binary found under target/ (checked release, debug, and x86_64-unknown-linux-musl subdirs)") - })?; - - info!("Using binary: {}", binary_path.display()); - fs::canonicalize(&binary_path).context("Failed to canonicalize binary path") + let target_dirs = [ + format!("target/{musl}/release"), + format!("target/{musl}/debug"), + "target/release".to_string(), + "target/debug".to_string(), + ]; + + // Prefer the host-specific binary ... + for dir in &target_dirs { + let path = root.join(dir).join(&host_binary_name); + if path.exists() { + info!("Using host-specific binary: {}", path.display()); + return Ok(( + fs::canonicalize(&path).context("Failed to canonicalize binary path")?, + true, + )); + } + } + + // ... but fall back to the generic CLI. The caller must then pass + // --host explicitly: without it the generic CLI applies its + // "(Agent Mode)" default and the test would silently verify the wrong + // host's configuration. + for dir in &target_dirs { + let path = root.join(dir).join("skillet"); + if path.exists() { + info!( + "Using generic binary, will pass --host explicitly: {}", + path.display() + ); + return Ok(( + fs::canonicalize(&path).context("Failed to canonicalize binary path")?, + false, + )); + } + } + + Err(anyhow!( + "No suitable skillet binary found under target/ (checked release, debug, and x86_64-unknown-linux-musl subdirs)" + )) } fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Result<()> { @@ -245,7 +267,7 @@ fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Res Ok(()) } -fn prepare_and_run_skillet(container_name: &str) -> Result<()> { +fn prepare_and_run_skillet(container_name: &str, hostname: &str, host_specific: bool) -> Result<()> { // Prepare entrypoint script let entrypoint_content = include_str!("test_entrypoint.sh"); let mut temp_entrypoint = tempfile::Builder::new().suffix(".sh").tempfile()?; @@ -287,16 +309,20 @@ fn prepare_and_run_skillet(container_name: &str) -> Result<()> { } info!("Executing skillet inside container..."); + // The host-specific binary already knows its host; the generic CLI + // needs --host pinned explicitly, otherwise it falls back to + // "(Agent Mode)" and the test would exercise the wrong configuration. + let mut skillet_args = vec!["apply".to_string()]; + if !host_specific { + skillet_args.push("--host".to_string()); + skillet_args.push(hostname.to_string()); + } + skillet_args.push("--record".to_string()); + skillet_args.push("/tmp/ops.yaml".to_string()); + let exec_status = Command::new("podman") - .args([ - "exec", - container_name, - "/tmp/test_entrypoint.sh", - "skillet", - "apply", - "--record", - "/tmp/ops.yaml", - ]) + .args(["exec", container_name, "/tmp/test_entrypoint.sh", "skillet"]) + .args(&skillet_args) .status() .context("Failed to exec skillet")?; From fde074228aa601d6f1373566d32cf4eab88e4714 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:58:36 +0000 Subject: [PATCH 061/252] skillet: apply cargo fmt to new/changed code (Also fixes a mis-indented '} else {' in ensure_podman_secret that predates this branch.) --- ublue/skillet/crates/cli-common/src/hosts.rs | 6 +++--- ublue/skillet/crates/cli-common/src/lib.rs | 3 ++- ublue/skillet/crates/cli/src/main.rs | 6 +++++- ublue/skillet/crates/core/src/credentials/tests.rs | 5 ++++- ublue/skillet/crates/core/src/system.rs | 4 ++-- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 275b1161..e8e409b5 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -28,7 +28,8 @@ pub enum ApplyError { Pihole(#[from] skillet_pihole::PiholeError), } -mod user_lookup { use users::{get_group_by_name, get_user_by_name}; +mod user_lookup { + use users::{get_group_by_name, get_user_by_name}; /// Look up UID for a username, returns None if user doesn't exist pub fn lookup_uid(username: &str) -> Option { @@ -59,8 +60,7 @@ pub const PIHOLE_WEB_PASSWORD_CREDENTIAL: &str = "pihole_web_password"; /// Custom DNS records for the clamps Pi-hole (`ip -> fqdn`). // TODO: replace with the real LAN IP and domain before the production // cutover; these are still the old placeholder values. -const CLAMPS_CUSTOM_DNS_RECORDS: &[(&str, &str)] = - &[("192.168.1.100", "my.custom.domain")]; +const CLAMPS_CUSTOM_DNS_RECORDS: &[(&str, &str)] = &[("192.168.1.100", "my.custom.domain")]; /// Apply the clamps host configuration (hardening + Pi-hole). /// diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 77833046..0ed3c087 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -1,5 +1,6 @@ use clap::Parser; -use skillet_core::files::{FileResource, LocalFileResource};use skillet_core::recorder::Recorder; +use skillet_core::files::{FileResource, LocalFileResource}; +use skillet_core::recorder::Recorder; use skillet_core::system::{LinuxSystemResource, SystemResource}; use std::fs; use std::path::PathBuf; diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index a3d4b05f..cede2932 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -267,7 +267,11 @@ fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Res Ok(()) } -fn prepare_and_run_skillet(container_name: &str, hostname: &str, host_specific: bool) -> Result<()> { +fn prepare_and_run_skillet( + container_name: &str, + hostname: &str, + host_specific: bool, +) -> Result<()> { // Prepare entrypoint script let entrypoint_content = include_str!("test_entrypoint.sh"); let mut temp_entrypoint = tempfile::Builder::new().suffix(".sh").tempfile()?; diff --git a/ublue/skillet/crates/core/src/credentials/tests.rs b/ublue/skillet/crates/core/src/credentials/tests.rs index a2bdb8ae..4a01aeba 100644 --- a/ublue/skillet/crates/core/src/credentials/tests.rs +++ b/ublue/skillet/crates/core/src/credentials/tests.rs @@ -12,7 +12,10 @@ fn manager_with_secret(name: &str, payload: &str) -> (CredentialManager, tempfil #[test] fn read_secret_happy_path() { let (manager, _dir) = manager_with_secret("pihole_web_password", "s3cret\n"); - assert_eq!(manager.read_secret("pihole_web_password").unwrap(), "s3cret"); + assert_eq!( + manager.read_secret("pihole_web_password").unwrap(), + "s3cret" + ); } #[test] diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index e98366f3..593113e0 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -338,7 +338,7 @@ impl SystemResource for LinuxSystemResource { "Failed to remove old secret {name}" ))); } - } else { + } else { if existing_hash == hash { debug!("Podman secret {name} already exists with correct hash"); return Ok(false); @@ -352,7 +352,7 @@ impl SystemResource for LinuxSystemResource { "Failed to remove old secret {name}" ))); } - } + } } info!("Creating podman secret {name}"); From a8c15a726786b3884c16d116654e63f9bc6db47f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 13:59:39 +0000 Subject: [PATCH 062/252] skillet: fix clippy pedantic warnings under --all-targets - system.rs: make run_systemctl_cli/wait_until_active associated functions (unused_self), backticks around DBus in doc comment (doc_markdown). - cli-common/src/hosts.rs: backticks around CREDENTIALS_DIRECTORY in doc comment; merge the identical 'beezelbot' and wildcard match arms (match_same_arms). - system/tests.rs: drop the 'use super::*' import, which is unused without the test-utils feature; import SystemResource directly under the feature gate instead. --- ublue/skillet/crates/cli-common/src/hosts.rs | 5 +++-- ublue/skillet/crates/core/src/system.rs | 12 ++++++------ ublue/skillet/crates/core/src/system/tests.rs | 3 ++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index e8e409b5..5352c039 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -65,7 +65,7 @@ const CLAMPS_CUSTOM_DNS_RECORDS: &[(&str, &str)] = &[("192.168.1.100", "my.custo /// Apply the clamps host configuration (hardening + Pi-hole). /// /// The credential manager is constructed lazily here: hosts that need no -/// secrets (e.g. beezelbot) never touch CREDENTIALS_DIRECTORY. +/// secrets (e.g. beezelbot) never touch `CREDENTIALS_DIRECTORY`. // pihole uid/gid lookups are intentionally parallel #[allow(clippy::similar_names)] pub fn apply_clamps( @@ -130,8 +130,9 @@ pub fn apply_host( files: &dyn FileResource, ) -> Result<(), ApplyError> { match hostname { - "beezelbot" => apply_beezelbot(system, files), "clamps" => apply_clamps(system, files), + // beezelbot and unknown hostnames fall back to the hardening-only + // baseline, matching the previous "(Agent Mode)" default behaviour. _ => apply_beezelbot(system, files), } } diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 593113e0..dc2fa3e6 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -93,7 +93,7 @@ impl LinuxSystemResource { Self { conn } } - /// Run a systemctl action via DBus (preferred) or the CLI (fallback). + /// Run a systemctl action via `DBus` (preferred) or the CLI (fallback). /// /// Actions are blocking: the CLI path no longer passes `--no-block`, /// and `start`/`restart` additionally wait until the unit reports @@ -120,24 +120,24 @@ impl LinuxSystemResource { Ok(_) => {} Err(e) => { warn!("DBus call failed, falling back to CLI: {e}"); - self.run_systemctl_cli(action, &name_with_suffix)?; + Self::run_systemctl_cli(action, &name_with_suffix)?; } } } else { - self.run_systemctl_cli(action, &name_with_suffix)?; + Self::run_systemctl_cli(action, &name_with_suffix)?; } // DBus only queues the job, so verify the end state for the // actions that are supposed to leave the unit running. if matches!(action, "start" | "restart") { - self.wait_until_active(&name_with_suffix)?; + Self::wait_until_active(&name_with_suffix)?; } Ok(()) } /// Blocking `systemctl ` via the CLI. - fn run_systemctl_cli(&self, action: &str, name_with_suffix: &str) -> Result<(), SystemError> { + fn run_systemctl_cli(action: &str, name_with_suffix: &str) -> Result<(), SystemError> { info!("Running systemctl {action} {name_with_suffix} via CLI"); let output = Command::new("systemctl") .arg(action) @@ -154,7 +154,7 @@ impl LinuxSystemResource { } /// Poll `systemctl is-active` until the unit is active or we time out. - fn wait_until_active(&self, name_with_suffix: &str) -> Result<(), SystemError> { + fn wait_until_active(name_with_suffix: &str) -> Result<(), SystemError> { const TIMEOUT: Duration = Duration::from_secs(60); const POLL_INTERVAL: Duration = Duration::from_millis(500); diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 389035c8..1dd4c6b8 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -1,4 +1,5 @@ -use super::*; +#[cfg(feature = "test-utils")] +use super::SystemResource; #[cfg(feature = "test-utils")] use crate::test_utils::MockSystem; From e10d265fce08673e034b37f249ed1aeada1778a7 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 22 Sep 2026 16:26:43 +0200 Subject: [PATCH 063/252] systemd: use systemctl --wait instead of polling is-active Replace wait_until_active()'s 60s is-active polling loop with systemctl start --wait / systemctl restart --wait. The exit status now reflects the start job's result directly, which is oneshot-safe: a successfully exited oneshot unit (e.g. systemd-sysctl) no longer looks like a failure. start/restart now always go through the CLI: the D-Bus StartUnit call only queues the job, so it cannot observe the job result. stop/reload keep the existing D-Bus-first behavior. --- .../skillet/crates/cli/src/test_entrypoint.sh | 15 ++--- ublue/skillet/crates/core/src/system.rs | 65 +++++++------------ 2 files changed, 26 insertions(+), 54 deletions(-) diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh index 3fc58d60..06cf6741 100755 --- a/ublue/skillet/crates/cli/src/test_entrypoint.sh +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -9,17 +9,10 @@ if [ ! -x /usr/bin/systemctl ]; then echo "Mocking systemctl..." cat < /usr/bin/systemctl #!/bin/sh -case "\$*" in - "is-active"*) - # Pretend every unit ends up active so skillet's post-start - # verification passes. - exit 0 - ;; - *) - echo "Mock systemctl: \$@" - exit 0 - ;; -esac +# The default branch exits 0 for every invocation, which is what skillet +# expects from e.g. `systemctl start --wait` (exit status = job result). +echo "Mock systemctl: \$@" +exit 0 EOF chmod +x /usr/bin/systemctl fi diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index dc2fa3e6..f3134708 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -1,7 +1,6 @@ use sha2::{Digest, Sha256}; use std::process::{Command, Stdio}; use std::sync::LazyLock; -use std::time::{Duration, Instant}; use thiserror::Error; use tracing::{debug, info, warn}; use users::{get_group_by_name, get_user_by_name}; @@ -95,12 +94,22 @@ impl LinuxSystemResource { /// Run a systemctl action via `DBus` (preferred) or the CLI (fallback). /// - /// Actions are blocking: the CLI path no longer passes `--no-block`, - /// and `start`/`restart` additionally wait until the unit reports - /// active, so a failed service can no longer look successful. + /// Actions are blocking: the CLI path never passes `--no-block`, and + /// `start`/`restart` additionally pass `--wait` so the call blocks until + /// the job completes. The exit status then reflects the job result, so a + /// failed service can no longer look successful. This is oneshot-safe: + /// unlike polling `is-active`, it does not mistake a successfully exited + /// oneshot unit for a failure. fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { let name_with_suffix = ensure_systemd_suffix(name); + // D-Bus `StartUnit`/`RestartUnit` only queue the job, so they cannot + // observe its result. Route start/restart through the CLI with + // `--wait` instead. + if matches!(action, "start" | "restart") { + return Self::run_systemctl_cli(action, &name_with_suffix); + } + if let Some(conn) = &self.conn { info!("Running systemctl {action} {name_with_suffix} via DBus"); let proxy = SystemdManagerProxyBlocking::new(conn)?; @@ -127,22 +136,21 @@ impl LinuxSystemResource { Self::run_systemctl_cli(action, &name_with_suffix)?; } - // DBus only queues the job, so verify the end state for the - // actions that are supposed to leave the unit running. - if matches!(action, "start" | "restart") { - Self::wait_until_active(&name_with_suffix)?; - } - Ok(()) } /// Blocking `systemctl ` via the CLI. + /// + /// `start`/`restart` pass `--wait`: the call blocks until the job + /// completes and the exit status reflects the job result. fn run_systemctl_cli(action: &str, name_with_suffix: &str) -> Result<(), SystemError> { info!("Running systemctl {action} {name_with_suffix} via CLI"); - let output = Command::new("systemctl") - .arg(action) - .arg(name_with_suffix) - .output()?; + let mut cmd = Command::new("systemctl"); + cmd.arg(action); + if matches!(action, "start" | "restart") { + cmd.arg("--wait"); + } + let output = cmd.arg(name_with_suffix).output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -153,35 +161,6 @@ impl LinuxSystemResource { Ok(()) } - /// Poll `systemctl is-active` until the unit is active or we time out. - fn wait_until_active(name_with_suffix: &str) -> Result<(), SystemError> { - const TIMEOUT: Duration = Duration::from_secs(60); - const POLL_INTERVAL: Duration = Duration::from_millis(500); - - let deadline = Instant::now() + TIMEOUT; - loop { - let status = Command::new("systemctl") - .arg("is-active") - .arg("--quiet") - .arg(name_with_suffix) - .status()?; - - if status.success() { - debug!("Service {name_with_suffix} is active"); - return Ok(()); - } - - if Instant::now() >= deadline { - return Err(SystemError::Command(format!( - "Service {name_with_suffix} did not become active within {}s of start/restart", - TIMEOUT.as_secs() - ))); - } - - std::thread::sleep(POLL_INTERVAL); - } - } - fn daemon_reload(&self) -> Result<(), SystemError> { if let Some(conn) = &self.conn { info!("Running systemctl daemon-reload via DBus"); From aaeb78ec82f1ae0943186b6cba815bb91aaaa0db Mon Sep 17 00:00:00 2001 From: zuckkybot Date: Tue, 22 Sep 2026 18:25:57 +0200 Subject: [PATCH 064/252] skillet: fix shellcheck SC2006 in test_entrypoint.sh mock comment Replace legacy backticks with single quotes in the mock systemctl comment; CI shell-lint (shellcheck -x) flags backticks even in comments. --- ublue/skillet/crates/cli/src/test_entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh index 06cf6741..ea7022f3 100755 --- a/ublue/skillet/crates/cli/src/test_entrypoint.sh +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -10,7 +10,7 @@ if [ ! -x /usr/bin/systemctl ]; then cat < /usr/bin/systemctl #!/bin/sh # The default branch exits 0 for every invocation, which is what skillet -# expects from e.g. `systemctl start --wait` (exit status = job result). +# expects from e.g. 'systemctl start --wait' (exit status = job result). echo "Mock systemctl: \$@" exit 0 EOF From e4c5b7ecf1c701297d67e0d2b5b0400492435771 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 23 Sep 2026 00:15:53 +0200 Subject: [PATCH 065/252] Use virt-install for CoreOS VMs --- ublue/butane/README.md | 7 +++++-- ublue/butane/bin/coreos-install | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 44df66ee..337a77c8 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -24,5 +24,8 @@ See `-h` for more ``` this will use the above script to transpile the file, then download a coreos -image and finally boot a KVM machine executing the ignition file. -It supports being run inside distrobox and will execute KVM on the host. +image and finally import it into a libvirt VM using `virt-install`, with the +Ignition file attached at boot. The VM uses unprivileged user networking, so it +does not require a libvirt bridge network. The VM has a SPICE display and a +serial console; open it in virt-manager to view the console. It supports being +run inside distrobox and will execute host commands through `run_on_host`. diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 142d92a3..d0ae38f3 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -53,8 +53,20 @@ VM_IMAGE_PATH="${IMAGES_DIR}/$VM_IMAGE" rm -f "$VM_IMAGE_PATH" cp -v "$IMAGE_PATH" "$VM_IMAGE_PATH" +NETWORK_ARGS=(--network user,model=virtio) +echo >&2 "Using unprivileged user networking" + set -x -run_on_host qemu-kvm -m 8192 -cpu host \ - -drive "if=virtio,format=qcow2,file=$(realpath "$VM_IMAGE_PATH")" \ - -fw_cfg "name=opt/com.coreos/config,file=${IGNITION_CONFIG}" \ - -nic "user,model=virtio,hostfwd=tcp::2222-:22" +"$SCRIPT_DIR/virt-install" \ + --name "$VM_NAME" \ + --osinfo linux2024 \ + --memory 8192 \ + --vcpus 2 \ + --import \ + --disk "path=$(realpath "$VM_IMAGE_PATH"),format=qcow2,bus=virtio" \ + "${NETWORK_ARGS[@]}" \ + --graphics spice \ + --video virtio \ + --console pty,target_type=serial \ + --noautoconsole \ + --qemu-commandline="-fw_cfg name=opt/com.coreos/config,file=${IGNITION_CONFIG}" From 1fa4fd481bf1ea15ee898ac07903db11436f82c1 Mon Sep 17 00:00:00 2001 From: zuckkybot Date: Fri, 25 Sep 2026 14:18:45 +0200 Subject: [PATCH 066/252] clamps: autorebase to ghcr.io/gbagnoli/ucore-clamps on first boot New includes/ucore-clamps.bu rebases to our BlueBuild image (unsigned first to install the cosign policy, then signed), following the same pattern as includes/ucore.bu. Merged into clamps.bu. --- ublue/butane/clamps.bu | 1 + ublue/butane/includes/ucore-clamps.bu | 50 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 ublue/butane/includes/ucore-clamps.bu diff --git a/ublue/butane/clamps.bu b/ublue/butane/clamps.bu index e9bc4acc..bac3b068 100644 --- a/ublue/butane/clamps.bu +++ b/ublue/butane/clamps.bu @@ -7,6 +7,7 @@ ignition: - local: includes/zram.bu - local: includes/brew.bu - local: includes/skillet.bu + - local: includes/ucore-clamps.bu storage: disks: - # The link to the block device the OS was booted from. diff --git a/ublue/butane/includes/ucore-clamps.bu b/ublue/butane/includes/ucore-clamps.bu new file mode 100644 index 00000000..be978144 --- /dev/null +++ b/ublue/butane/includes/ucore-clamps.bu @@ -0,0 +1,50 @@ +# Autorebase clamps onto our BlueBuild image on first boot: +# ghcr.io/gbagnoli/ucore-clamps:latest +# Same two-step dance as includes/ucore.bu: first the unsigned OCI (which +# installs the cosign signing policy from the image itself), then the signed +# OCI. Reboots between steps; marker files in /etc/ucore-clamps-autorebase +# survive the rebases because /etc persists across deployments. +variant: fcos +version: 1.6.0 +storage: + directories: + - path: /etc/ucore-clamps-autorebase + mode: 0754 +systemd: + units: + - name: ucore-clamps-unsigned-autorebase.service + enabled: true + contents: | + [Unit] + Description=Autorebase clamps to unsigned ucore-clamps OCI and reboot + ConditionPathExists=!/etc/ucore-clamps-autorebase/unverified + ConditionPathExists=!/etc/ucore-clamps-autorebase/signed + After=network-online.target + Wants=network-online.target + [Service] + Type=oneshot + StandardOutput=journal+console + ExecStart=/usr/bin/rpm-ostree rebase --bypass-driver ostree-unverified-registry:ghcr.io/gbagnoli/ucore-clamps:latest + ExecStart=/usr/bin/touch /etc/ucore-clamps-autorebase/unverified + ExecStart=/usr/bin/systemctl disable ucore-clamps-unsigned-autorebase.service + ExecStart=/usr/bin/systemctl reboot + [Install] + WantedBy=multi-user.target + - name: ucore-clamps-signed-autorebase.service + enabled: true + contents: | + [Unit] + Description=Autorebase clamps to signed ucore-clamps OCI and reboot + ConditionPathExists=/etc/ucore-clamps-autorebase/unverified + ConditionPathExists=!/etc/ucore-clamps-autorebase/signed + After=network-online.target + Wants=network-online.target + [Service] + Type=oneshot + StandardOutput=journal+console + ExecStart=/usr/bin/rpm-ostree rebase --bypass-driver ostree-image-signed:docker://ghcr.io/gbagnoli/ucore-clamps:latest + ExecStart=/usr/bin/touch /etc/ucore-clamps-autorebase/signed + ExecStart=/usr/bin/systemctl disable ucore-clamps-signed-autorebase.service + ExecStart=/usr/bin/systemctl reboot + [Install] + WantedBy=multi-user.target From 6ad2c0ecd176213cabc0e23600205da78c07e62d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 12:18:04 +0200 Subject: [PATCH 067/252] clamps: add baseline convergence and VM bootstrap checks --- ublue/CLAMPS-AGENT-1.md | 57 +++ ublue/CLAMPS-AGENT-2.md | 64 +++ ublue/CLAMPS-MIGRATION.md | 125 ++++++ ublue/CLAMPS-STORAGE-CREDENTIALS.md | 78 ++++ ublue/butane/.gitignore | 4 + ublue/butane/ACCEPTANCE.md | 68 ++++ ublue/butane/README.md | 91 ++++- ublue/butane/bin/butane | 144 +++---- ublue/butane/bin/clamps-ready | 67 ++++ ublue/butane/bin/clamps-vm | 112 ++++++ ublue/butane/bin/coreos-install | 178 ++++++--- ublue/butane/clamps.bu | 3 + ublue/butane/includes/resolver.bu | 17 + ublue/butane/includes/skillet.bu | 23 +- ublue/butane/includes/ucore-clamps.bu | 91 +++-- ublue/butane/tests/bootstrap.sh | 80 ++++ ublue/skillet/AGENTS.md | 11 +- ublue/skillet/Cargo.lock | 84 +--- ublue/skillet/README.md | 41 +- ublue/skillet/crates/cli-common/Cargo.toml | 3 + ublue/skillet/crates/cli-common/src/hosts.rs | 39 +- .../crates/cli-common/src/hosts/fixture.rs | 116 ++++++ .../cli-common/src/hosts/fixture/tests.rs | 37 ++ ublue/skillet/crates/cli-common/src/lib.rs | 9 +- ublue/skillet/crates/cli/Cargo.toml | 11 - ublue/skillet/crates/cli/src/main.rs | 368 +----------------- .../skillet/crates/cli/src/test_entrypoint.sh | 42 -- ublue/skillet/crates/core/src/files.rs | 16 +- ublue/skillet/crates/core/src/recorder.rs | 10 + ublue/skillet/crates/core/src/system.rs | 129 +++--- ublue/skillet/crates/core/src/test_utils.rs | 94 ++++- ublue/skillet/crates/hardening/Cargo.toml | 2 + ublue/skillet/crates/hardening/src/lib.rs | 57 ++- ublue/skillet/crates/hardening/src/tests.rs | 43 +- .../crates/hosts/beezelbot/src/main.rs | 4 +- ublue/skillet/crates/hosts/clamps/src/main.rs | 4 +- ublue/skillet/crates/pihole/src/lib.rs | 21 +- ublue/skillet/crates/podman/Cargo.toml | 5 + ublue/skillet/crates/podman/src/lib.rs | 82 ++-- ublue/skillet/crates/podman/src/tests.rs | 91 +++++ .../recordings/beezelbot.yaml | 32 -- .../integration_tests/recordings/clamps.yaml | 95 ----- .../skillet/integration_tests/smoke-guest.sh | 153 ++++++++ ublue/skillet/integration_tests/smoke-ssh.sh | 76 ++++ 44 files changed, 1905 insertions(+), 972 deletions(-) create mode 100644 ublue/CLAMPS-AGENT-1.md create mode 100644 ublue/CLAMPS-AGENT-2.md create mode 100644 ublue/CLAMPS-MIGRATION.md create mode 100644 ublue/CLAMPS-STORAGE-CREDENTIALS.md create mode 100644 ublue/butane/.gitignore create mode 100644 ublue/butane/ACCEPTANCE.md create mode 100755 ublue/butane/bin/clamps-ready create mode 100755 ublue/butane/bin/clamps-vm create mode 100644 ublue/butane/includes/resolver.bu create mode 100644 ublue/butane/tests/bootstrap.sh create mode 100644 ublue/skillet/crates/cli-common/src/hosts/fixture.rs create mode 100644 ublue/skillet/crates/cli-common/src/hosts/fixture/tests.rs delete mode 100755 ublue/skillet/crates/cli/src/test_entrypoint.sh create mode 100644 ublue/skillet/crates/podman/src/tests.rs delete mode 100644 ublue/skillet/integration_tests/recordings/beezelbot.yaml delete mode 100644 ublue/skillet/integration_tests/recordings/clamps.yaml create mode 100755 ublue/skillet/integration_tests/smoke-guest.sh create mode 100755 ublue/skillet/integration_tests/smoke-ssh.sh diff --git a/ublue/CLAMPS-AGENT-1.md b/ublue/CLAMPS-AGENT-1.md new file mode 100644 index 00000000..b467182d --- /dev/null +++ b/ublue/CLAMPS-AGENT-1.md @@ -0,0 +1,57 @@ +# Agent assignment 1: reliable Skillet convergence + +Draft assignment; writing this file does not launch an agent. Suggested model: `gpt-6-sol`, medium reasoning, fresh context with this assignment and the migration draft. The parent reviews the implementation and evidence. + +## Objective + +Make Skillet's existing resources reliably converge under real systemd and Podman. Deliver a repeatable smoke check for agent 2's disposable uCore VM. Do not add production services or implement the later Pi-hole/storage/TPM migration. + +Read `ublue/CLAMPS-MIGRATION.md`, `ublue/skillet/AGENTS.md`, and the relevant Rust implementations before editing. Resolve applicable instructions from the actual checkout. Preserve unrelated changes. + +## Ownership and interface + +- Own `ublue/skillet/`, including tests, documentation, and its integration-test instructions. Coordinate any edit outside that directory with the parent. +- Agent 2 owns `ublue/butane/`. Do not change its launcher or bootstrap units. +- Preserve normal full-apply behavior. Add an explicit `apply --phase base` option to both the generic and clamps binaries. The base phase applies only the host baseline and must not read application credentials or start Pi-hole. Default `apply` continues to mean the full configured host; missing required credentials must remain an error there. +- Both entry points must call the same baseline implementation. Keep this a small explicit phase selection; no general dependency scheduler or plugin framework. +- Produce the static `skillet-clamps` artifact and document its exact build command, path and SHA256. Agent 2 supplies it to the guest and invokes `skillet-clamps apply --phase base`. +- Provide a documented smoke command against an explicit SSH target. It must fail closed without a disposable-target opt-in and never default to the workstation or production clamps. Agent 2 owns VM creation; this check owns guest assertions. + +## Implementation plan + +1. Fix service start/restart calls to wait for startup completion and report failures. Remove the blanket `--wait` use, which waits for service termination. Bound smoke-test waits and retain failure output. +2. Correct generated Quadlet lifecycle: use `[Install]` plus generator reload for persistence, not `systemctl enable` on generated units. Ensure inactive desired services start even when files are unchanged. Preserve ordinary unit enablement where appropriate. +3. Track and recover unfinished convergence. A configuration write followed by failed reload/restart must be retried on the next apply. Avoid a change flag that disappears across runs before the service consumes the change. +4. Propagate changes to consumed secrets into container recreation. Recover from interrupted secret updates; a failed inspect must not automatically be interpreted as 'missing'. Do not expose secret values in logs, arguments, recordings or errors. +5. Fix ownership/mode churn on paths managed by both application and container resources. Add only the primitives needed for these cases. +6. Implement the base-phase interface above. Cover both CLI entry points and retain normal full-apply errors. +7. Replace recording equality as the acceptance oracle. Keep recordings only if diagnostically useful; remove obsolete test commands/docs if scrapping them. Update `AGENTS.md` explicitly to replace the old container-recording requirement with unit checks plus the real-runtime scenario. + +## Smoke scenario and exit criteria + +Use a harmless fixture container configured through the same Rust resources used by hosts. Do not start the partial production Pi-hole implementation for this milestone. Use dummy secrets and a persistent test volume. Mock systemctl/Podman is forbidden for runtime acceptance; unit tests may use mocks. + +| Case | Required observation | +| --- | --- | +| First apply | Returns within a documented deadline; service is running; fixture reports expected configuration; volume is usable | +| Repeat apply | Managed bytes, permissions and ownership are unchanged; container identity/start timestamp is unchanged | +| Stopped service | Apply starts it despite unchanged configuration | +| Configuration change | Running fixture consumes the new configuration; subsequent apply causes no further recreation | +| Secret rotation | Running fixture consumes the replacement dummy secret; verification reports pass/fail without printing it | +| Startup failure | Apply returns nonzero and useful diagnostics; fixing the cause and reapplying identical desired configuration succeeds | +| Interrupted convergence | Inject failure after writing desired state but before successful activation; a new apply completes pending work | +| Reboot | Fixture starts automatically; persistent test data remains; another apply succeeds without unnecessary restart | +| Base/full separation | Base succeeds without credentials; full clamps apply still rejects missing required credentials | + +Capture hashes, ownership/modes, systemd result and container identity before/after. An unchanged operation count is not enough. Check content and runtime state. Fixture namespaces, files and services must be distinctive and removable without touching other guest workloads. + +Run formatting, workspace tests, and pedantic Clippy with warnings denied, using the workspace's musl configuration. Tests belong in separate modules. Preserve `thiserror` in libraries, no `unsafe`, no library `unwrap`/`expect`, and crate-first system interactions. + +## Handoff and completion + +Two statuses are deliberately distinct: + +- **Ready for VM integration:** implementation, focused unit tests, lint/build checks, artifact and smoke command are ready. Include any runtime checks already performed. Agent 2 may start from this handoff. +- **Accepted:** all smoke cases have passed with real systemd/Podman, including on agent 2's final uCore VM, and the parent has reviewed the changes. If a guest is unavailable, report pending runtime validation; do not claim acceptance. + +Return changed files, interface/build commands, artifact hash, test results, evidence paths, and unresolved issues. Agent 2 reports Skillet defects to this agent/parent instead of editing owned files concurrently. No nested delegation, production changes, publishing or physical disk operations. diff --git a/ublue/CLAMPS-AGENT-2.md b/ublue/CLAMPS-AGENT-2.md new file mode 100644 index 00000000..81160731 --- /dev/null +++ b/ublue/CLAMPS-AGENT-2.md @@ -0,0 +1,64 @@ +# Agent assignment 2: reproducible clamps VM boot + +Draft assignment; writing this file does not launch an agent. Suggested model: `gpt-6-sol`, medium reasoning, fresh context with this assignment and the migration draft. Start implementation after agent 1 provides the base-phase interface and artifact. The parent reviews changes and acceptance evidence. + +## Objective + +From `ublue/butane/`, one documented invocation of `./bin/coreos-install clamps.bu` provisions a disposable VM which reaches the signed uCore clamps deployment and automatically completes the Skillet base phase. The agent may use virsh, SSH, serial logs, guest commands, reboot, and repeated Skillet applies to diagnose and validate it. + +Final acceptance must come from a fresh install with fixes encoded in the source. Manual guest repairs alone do not count. This assignment proves VM boot, not physical hardware/Secure Boot, TPM credentials, Pi-hole parity, production storage or cutover. + +## Ownership and preflight + +- Own `ublue/butane/` and its documentation/tests. Shared `ublue/lib/` changes require parent coordination. Agent 1 owns `ublue/skillet/`; request Rust fixes through that agent/parent. +- Read the migration draft, launcher, Butane wrapper, all clamps includes, host-execution helpers, and agent 1's handoff. +- Existing scripts use a Flatpak virt-install wrapper, user networking without an explicit inbound SSH path, a domain named `clamps`, and unconditional deletion of `images/clamps.img`. Fix collision handling before executing them against existing artifacts. +- Identify the actual libvirt URI and execution environment. Use the same URI for creation, inspection and cleanup; account for host commands when inside distrobox. Check permissions, KVM, disk space, RAM, Podman, Butane and SSH access. Host tools existing on PATH is not proof of working virtualization. +- Use only newly created disposable VM disks. Refuse to overwrite an existing domain or disk unless it is identified as belonging to this test and replacement was explicitly requested. Preserve any pre-existing `clamps` VM/image. Support a unique VM-name/output option while retaining the simple invocation on a clean environment. +- Report permission/environment blocks with the failing command. Use normal escalation when required; do not silently change host networking, security settings, or unrelated VMs. + +## Agreed handoff from agent 1 + +- A static host binary `skillet-clamps` supporting `apply --phase base`, with a build command and SHA256. +- The default full apply is unchanged and requires application credentials where configured. +- A smoke check accepting an explicit disposable SSH target, using real systemd/Podman and dummy fixtures. + +## Implementation plan + +1. Make the launcher safe to repeat. Parse inputs reliably, isolate generated artifacts, name the VM explicitly, and reject collisions before replacing anything. Provide intentional cleanup/recreate for test-owned resources. Show the exact VM identity and output paths. +2. Establish unattended SSH access. Prefer loopback-bound forwarding on the existing user-network setup if supported; otherwise use a documented isolated libvirt network. Use test-specific SSH configuration and known_hosts. Permit existing authorized credentials or generate an ignored ephemeral test key; embed only its public key. Avoid interactive password-change prompts in test access. +3. Keep `/dev/vda` specific to the VM layout. Separate or clearly guard physical-install disk selection. Do not choose the final `/srv` design here. +4. Build/stage the supplied Skillet artifact automatically as part of provisioning, or accept a documented artifact override. For this milestone, prefer an Ignition-delivered artifact under `/var/lib/skillet/` with executable permissions and a persistent unit invoking it. Verify execution with SELinux enforcing. Do not rely on modifying immutable `/usr`, publishing a new image, or manually copying the binary after first boot. +5. Provision bootstrap DNS before the first rebase into the resolved-masked image. Use a single owner for resolver state, obtain suitable upstreams from the test network, and avoid dependence on the future Pi-hole service or a nonexistent resolved runtime file. Confirm NetworkManager behavior on the actual guest. Do not hardcode production LAN values as a convenience. +6. Make rebase stages recoverable and sequenced. Distinguish requested/pending deployments from the actually booted deployment using rpm-ostree state. Verify the expected signed origin and signature policy, not just marker files or an image name. Prevent Skillet starting before the intended booted deployment. Bound failure/retry behavior so a failed rebase cannot create a reboot loop. +7. Configure `skillet-apply.service` to invoke the explicit base phase without application credentials. Arrange reruns/recovery after failure. Keep all required baseline files, access and binary delivery valid across both rebase boots. +8. Add an automated readiness/verification command using virsh plus SSH/serial diagnostics. Poll with deadlines; report the failing stage and journals. A successful virt-install exit or SSH response alone is not readiness. + +The local image checkout is at `759f195` as of drafting. Preserve its common-before-host build dependency. If the published image cannot support the task, report the exact mismatch and proposed image change to the parent; do not silently publish or weaken verification. + +## Exit criteria + +| Case | Evidence required | +| --- | --- | +| Clean install | The documented launcher invocation completes provisioning from a fresh VM disk with no manual guest repair; exact input revisions and artifact SHA recorded | +| Access | virsh identifies the expected domain and disk; noninteractive SSH and needed sudo work using the documented connection method | +| Rebase sequence | Boot IDs and deployment/journal evidence show the intended transitions and the final booted signed clamps deployment; no pending bootstrap transition or repeating rebase | +| Resolver continuity | External resolution and registry access work at the stages that need them and after final reboot; resolved remains masked on final uCore | +| Artifact delivery | Guest binary SHA matches the staged artifact and it executes under enforcing SELinux after rebases | +| Automatic apply | Skillet unit finishes successfully on the intended deployment without application credentials or an SSH-triggered first apply | +| Repeat apply | Two further base applies succeed without unnecessary file changes or service restarts; use actual state evidence | +| Reboot | Another ordinary reboot retains SSH, DNS, binary and successful baseline state; bootstrap does not restart the rebase cycle | +| Interruption | Interrupt a disposable bootstrap at a recorded stage; the subsequent boot recovers to the expected final state without hand-editing markers | +| Repeatability | A second fresh VM creation from the final source passes the same checks; existing unrelated VM/disk collision is refused safely | +| Real Skillet scenario | Run agent 1's fixture checks on final uCore, including repeat apply, failure recovery, secret rotation and reboot | + +Run strict Butane validation, shell syntax checks and ShellCheck for changed scripts when available. Rerun the end-to-end installation after the last bootstrap-affecting edit. Capture unexpected failed units and resolve those caused by the change; disclose unrelated failures. + +## Deliverables + +- Source changes and concise instructions: clean provision, connection details, readiness check, base apply, log collection and explicit test cleanup. +- A result for every exit criterion: pass, fail, or not run, with reproducible commands and local evidence paths. Include actual image digests, tool versions, guest boot IDs and final deployment status. +- Diagnostics for failed runs without credential material. Track generated keys, Ignition, VM disks and logs outside committed source. +- Leave the successful VM inspectable and report its domain/URI/connection command. Clean up only test-owned resources when explicitly requested or according to documented test lifecycle. + +Return to the parent for review when criteria pass. Report environment or unavailable-image blockers accurately; never label an unexecuted acceptance check as passed. No nested delegation, production changes, physical disk operations or image publishing. diff --git a/ublue/CLAMPS-MIGRATION.md b/ublue/CLAMPS-MIGRATION.md new file mode 100644 index 00000000..df1b6f3b --- /dev/null +++ b/ublue/CLAMPS-MIGRATION.md @@ -0,0 +1,125 @@ +# Clamps migration draft + +Status: implementation checkpoint, 2026-09-26. Milestones 1 and 2 were delegated and implemented; final VM acceptance remains outstanding. Later milestones remain a planning draft. + +Detailed delegation drafts: [agent 1 — Skillet convergence](CLAMPS-AGENT-1.md) and [agent 2 — reproducible VM boot](CLAMPS-AGENT-2.md). Agent 1 hands off a tested base-phase interface and smoke check; agent 2 supplies final uCore runtime evidence. Both require parent review before acceptance. + +## Goal and starting point + +Provision clamps with uCore + Skillet and migrate the required behavior and persistent state of the Chef-managed rupik. Complete one milestone at a time; add reusable Skillet resources when a service needs them. + +Repositories refreshed with `git pull --ff-only`: + +- devmachine, branch `skillet`, commit `1fa4fd4`: already current. +- ucore-images, branch `main`, commit `759f195`: now includes serialized common/host builds, `wol` and `et`, and removal of the `/usr/local/bin/wol` wrapper. CI status and hardware operation were not checked. + +Skillet currently has hardening, container/secret primitives, and partial Pi-hole configuration. Its broader OS-hardening function is a placeholder. Butane defines installation and autorebase services, but binary delivery, credential provisioning, resolver continuity, and boot sequencing are incomplete. + +The starting-point description above refers to the pre-migration commits. The current checkpoint adds explicit base-phase apply, service activation recovery, a real-runtime smoke runner, staged binary delivery, resolver configuration and deployment-aware bootstrap. Static/unit validation does not establish successful installation: see [VM acceptance evidence](butane/ACCEPTANCE.md) for the remaining runtime checks. + +The next acceptance gate is a fresh disposable VM reaching the signed deployment and completing base apply, followed by the smoke runner and reboot/repeatability checks. Resolve that before implementing storage. The selected storage direction is boxy's single Btrfs filesystem with separate OS/data subvolumes; the mount path and final physical disk remain open in the [milestone 3 discussion](CLAMPS-STORAGE-CREDENTIALS.md). + +## Boundaries + +- Image: packages, static OS files, baseline unit enablement/masking. Preserve common-before-host image builds. +- Ignition: installation layout, initial access, bootstrap configuration and credential delivery. +- Skillet: continuing convergence of host values, application configuration, secrets, storage resources and containers. +- Public repositories and images must exclude secret values, including secrets compiled into host binaries. Decide which topology values also require private delivery. +- Keep the production Samsung SSD and functioning DNS on rupik until cutover. Test on the stock clamps SSD or disposable VM disks. +- Leave desktop `ublue/tailscale/` and `ublue/llama/` outside this work. +- Preserve Rust safety/error-handling rules and idempotency. Replace the blanket `systemctl --wait` rule with waiting for startup job completion: `--wait` waits for service termination and can hang on persistent services. + +## Milestone 1: make Skillet convergence reliable + +Scope: repair the existing execution path before adding another application. + +- Correct service start/restart waiting and propagate failures. +- Let Quadlet's generator process `[Install]`; remove explicit enabling of generated services. +- Ensure an unchanged desired service is started if inactive. Restart/recreate it when its configuration or consumed secret changes. +- Handle interrupted applies: a file written before a failed reload/start must not prevent recovery on the next apply. +- Avoid configuration churn, including competing ownership updates to the same directories. +- Add focused unit tests for these failure and convergence cases, using the existing separate test modules. +- Replace the recording-comparison acceptance check with a small disposable VM smoke scenario using real systemd, Podman and a harmless container. Check first apply, second apply, stopped-service recovery, changed configuration, and reboot persistence. +- Keep recording only if useful for diagnostics. Update `skillet/AGENTS.md` and documentation to reflect the replacement; do not maintain a second harness merely to preserve old recordings. + +Done when: apply completes, failures are visible, a second apply causes no unnecessary changes/restarts, and interrupted work can recover. Formatting, workspace tests and pedantic Clippy pass; the real-runtime smoke scenario passes. + +## Milestone 2: boot a reproducible clamps base + +- Separate VM disk selection from physical installation; remove the physical install's dependence on `/dev/vda`. +- Establish SSH access, users/sudo requirements and hardware/Secure Boot preparation before unattended provisioning. +- Supply working bootstrap DNS in Ignition. The image masks resolved, so DNS must work before Skillet can run or pull containers. Decide the resolver owner and upstreams, then have Skillet maintain that policy. +- Deliver a versioned Skillet artifact to a location compatible with ostree and rebases. Decide image delivery versus a persistent runtime location; do not assume an Ignition-written `/usr/bin` binary survives rebases. +- Sequence unsigned rebase, signed rebase and apply using the actually booted deployment. Existing markers record requested rebases, not proof that the target deployment has booted. +- Decouple baseline provisioning from application credentials so the base can converge before Pi-hole is introduced. + +Done when: a fresh VM reaches the signed clamps deployment, retains SSH and DNS through reboots, and runs the baseline apply successfully. Repeat on the stock SSD after hardware preparation. + +## Milestone 3: storage and credentials for the first application + +Resolve only the decisions needed for Pi-hole first. + +- Decide the test and final `/srv` layout, mount identity, and Pi-hole persistence path. Chef currently uses `/srv/pihole`; Skillet currently uses `/etc/pihole`. +- Put formatting/partition creation in the installation path. Skillet must not format existing data during convergence. +- Make applications depend on their real data mount so a missing disk cannot silently produce an empty replacement data directory on the OS filesystem. +- Add directory/subvolume/ownership support as required. Defer container graphroot relocation unless chosen explicitly. +- Implement SOPS/age delivery for one credential end to end. Choose individual credentials versus a bundle, safe serialization, and handling of generated Ignition artifacts. +- Define TPM requirements, encryption timing, recovery and rotation. Make encryption and cleanup recoverable after interruption; verify a usable encrypted credential before deleting the input. +- Decide Podman secret storage protection explicitly. Its default file driver does not inherit systemd credential encryption. + +Done when: Pi-hole's storage and password can be provisioned, recovered after interruption, and rotated without leaking values into repository files, command arguments or logs. Reboot/rebase recovery works. The persistent-data mount failure case is exercised. + +## Milestone 4: working Pi-hole on an isolated test address + +- Add the dual-stack network resource, explicit container attachment and auto-update configuration. +- Decide whether to retain the Chef web pod or publish Pi-hole ports independently. Preserve the eventual nginx integration contract. +- Correct Pi-hole password-file consumption, container identity/permissions, SELinux labels, DNS publication, upstreams, web port and custom-record format for the selected image version. +- Replace placeholder DNS records with supplied configuration. Support multiple names per address. +- Keep host bootstrap DNS independent of Pi-hole availability. Verify IPv4/IPv6 TCP and UDP DNS, web authentication, persistence, rotation, repeated apply and reboot. +- Decide whether Nebula Sync is required and which instance is authoritative before enabling it. + +Done when: clients can use the isolated clamps instance and all required Pi-hole configuration survives restarts. Production DNS remains on rupik. + +## Milestone 5: migrate one remaining service at a time + +Each row is a separate implementation and validation step. Adjust order for dependencies; do not implement all rows as one batch. + +| Order | Capability | Behavior and state to preserve | +| --- | --- | --- | +| 1 | Syncthing | Data, device identity, folder configuration, UID/GID, required ports | +| 2 | btrbk | Real Btrfs subvolumes, hourly snapshots, Chef retention policy, restore exercise; local snapshots are not an independent backup | +| 3 | UniFi | Controller data or supported backup restore, version compatibility, adoption and ownership | +| 4 | Tailscale | Authentication/identity policy, exit-node approval, forwarding, persistent state, DNS policy and hardware interface settings | +| 5 | nginx + OAuth2 proxy + ACME | Domains/routes, access rules, Cloudflare real-IP handling, certificates, renewal and reload | +| 6 | Cloudflare DDNS | Required records and token delivery; reconcile the legacy updater before enabling competing writers | +| 7 | Monitoring and remaining host baseline | Explicit keep/drop decision for Datadog; required hardening, users, SSH/sudo, ET and WOL behavior | + +For each service: inspect effective Chef inputs, settle its open decisions, add only needed Skillet support, migrate a copy of state, validate functionality and repeat convergence. Account for ARM-to-x86 application/image and data compatibility. Avoid activating duplicate production identities or DNS writers during testing. + +Legacy NAT rules and the separate updater live in a recipe that is not included by rupik's default recipe. Confirm whether they are active or still required before porting them. Argon One support is Pi-specific and excluded. + +## Milestone 6: cut over with a recovery path + +- Reconcile the parity checklist against effective production configuration and explicitly record intentional omissions. +- Record the final disk/data transfer approach; take usable backups and perform final synchronization with stateful writers stopped where necessary. +- Transfer chosen addresses, DNS/DHCP settings, names and service identities without running conflicting instances. +- Exercise client DNS, synchronization, UniFi, VPN/exit-node operation, proxy authentication, certificate renewal, DDNS and restores as applicable. +- Verify a reboot, an OS update/rebase and a repeated Skillet apply on the target. +- Retain a documented way to return service to rupik until clamps has passed the agreed observation period. + +Done when: all retained capabilities and data work on clamps, routine convergence and updates work, and the user accepts retirement of rupik's production role. + +## Decisions deferred until needed + +- Base bootstrap: artifact delivery and resolver ownership/upstreams. +- Storage: stock-SSD test layout, final disk arrangement, Pi-hole path, container graphroot. +- Credentials: artifact handling, TPM/recovery policy, Podman storage and rotation. +- Services: actual DNS records, domains/routes, Tailscale enrollment, Nebula Sync, Datadog and legacy networking. +- Cutover: address/identity transitions, outage window and rollback duration. + +## Technical references + +- [systemctl waiting semantics](https://raw.githubusercontent.com/systemd/systemd/main/man/systemctl.xml) +- [Quadlet enablement](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#enabling-unit-files) +- [Podman secret drivers and replacement](https://docs.podman.io/en/latest/markdown/podman-secret-create.1.html) +- [Pi-hole configuration and password files](https://docs.pi-hole.net/docker/configuration/) diff --git a/ublue/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/CLAMPS-STORAGE-CREDENTIALS.md new file mode 100644 index 00000000..4fc2a1e8 --- /dev/null +++ b/ublue/CLAMPS-STORAGE-CREDENTIALS.md @@ -0,0 +1,78 @@ +# Milestone 3 discussion: storage and credentials + +Status: draft, 2026-09-25. Parent-owned planning document while agents implement milestones 1 and 2. User prefers boxy's single Btrfs filesystem with separate OS/data subvolumes. Mount path, final disk and credential policy remain open. + +## Split into two small steps + +3A establishes a persistent application-data mount and proves it behaves correctly. 3B delivers one dummy Pi-hole password end to end and proves recovery and rotation. Pi-hole itself is introduced in milestone 4. + +## 3A: application storage + +Agreed direction: one Btrfs filesystem sharing space between OS and data, with separate subvolumes, following boxy's approach. The particular data mount path is not important to the user. Final disk selection remains open; the Samsung remains in rupik until cutover. + +Read-only SSH inspection of boxy confirmed: + +- `nvme0n1p2`, label `boxy`, supplies both `/os` mounted at `/` and `/data` mounted at `/var/lib/data`. +- System Podman uses driver `btrfs`, graphroot `/var/lib/data/containers/system`, runroot `/run/containers/storage`. +- Giacomo's rootless graphroot is `/var/lib/data/containers/giacomo`, also with driver `btrfs`. +- `sync` and `snapshots` exist under the data root. Listing all subvolumes requires privileges; passwordless sudo was unavailable, so the full subvolume inventory is not verified. +- Current mount flags include `noatime,lazytime,compress-force=zstd:1,discard=async,space_cache=v2,commit=120`. These are observed settings, not automatically selected defaults for clamps. + +User-reported existing fleet differences: calculon mounts a separate Btrfs filesystem at `/var/lib/data`; rupik mounts one at `/srv`. Boxy's layout is the preferred model for the new fleet. + +| Arrangement | Design consequence | +| --- | --- | +| Samsung for both OS and data | Requires a backup/restore or other explicitly designed repartitioning/install procedure; never assume the current data survives an OS installation | +| Stock SSD for both | Size the application data and headroom before committing; migrate data over the network or another agreed transfer route | + +After that choice, establish whether the Samsung's existing filesystem/subvolume structure should be preserved or its data restored into a new layout. Use a read-only inventory of disk identifiers, filesystem types, sizes, subvolumes and used space when access is available. Do not infer these from Chef's `/dev/sda3` default. + +Proposed defaults for discussion: + +- Keep OS/data subvolumes on the shared filesystem while respecting uCore's deployment layout. Do not assume boxy's ordinary `/os` root mount can be copied literally into an ostree installation; prove the installation/rebase mechanism in the VM. +- Proposed canonical data mount: `/var/lib/data`, matching boxy and calculon. An alternative remains `/srv`; settle the path before implementation. +- Place Pi-hole under `/pihole`, preserving its configuration layout during migration rather than requiring its old absolute host path. +- Proposed system Podman graphroot: `/containers/system`, following boxy. Confirm the storage driver and SELinux labeling on uCore before committing it. Configure before the first application container; never switch the driver of an existing populated store implicitly. Add rootless storage only if clamps actually needs it. +- Use stable disk/filesystem identifiers. Ignition handles installation-time formatting only for explicitly selected new/test storage. Skillet converges mounts, directories, permissions and eventual subvolumes without formatting populated disks. +- Make the data subvolume mount a prerequisite for directory creation, container storage use and application startup. A failed data mount should leave SSH available where the OS can still boot, with dependent applications stopped and the failure visible. Loss of the shared physical disk also loses the OS; subvolumes do not provide device independence. +- Model the selected layout on one disposable VM disk. Test a failed/unavailable data subvolume mount rather than removing the shared OS disk. + +Exit criteria for 3A: + +1. Correct data filesystem mounted at the intended resolved path; ownership/modes survive reboot and repeat apply. +2. A sentinel file survives reboot and an OS rebase. +3. An unavailable or wrong data subvolume mount prevents dependent application startup and prevents writes into a fallback OS directory. +4. Restoring the correct subvolume mount permits recovery through normal convergence without formatting or data loss. +5. Repeated apply changes neither mount configuration nor directory metadata unnecessarily. + +Snapshots, Syncthing data transfer and final disk cutover are later milestones. Do not introduce all service subvolumes before their requirements are known. + +## 3B: one credential end to end + +Retain the intended SOPS/age -> bootstrap delivery -> encrypted systemd credential -> Skillet -> Podman -> container sequence, with explicit decisions at its boundaries. + +Decisions to take after storage: + +1. Is a restricted, temporary plaintext-bearing Ignition artifact acceptable during provisioning, or must plaintext never be persisted on the workstation? This changes the transport/build workflow. +2. Must production credentials require the TPM? How will reinstall, TPM failure and recovery work? VM testing must use a documented test policy or vTPM and must not silently weaken the production policy. +3. Is Podman's protected default file store sufficient with the chosen disk protection, or is encrypted secret storage required there too? Systemd credential encryption alone does not protect a second stored copy. + +Proposed implementation size: + +- Start with the existing named `pihole_web_password` credential; defer a general JSON secret bundle unless multiple credentials justify it. +- Use a dummy value in VM acceptance. Add only one encrypted source entry, one safe transport path, one bootstrap encryption service and one consuming fixture. +- Preserve secret bytes according to an explicit input format; do not trim meaningful whitespace implicitly. +- Encrypt to a temporary output, validate it, then atomically install it. Make cleanup/retry safe across interruption at every stage. Deleting a temporary file is cleanup, not a claim of guaranteed physical erasure. +- Keep the encrypted source recoverable independently of the target TPM. Document the age-key backup/recovery process without storing private keys in the repository. +- Rotation must deliver the replacement credential and recreate its consumers. Repeated application of an unchanged credential must not restart them. + +Exit criteria for 3B: + +1. One documented provisioning path delivers the dummy credential automatically; a fixture verifies receipt without printing it. +2. Source control and generated unit/Quadlet text contain no plaintext value; command arguments and captured logs do not expose it. +3. Restarts, reboots and an OS rebase retain credential usability under the chosen policy. +4. Interrupted encryption/delivery/cleanup can be retried without losing the only usable credential. +5. A rotation reaches the running fixture; a subsequent apply causes no further recreation. +6. Missing, corrupt or undecryptable credentials produce a clear failure and follow a documented recovery procedure. They never fall back to an empty/default production password. + +Advance to real Pi-hole only after both steps pass. The shared-filesystem/subvolume direction is selected; its remaining details and credential choices remain pending discussion. diff --git a/ublue/butane/.gitignore b/ublue/butane/.gitignore new file mode 100644 index 00000000..620db420 --- /dev/null +++ b/ublue/butane/.gitignore @@ -0,0 +1,4 @@ +/runs/ +/images/ +/ignition/*.ign +/ignition/includes/ diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md new file mode 100644 index 00000000..cfa5a91c --- /dev/null +++ b/ublue/butane/ACCEPTANCE.md @@ -0,0 +1,68 @@ +# Clamps VM acceptance log + +Status: integration incomplete, reviewed 2026-09-26. The current source has not yet +completed a fresh VM boot. Nothing below is claimed as a passing end-to-end +install. + +## Preflight and failed attempts + +- Native `virsh -c qemu:///session list --all` worked with host execution and + showed no existing domains before provisioning. Host has `passt` and + `virt-install`; a read-only XML preview showed loopback-bound passt forwarding. +- Initial launcher attempt `clamps-agent2-0925-01` stopped before VM creation + on an invalid yq expression; fixed in `bin/butane`. +- `clamps-agent2-0925-02` compiled strict Butane output but QEMU refused to + open the fw_cfg Ignition file in the workspace (`Permission denied`). +- `clamps-agent2-0925-03` produced the same QEMU error with artifacts under + `/tmp`. No domain remained after either failed virt-install. The current + source adds the Ignition file as a read-only libvirt-managed raw attachment; + this change still needs a fresh VM attempt. Per-VM confinement was not + disabled and host security policy was not changed. +- Failed generated artifacts remain in ignored `runs/` and `/tmp` paths for + diagnosis. They contain private test SSH keys and should not be committed. +- A later `runs/clamps-test-0925-05` directory contains generated artifacts + but no recorded domain UUID or acceptance results. This is not evidence of + a successful guest boot. The helper now selects a separate native libvirt + runtime to avoid mixing native and Flatpak daemon filesystem views; that + approach still requires end-to-end validation. + +## Static checks + +- ShellCheck passes for the four launcher/readiness scripts, bootstrap + regression script and both Skillet smoke scripts. +- Full strict Butane compilation passed on 2026-09-26 with the final staged + source and rebuilt host artifact. Local output: + `/tmp/clamps-commit-validation-93upz2et/ignition/clamps.ign`. +- `bash tests/bootstrap.sh` passes seven mocked deployment-state cases: + fresh boot, unsigned boot with signed rollback, signed pending, signed + booted (tag and digest), reboot retry limit, and rebase failure. + These are regression checks, not a replacement for actual VM boots. +- Review corrected duplicate `:latest` matching, rollback selection as a + pending deployment, and reuse of a stale default Skillet build. The default + launcher now rebuilds; `--artifact` remains an explicit override. +- Skillet review passed formatting, 25 workspace tests, configured pedantic + Clippy with warnings denied, and the offline static release build. +- Latest Skillet handoff artifact: `skillet-clamps` SHA256 + `55bf7584ce405dcf2c20956c182f4dbfe04bfbb20a9b649e48ab97877d9e8856`. + No VM has consumed this final artifact yet. + +## Exit criteria + +| Criterion | Result | Evidence / next command | +| --- | --- | --- | +| Clean install | Fail | QEMU fw_cfg EACCES on attempts 02/03; retry with current source | +| Access | Not run | `bin/clamps-ready RUN_DIR` after guest boot | +| Rebase sequence | Not run | Guest `rpm-ostree status --json` and bootstrap journals | +| Resolver continuity | Not run | `bin/clamps-ready` DNS/link checks on each boot | +| Artifact delivery | Not run | Guest SHA versus `skillet.sha256` | +| Automatic apply | Not run | `systemctl status skillet-apply.service` | +| Repeat apply | Not run | Two base applies with state/service timestamps | +| Reboot | Not run | Ordinary VM reboot and repeated readiness checks | +| Interruption | Not run | Interrupt an isolated bootstrap, then recover | +| Repeatability | Not run | Second fresh VM after first passes | +| Real Skillet scenario | Not run | `skillet/integration_tests/smoke-ssh.sh` with VM identity | + +No successful VM is established by the saved evidence. Next, create a new +`clamps-test-*` domain and retain a passing guest for acceptance. The mutable +source-tree helper has not been installed as a frozen, persistently approved +host tool; it still uses normal command approvals. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 337a77c8..447e6f59 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -1,31 +1,84 @@ -# butane wrapper +# Clamps VM bootstrap -This is a simple compiler script that uses butane (running in podman) to -transpile the configurations to ignitions. +Implementation checkpoint: source validation passes; a complete VM boot and +runtime acceptance are still pending. See [ACCEPTANCE.md](ACCEPTANCE.md). -It has minimal support for includes: every `.bu` include will get first -transpiled to ignition, then the original source will be modified to point to -the generated ignition file. An include can *not* include other files this way. +`clamps.bu` is a VM configuration. It resizes and reformats partition 4 on +`/dev/vda`, the virtio disk attached by this launcher. Do not use it as a +physical-disk install file. The host needs native libvirt on `qemu:///session`, +`virt-install`, `passt`, Podman, `yq`, `ssh-keygen`, and KVM. -## running +From this directory, the simple clean install command is: ```bash -./bin/butane -s +./bin/coreos-install clamps.bu ``` -this will generate a ignition file under a directory `ignition` (can be -controlled by passing `-o`. Without `-s` the output will go to STDOUT. -See `-h` for more +It refuses an existing `clamps` domain or `runs/clamps` directory. For +independent disposable runs, use a unique name and loopback SSH port: -## coreos-install +```bash +./bin/coreos-install --name clamps-test-example --ssh-port 2201 clamps.bu +./bin/clamps-ready runs/clamps-test-example +``` + +The launcher builds the current static `skillet-clamps` host binary, or accepts +`--artifact PATH` to use a specific binary. It records its SHA256 in `runs/NAME/skillet.sha256`, copies +the binary into the generated Ignition, and delivers it at +`/var/lib/skillet/skillet-clamps`. Generated Ignition, SSH key, VM disk and logs +stay under `runs/NAME`, which Git ignores. The test key is private and only its +public half enters Ignition. `--image PATH` selects a specific FCOS qcow2; +the default is `images/coreos.qcow2` when present. The VM uses passt with an +inbound forward bound to `127.0.0.1`. + +`bin/clamps-vm` starts a separate native user libvirt daemon with +`XDG_RUNTIME_DIR=/run/user/UID/clamps-native-libvirt`. This keeps the native +daemon in the same filesystem view as generated artifacts when Flatpak +virt-manager has its own session daemon. For manual virsh inspection of a +helper-created VM, set that environment variable on each virsh invocation. + +Connect with: + +```bash +ssh -i runs/NAME/ssh/id_ed25519 -p PORT \ + -o UserKnownHostsFile=runs/NAME/ssh/known_hosts core@127.0.0.1 +``` + +The `core` account has passwordless sudo on FCOS. The guest starts +`clamps-bootstrap.service`, which rebases first to the unsigned clamps image +and then to the signed image. It checks the *booted* rpm-ostree deployment on +every boot. It never starts the base apply until the signed deployment boots. +A failed rebase stops without rebooting; an already pending deployment gets at +most two reboot attempts. The base unit runs +`/var/lib/skillet/skillet-clamps apply --phase base` with no app credentials. + +`./bin/clamps-ready runs/NAME` waits up to 45 minutes, checks the expected +domain/disk, noninteractive SSH and sudo, signed booted deployment, successful +Skillet unit, guest artifact SHA, enforcing SELinux, DNS and masked resolved. +It writes `domain.txt`, `disks.txt`, `final-status.json`, `final-boot-id`, +`guest-skillet.sha256`, and `readiness.log` into the run directory. On timeout, +it writes `failure.log` with the current deployment and relevant journals. +For manual diagnostics: ```bash -./bin/coreos-install +virsh -c qemu:///session dominfo NAME +virsh -c qemu:///session domblklist NAME +ssh -i runs/NAME/ssh/id_ed25519 -p PORT core@127.0.0.1 \ + 'sudo rpm-ostree status; sudo journalctl -b -u clamps-bootstrap.service -u skillet-apply.service' ``` -this will use the above script to transpile the file, then download a coreos -image and finally import it into a libvirt VM using `virt-install`, with the -Ignition file attached at boot. The VM uses unprivileged user networking, so it -does not require a libvirt bridge network. The VM has a SPICE display and a -serial console; open it in virt-manager to view the console. It supports being -run inside distrobox and will execute host commands through `run_on_host`. +`bin/clamps-vm` is a constrained source-tree helper for test domains named +`clamps-test-*` under the ignored `butane/runs` directory. It offers +`create|status|ssh|logs|reboot|destroy`; `destroy` checks the recorded domain +UUID and disk before removing test-owned resources. Its source-tree path is +mutable, so an enduring elevated approval requires reviewed, frozen helper +code and executable dependencies installed outside writable roots. + +The VM and its disk remain available after readiness checks. Cleanup is +explicit: inspect the domain and disk, then call +`./bin/clamps-vm destroy clamps-test-example` for a helper-created run. For a +plain launcher run, shut down and undefine the named test domain with virsh, +then remove only its recorded `runs/NAME` directory after checking `domblklist`. + +Run the local bootstrap state regression checks with `bash tests/bootstrap.sh`. +They exercise mocked deployment states and do not create or modify a VM. diff --git a/ublue/butane/bin/butane b/ublue/butane/bin/butane index 1e49e849..7b2d73c5 100755 --- a/ublue/butane/bin/butane +++ b/ublue/butane/bin/butane @@ -1,108 +1,62 @@ -#!/bin/bash - +#!/usr/bin/env bash set -euo pipefail -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -OUTPUTDIR="$(realpath "$SCRIPT_DIR/../ignition")" -INPUT="" -RAW=false -STDOUT=true - -pushd "$SCRIPT_DIR/.." &>/dev/null - usage() { - echo "$(realpath --relative-to="$SCRIPT_DIR" "$0") [-hrs] [-o OUTPUT_DIR] [config.ign]" - echo - echo "By default, ignition file is pretty printed to stdout" - - echo "Options:" - echo "-h : show this help" - echo "-r : do not prettyfy stdout" - echo "-s : save to file" - echo "-o : output directory for ignition files. Defaults to $OUTPUTDIR" + echo "Usage: $0 [-s] [-o OUTPUT_DIR] CONFIG.bu [CONFIG.ign]" >&2 } -run() { - podman run --interactive --rm --security-opt label=disable \ - --volume "${PWD}:/pwd" --workdir /pwd quay.io/coreos/butane:release \ - --pretty --strict --files-dir "$(basename "$OUTPUTDIR")" "$1" - return $? -} - -while getopts "rsho:" opt; do - case $opt in - o) - OUTPUTDIR="$OPTARG" - ;; - r) - RAW=true - ;; - s) - STDOUT=false - ;; - h) - usage - exit 0 - ;; - \?) - echo "Invalid option: -$OPTARG" >&2 - usage - exit 1 - ;; - :) - echo "Option -$OPTARG requires an argument." >&2 - exit 1 - ;; +save=false +output_dir="$(realpath -m "$(dirname "${BASH_SOURCE[0]}")/../ignition")" +while getopts ':hso:' option; do + case "$option" in + h) usage; exit 0 ;; + s) save=true ;; + o) output_dir="$(realpath -m "$OPTARG")" ;; + *) usage; exit 2 ;; esac done - -if [ $# -lt $OPTIND ]; then - echo "Missing input script" >&2 - usage - exit 1 +shift $((OPTIND - 1)) +[[ $# -ge 1 && $# -le 2 && "$1" == *.bu && -f "$1" ]] || { usage; exit 2; } + +input="$(realpath "$1")" +output_name="${2:-$(basename "${input%.bu}.ign")}" +[[ "$output_name" != */* && "$output_name" == *.ign ]] || { echo "Output must be a .ign filename" >&2; exit 2; } +mkdir -p "$output_dir" +work_dir="$(mktemp -d "$output_dir/.butane.XXXXXXXX")" +trap 'rm -rf -- "$work_dir"' EXIT + +# Local merge sources are compiled first. Their Ignition paths stay relative to +# --files-dir, which is this isolated workspace rather than the source tree. +mapfile -t fragments < <(yq -r '.ignition.config.merge[].local' "$input") +for fragment in "${fragments[@]}"; do + [[ "$fragment" == *.bu && "$fragment" != /* && "$fragment" != *..* ]] || { + echo "Unsupported merge source: $fragment" >&2; exit 2; + } + mkdir -p "$work_dir/$(dirname "$fragment")" + cp "$(dirname "$input")/$fragment" "$work_dir/$fragment" +done +cp "$input" "$work_dir/input.bu" +if [[ -d "$(dirname "$input")/files" ]]; then + cp -a "$(dirname "$input")/files" "$work_dir/files" fi -INPUT="$(eval echo \$$OPTIND)" -OUTPUT="${INPUT/.bu/.ign}" - -if [ $# -gt $OPTIND ]; then - IDX=$(( OPTIND + 1 )) - OUTPUT="$(eval echo \$$IDX)" -fi -OUTPUT="$(realpath "${OUTPUTDIR}/${OUTPUT}")" +run_butane() { + podman run --interactive --rm --security-opt label=disable \ + --volume "$work_dir:/work" --workdir /work quay.io/coreos/butane:release \ + --pretty --strict --files-dir /work "$1" +} -# first. We need to transpile every (sigh) include -for fragment in $(yq '.ignition.config.merge[] | select (.local == "*.bu") | .local' "$INPUT"); do - fragment_name="${fragment%.bu}" - mkdir -p "$(dirname "$OUTPUTDIR/${fragment_name}")" - fragment_out="$(realpath "${OUTPUTDIR}/${fragment_name}.ign")" - echo >&2 "Compiling fragment ${fragment}" - run "${fragment}" > "${fragment_out}" +for fragment in "${fragments[@]}"; do + compiled="${fragment%.bu}.ign" + run_butane "$fragment" > "$work_dir/$compiled" + FRAGMENT="$fragment" COMPILED="$compiled" yq -i \ + '(.ignition.config.merge[] | select(.local == strenv(FRAGMENT)) | .local) = strenv(COMPILED)' \ + "$work_dir/input.bu" done -temp_input=$(mktemp --tmpdir="$OUTPUTDIR" ) -cp "$INPUT" "$temp_input" -# shellcheck disable=SC2046 -sed -i $(yq '.ignition.config.merge[] | select(.local == "*.bu") | .local | "-e s;" + . + ";" + sub(".bu", ".ign") + ";"' "$INPUT" | tr '\n' ' ') "$temp_input" - -trap 'rm $temp_input' EXIT -new_input="ignition/$(basename "$temp_input")" - -if $STDOUT; then - if $RAW; then - run "$new_input" - exit $? - fi - run "$new_input" | jq . - exit $? -fi - -# ok we are not printing to stdot -if [ "$OUTPUT" -ef "$INPUT" ];then - echo >&2 "output $OUTPUT and input $INPUT are the same file" - exit 1 +if "$save"; then + run_butane input.bu > "$output_dir/$output_name" + echo "$output_dir/$output_name" +else + run_butane input.bu fi - -echo "Saving to $OUTPUT" >&2 -run "$new_input" > "$OUTPUT" -exit $? diff --git a/ublue/butane/bin/clamps-ready b/ublue/butane/bin/clamps-ready new file mode 100755 index 00000000..64037359 --- /dev/null +++ b/ublue/butane/bin/clamps-ready @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +if (($# != 1)) || [[ ! -f "$1/run.conf" ]]; then + echo "Usage: $0 RUN_DIR" >&2 + exit 2 +fi +run_dir="$(realpath "$1")" +config_value() { sed -n "s/^$1=//p" "$run_dir/run.conf" | head -n 1; } +vm="$(config_value vm)" +uri="$(config_value uri)" +ssh_port="$(config_value ssh_port)" +runtime_dir="$(config_value runtime_dir)" +vm_virsh() { + if [[ -n "$runtime_dir" ]]; then + env XDG_RUNTIME_DIR="$runtime_dir" virsh -c "$uri" "$@" + else + virsh -c "$uri" "$@" + fi +} +known_hosts="$run_dir/ssh/known_hosts" +ssh_args=(-i "$run_dir/ssh/id_ed25519" -p "$ssh_port" -o BatchMode=yes + -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts") +# shellcheck disable=SC2029 +ssh_guest() { ssh "${ssh_args[@]}" core@127.0.0.1 "$@"; } + +if ! vm_virsh dominfo "$vm" > "$run_dir/domain.txt"; then + echo "Domain $vm unavailable on $uri" >&2 + exit 1 +fi +vm_virsh domblklist "$vm" > "$run_dir/disks.txt" +if ! rg -Fq "$run_dir/$vm.qcow2" "$run_dir/disks.txt"; then + echo "Domain $vm does not use expected disk $run_dir/$vm.qcow2" >&2 + exit 1 +fi + +deadline=$((SECONDS + 2700)) +while ((SECONDS < deadline)); do + if ssh_guest 'sudo -n test -e /run/clamps-bootstrap-ready && sudo -n systemctl is-active --quiet skillet-apply.service' &>/dev/null; then + break + fi + sleep 15 +done +if ((SECONDS >= deadline)); then + echo "Timed out waiting for signed clamps boot and Skillet base apply" >&2 + ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true + echo "Diagnostics: $run_dir/failure.log" >&2 + exit 1 +fi + +ssh_guest 'sudo -n rpm-ostree status --json' > "$run_dir/final-status.json" +ssh_guest 'cat /proc/sys/kernel/random/boot_id' > "$run_dir/final-boot-id" +ssh_guest 'sha256sum /var/lib/skillet/skillet-clamps' > "$run_dir/guest-skillet.sha256" +ssh_guest 'getenforce; readlink -f /etc/resolv.conf; cat /etc/resolv.conf; getent hosts ghcr.io; sudo -n systemctl is-enabled systemd-resolved.service; sudo -n systemctl status --no-pager skillet-apply.service' > "$run_dir/readiness.log" 2>&1 || true +expected="$(cut -d ' ' -f 1 "$run_dir/skillet.sha256")" +actual="$(cut -d ' ' -f 1 "$run_dir/guest-skillet.sha256")" +[[ "$expected" == "$actual" ]] || { echo "Guest Skillet SHA mismatch" >&2; exit 1; } +if ! jq -e \ + '.deployments[] | select(.booted) | .["container-image-reference"] | test("^ostree-image-signed:docker://ghcr\\.io/gbagnoli/ucore-clamps(:latest|@sha256:[0-9a-f]{64})$")' \ + "$run_dir/final-status.json" >/dev/null; then + echo "Booted deployment is not the expected signed clamps origin" >&2 + exit 1 +fi +# The substitutions run on the guest. +# shellcheck disable=SC2016 +ssh_guest 'sudo -n systemctl is-active --quiet skillet-apply.service && test "$(getenforce)" = Enforcing && test "$(readlink -f /etc/resolv.conf)" = /run/NetworkManager/resolv.conf && getent hosts ghcr.io >/dev/null && test "$(systemctl is-enabled systemd-resolved.service)" = masked' +echo "Ready: $vm on $uri; SSH: ssh -i $run_dir/ssh/id_ed25519 -p $ssh_port -o UserKnownHostsFile=$known_hosts core@127.0.0.1" diff --git a/ublue/butane/bin/clamps-vm b/ublue/butane/bin/clamps-vm new file mode 100755 index 00000000..d453dea1 --- /dev/null +++ b/ublue/butane/bin/clamps-vm @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This source-tree helper is reviewable, but must be installed read-only outside +# agent-writable roots before any enduring command-prefix approval is safe. +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +root="$(realpath -m "$script_dir/../runs")" +uri=qemu:///session +runtime_dir="/run/user/$(id -u)/clamps-native-libvirt" +vm_virsh() { env XDG_RUNTIME_DIR="$runtime_dir" /usr/bin/virsh -c "$uri" "$@"; } +ensure_native_runtime() { + [[ ! -L "$runtime_dir" ]] || { echo "Symlinked libvirt runtime refused" >&2; exit 1; } + if [[ ! -d "$runtime_dir" ]]; then + mkdir -m 0700 "$runtime_dir" + fi + if [[ ! -S "$runtime_dir/libvirt/virtqemud-sock" ]]; then + env XDG_RUNTIME_DIR="$runtime_dir" /usr/bin/virtqemud --daemon --timeout 0 + fi + if [[ ! -S "$runtime_dir/libvirt/virtstoraged-sock" ]]; then + env XDG_RUNTIME_DIR="$runtime_dir" /usr/bin/virtstoraged --daemon --timeout 0 + fi +} +usage() { + echo "Usage: $0 create|status|ssh|logs|reboot|destroy clamps-test-NAME [SSH_PORT for create]" >&2 + exit 2 +} +(($# >= 2)) || usage +operation="$1" +name="$2" +shift 2 +[[ "$name" =~ ^clamps-test-[a-z0-9-]+$ ]] || usage +run_dir="$root/$name" +[[ ! -L "$root" && ! -L "$run_dir" ]] || { echo "Symlinked artifact path refused" >&2; exit 1; } + +config_value() { sed -n "s/^$1=//p" "$run_dir/run.conf" | head -n 1; } +inspect_owned() { + [[ -d "$run_dir" && -f "$run_dir/run.conf" && -f "$run_dir/domain.uuid" ]] || { + echo "Missing owned run manifest for $name" >&2; exit 1; + } + [[ "$(realpath -e "$run_dir")" == "$run_dir" ]] || { echo "Artifact path escapes root" >&2; exit 1; } + [[ "$(config_value vm)" == "$name" && "$(config_value uri)" == "$uri" ]] || { + echo "Run manifest does not match requested domain" >&2; exit 1; + } + actual_uuid="$(vm_virsh domuuid "$name")" + [[ "$actual_uuid" == "$(cat "$run_dir/domain.uuid")" ]] || { echo "Domain UUID mismatch" >&2; exit 1; } + disk="$run_dir/$name.qcow2" + [[ -f "$disk" && ! -L "$disk" ]] || { echo "Expected test disk is missing or symlinked" >&2; exit 1; } + ignition="$run_dir/ignition/clamps.ign" + [[ -f "$ignition" && ! -L "$ignition" ]] || { echo "Expected Ignition file is missing or symlinked" >&2; exit 1; } + mapfile -t disks < <(vm_virsh domblklist "$name" --details | awk '$2 == "disk" {print $4}') + [[ "${#disks[@]}" -eq 2 && "${disks[0]}" == "$disk" && "${disks[1]}" == "$ignition" ]] || { + echo "Domain disks do not match test artifacts" >&2; exit 1; + } + port="$(config_value ssh_port)" + [[ "$port" =~ ^22[0-9][0-9]$ ]] || { echo "Invalid recorded port" >&2; exit 1; } +} +ssh_args() { + args=(-i "$run_dir/ssh/id_ed25519" -p "$port" -o BatchMode=yes + -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new + -o UserKnownHostsFile="$run_dir/ssh/known_hosts") +} +case "$operation" in + create) + (($# == 1)) || usage + port="$1" + [[ "$port" =~ ^22[0-9][0-9]$ ]] || { echo "Use a port from 2200 to 2299" >&2; exit 2; } + [[ ! -e "$run_dir" && ! -L "$run_dir" ]] || { echo "Run path already exists" >&2; exit 1; } + ensure_native_runtime + if vm_virsh dominfo "$name" &>/dev/null; then + echo "Domain already exists" >&2; exit 1 + fi + mkdir -p "$root" + CLAMPS_LIBVIRT_RUNTIME_DIR="$runtime_dir" "$script_dir/coreos-install" --name "$name" --output-dir "$run_dir" \ + --ssh-port "$port" --libvirt-uri "$uri" "$script_dir/../clamps.bu" + vm_virsh domuuid "$name" > "$run_dir/domain.uuid" + echo "Run: $run_dir" ;; + status) + (($# == 0)) || usage + ensure_native_runtime + inspect_owned + vm_virsh dominfo "$name" + vm_virsh domblklist "$name" ;; + ssh) + (($# == 0)) || usage + ensure_native_runtime + inspect_owned + ssh_args + exec ssh "${args[@]}" core@127.0.0.1 ;; + logs) + (($# == 0)) || usage + ensure_native_runtime + inspect_owned + ssh_args + ssh "${args[@]}" core@127.0.0.1 \ + 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' ;; + reboot) + (($# == 0)) || usage + ensure_native_runtime + inspect_owned + vm_virsh reboot "$name" ;; + destroy) + (($# == 0)) || usage + ensure_native_runtime + inspect_owned + if [[ "$(vm_virsh domstate "$name")" != 'shut off' ]]; then + vm_virsh destroy "$name" + fi + vm_virsh undefine "$name" + [[ "$(realpath -e "$run_dir")" == "$run_dir" && "$run_dir" == "$root"/clamps-test-* ]] || exit 1 + rm -rf -- "$run_dir" ;; + *) usage ;; +esac diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index d0ae38f3..59dddd2e 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -1,72 +1,128 @@ -#!/bin/bash - +#!/usr/bin/env bash set -euo pipefail -script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" -# shellcheck source-path=SCRIPTDIR -# shellcheck source=../../lib/utils.sh -source "$(realpath "$script_dir/../../lib/utils.sh")" +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +butane_dir="$(realpath "$script_dir/..")" +repo_dir="$(realpath "$butane_dir/..")" -if [ $# -lt 1 ]; then - echo "Missing butane input script" >&2 - exit 1 -fi +usage() { + cat <&2 +Default VM: clamps. Generated keys, Ignition, logs and disk are kept in +butane/runs/NAME. Existing domains and output directories are refused. +USAGE +} + +vm_name=clamps +output_dir= +ssh_port=2222 +artifact= +image= +libvirt_uri=qemu:///session +vm_command() { + if [[ -n "${CLAMPS_LIBVIRT_RUNTIME_DIR:-}" ]]; then + env XDG_RUNTIME_DIR="$CLAMPS_LIBVIRT_RUNTIME_DIR" "$@" + else + "$@" + fi +} +while (($#)); do + case "$1" in + --name|--output-dir|--ssh-port|--artifact|--image|--libvirt-uri) + (($# >= 2)) || { usage >&2; exit 2; } + case "$1" in + --name) vm_name="$2" ;; + --output-dir) output_dir="$2" ;; + --ssh-port) ssh_port="$2" ;; + --artifact) artifact="$2" ;; + --image) image="$2" ;; + --libvirt-uri) libvirt_uri="$2" ;; + esac + shift 2 ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) usage >&2; exit 2 ;; + *) break ;; + esac +done +(($# == 1)) && [[ "$1" == *.bu && -f "$1" ]] || { usage >&2; exit 2; } +input="$(realpath "$1")" +[[ "$vm_name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] || { echo "Invalid VM name" >&2; exit 2; } +if [[ ! "$ssh_port" =~ ^[0-9]+$ ]] || ((ssh_port < 1024 || ssh_port > 65535)); then + echo "Invalid SSH port" >&2 + exit 2 +fi +[[ "$input" == "$butane_dir/clamps.bu" ]] || { echo "This VM launcher currently supports clamps.bu only" >&2; exit 2; } +output_dir="$(realpath -m "${output_dir:-$butane_dir/runs/$vm_name}")" +vm_command virsh -c "$libvirt_uri" uri >/dev/null || { echo "Cannot access libvirt URI $libvirt_uri" >&2; exit 1; } +command -v passt >/dev/null || { echo "passt is required for loopback SSH forwarding" >&2; exit 1; } +if vm_command virsh -c "$libvirt_uri" dominfo "$vm_name" &>/dev/null; then + echo "Refusing existing libvirt domain $vm_name on $libvirt_uri" >&2 exit 1 fi - -if [[ "$BUTANE_CONFIG" != *".bu" ]]; then - echo "Invalid file $BUTANE_CONFIG: should be a butane config" >&2 +if [[ -e "$output_dir" || -L "$output_dir" ]]; then + echo "Refusing existing output path $output_dir" >&2 exit 1 fi - -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -IGNITION_CONFIG_NAME="${BUTANE_CONFIG/.bu/.ign}" -IGNITION_CONFIG="$(realpath "$SCRIPT_DIR/../ignition/$IGNITION_CONFIG_NAME")" -echo >&2 "Creating $IGNITION_CONFIG from $BUTANE_CONFIG" -"$SCRIPT_DIR"/butane -s -o "$SCRIPT_DIR/../ignition" "$BUTANE_CONFIG" "$IGNITION_CONFIG_NAME" - -VM_NAME="${BUTANE_CONFIG/.bu/}" -IMAGES_DIR="$(realpath "$SCRIPT_DIR/../images/")" -mkdir -p "$IMAGES_DIR" -IMAGE_NAME="coreos.qcow2" -IMAGE_PATH="$IMAGES_DIR/$IMAGE_NAME" - -if [[ ! -L "$IMAGE_PATH" ]]; then - set -x - run_on_host podman run --pull=always --privileged --rm -v "${IMAGES_DIR}:/data" -w /data \ - quay.io/coreos/coreos-installer:release download -s stable -p qemu -f "qcow2.xz" --decompress - set +x - for file in "$IMAGES_DIR"/fedora-coreos-*; do - latest=$(basename "$file") - done - - ln -vs "$IMAGES_DIR/$latest" "$IMAGE_PATH" +if [[ -z "$artifact" ]]; then + artifact="$repo_dir/skillet/target/x86_64-unknown-linux-musl/release/skillet-clamps" + (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p skillet-clamps) fi +[[ -f "$artifact" ]] || { echo "Missing Skillet artifact: $artifact" >&2; exit 1; } +artifact="$(realpath "$artifact")" +if [[ -z "$image" ]]; then + image="$butane_dir/images/coreos.qcow2" + if [[ ! -f "$image" ]]; then + mkdir -p "$butane_dir/images" + podman run --pull=always --rm --security-opt label=disable \ + -v "$butane_dir/images:/data" -w /data \ + quay.io/coreos/coreos-installer:release \ + download -s stable -p qemu -f qcow2.xz --decompress + mapfile -t images < <(find "$butane_dir/images" -maxdepth 1 -name 'fedora-coreos-*-qemu.x86_64.qcow2' -type f | sort) + ((${#images[@]} == 1)) || { echo "Specify --image: found ${#images[@]} candidate FCOS images" >&2; exit 1; } + image="${images[0]}" + fi +fi +[[ -f "$image" ]] || { echo "Missing FCOS image: $image" >&2; exit 1; } +image="$(realpath "$image")" +mkdir -p "$(dirname "$output_dir")" +mkdir "$output_dir" +mkdir -p "$output_dir/source/includes" "$output_dir/source/files" "$output_dir/ssh" "$output_dir/ignition" +cp "$input" "$output_dir/source/clamps.bu" +cp "$butane_dir"/includes/*.bu "$output_dir/source/includes/" +cp "$artifact" "$output_dir/source/files/skillet-clamps" +chmod 755 "$output_dir/source/files/skillet-clamps" +ssh-keygen -q -t ed25519 -N '' -f "$output_dir/ssh/id_ed25519" +chmod 600 "$output_dir/ssh/id_ed25519" +public_key="$(cat "$output_dir/ssh/id_ed25519.pub")" +PUBLIC_KEY="$public_key" yq -n \ + '{"variant":"fcos","version":"1.6.0","passwd":{"users":[{"name":"core","ssh_authorized_keys":[strenv(PUBLIC_KEY)]}]}}' \ + > "$output_dir/source/includes/vm-access.bu" +VM_INCLUDE='includes/vm-access.bu' yq -i \ + '.ignition.config.merge += [{"local":strenv(VM_INCLUDE)}]' \ + "$output_dir/source/clamps.bu" -echo >&2 "Creating copy of image file" -VM_IMAGE="${VM_NAME}.img" -VM_IMAGE_PATH="${IMAGES_DIR}/$VM_IMAGE" -rm -f "$VM_IMAGE_PATH" -cp -v "$IMAGE_PATH" "$VM_IMAGE_PATH" - -NETWORK_ARGS=(--network user,model=virtio) -echo >&2 "Using unprivileged user networking" +ignition="$("$script_dir/butane" -s -o "$output_dir/ignition" "$output_dir/source/clamps.bu" clamps.ign)" +cp --reflink=auto "$image" "$output_dir/$vm_name.qcow2" +sha256sum "$output_dir/source/files/skillet-clamps" > "$output_dir/skillet.sha256" +sha256sum "$image" > "$output_dir/fcos-image.sha256" +printf 'vm=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir=%s\nsource_commit=%s\n' \ + "$vm_name" "$libvirt_uri" "$ssh_port" "$image" "$artifact" "${CLAMPS_LIBVIRT_RUNTIME_DIR:-}" \ + "$(git -C "$repo_dir" rev-parse HEAD)" > "$output_dir/run.conf" +{ + virt-install --version + vm_command virsh --version + podman --version + yq --version +} > "$output_dir/tool-versions.txt" -set -x -"$SCRIPT_DIR/virt-install" \ - --name "$VM_NAME" \ - --osinfo linux2024 \ - --memory 8192 \ - --vcpus 2 \ - --import \ - --disk "path=$(realpath "$VM_IMAGE_PATH"),format=qcow2,bus=virtio" \ - "${NETWORK_ARGS[@]}" \ - --graphics spice \ - --video virtio \ - --console pty,target_type=serial \ - --noautoconsole \ - --qemu-commandline="-fw_cfg name=opt/com.coreos/config,file=${IGNITION_CONFIG}" +network="passt,model=virtio,portForward0.proto=tcp,portForward0.address=127.0.0.1,portForward0.range0.start=$ssh_port,portForward0.range0.to=22" +echo "Creating $vm_name on $libvirt_uri, disk $output_dir/$vm_name.qcow2, SSH 127.0.0.1:$ssh_port" >&2 +vm_command virt-install --connect "$libvirt_uri" --name "$vm_name" \ + --osinfo linux2024 --memory 8192 --vcpus 2 --import \ + --disk "path=$output_dir/$vm_name.qcow2,format=qcow2,bus=virtio" \ + --disk "path=$ignition,format=raw,bus=virtio,readonly=on" \ + --network "$network" --graphics none \ + --console pty,target_type=serial --noautoconsole \ + --qemu-commandline="-fw_cfg name=opt/com.coreos/config,file=$ignition" diff --git a/ublue/butane/clamps.bu b/ublue/butane/clamps.bu index bac3b068..ebe66458 100644 --- a/ublue/butane/clamps.bu +++ b/ublue/butane/clamps.bu @@ -1,10 +1,13 @@ variant: fcos version: 1.6.0 +# VM-only layout: coreos-install attaches the FCOS disk as virtio /dev/vda. +# A physical installer must use an explicitly selected hardware disk instead. ignition: config: merge: - local: includes/passwd.bu - local: includes/zram.bu + - local: includes/resolver.bu - local: includes/brew.bu - local: includes/skillet.bu - local: includes/ucore-clamps.bu diff --git a/ublue/butane/includes/resolver.bu b/ublue/butane/includes/resolver.bu new file mode 100644 index 00000000..4493ed04 --- /dev/null +++ b/ublue/butane/includes/resolver.bu @@ -0,0 +1,17 @@ +# NetworkManager owns host DNS from active connection DHCP. The explicit link +# prevents a resolved stub symlink surviving into the resolved-masked image. +variant: fcos +version: 1.6.0 +storage: + links: + - path: /etc/resolv.conf + target: ../run/NetworkManager/resolv.conf + overwrite: true + files: + - path: /etc/NetworkManager/conf.d/90-clamps-dns.conf + mode: 0644 + contents: + inline: | + [main] + dns=default + rc-manager=symlink diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index 1fc831bb..1ec16453 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -1,23 +1,28 @@ variant: fcos version: 1.6.0 +storage: + directories: + - path: /var/lib/skillet + mode: 0755 + files: + - path: /var/lib/skillet/skillet-clamps + mode: 0755 + contents: + local: files/skillet-clamps systemd: units: - name: skillet-apply.service enabled: true contents: | [Unit] - Description=Apply Skillet configuration - After=network-online.target - Wants=network-online.target + Description=Apply Skillet clamps base configuration + Requires=clamps-bootstrap.service + After=clamps-bootstrap.service + ConditionPathExists=/run/clamps-bootstrap-ready [Service] Type=oneshot - # Use the generic skillet binary and pass the hostname from /etc/hostname - ExecStart=/usr/bin/skillet apply --host-file /etc/hostname - # Expose the Pi-hole web password to skillet via $CREDENTIALS_DIRECTORY. - # The credential file itself must be provisioned separately (Ignition - # storage.files); the service intentionally fails if it is missing. - LoadCredential=pihole_web_password:/etc/skillet/credentials/pihole_web_password + ExecStart=/var/lib/skillet/skillet-clamps apply --phase base RemainAfterExit=yes [Install] diff --git a/ublue/butane/includes/ucore-clamps.bu b/ublue/butane/includes/ucore-clamps.bu index be978144..774a0a67 100644 --- a/ublue/butane/includes/ucore-clamps.bu +++ b/ublue/butane/includes/ucore-clamps.bu @@ -1,50 +1,71 @@ -# Autorebase clamps onto our BlueBuild image on first boot: -# ghcr.io/gbagnoli/ucore-clamps:latest -# Same two-step dance as includes/ucore.bu: first the unsigned OCI (which -# installs the cosign signing policy from the image itself), then the signed -# OCI. Reboots between steps; marker files in /etc/ucore-clamps-autorebase -# survive the rebases because /etc persists across deployments. +# The script follows the booted rpm-ostree deployment, not a marker recording +# that a rebase command was requested. A failed rebase stops until the next boot. variant: fcos version: 1.6.0 storage: directories: - - path: /etc/ucore-clamps-autorebase - mode: 0754 + - path: /var/lib/clamps-bootstrap + mode: 0755 + files: + - path: /var/lib/clamps-bootstrap/rebase.sh + mode: 0755 + contents: + inline: | + #!/usr/bin/env bash + set -euo pipefail + image=ghcr.io/gbagnoli/ucore-clamps + unsigned="ostree-unverified-registry:$image:latest" + signed="ostree-image-signed:docker://$image:latest" + status="$(rpm-ostree status --json)" + booted="$(jq -r '.deployments[] | select(.booted) | .["container-image-reference"] // ""' <<< "$status")" + pending="$(jq -r '.deployments[0] | select(.booted == false) | .["container-image-reference"] // ""' <<< "$status")" + matches_image() { + local reference="$1" scheme="$2" + [[ "$reference" == "$scheme$image:latest" || "$reference" == "$scheme$image@sha256:"* ]] + } + if matches_image "$booted" 'ostree-image-signed:docker://'; then + rm -f /var/lib/clamps-bootstrap/reboot-attempts + touch /run/clamps-bootstrap-ready + echo "Booted signed clamps deployment: $booted" + exit 0 + fi + if matches_image "$booted" 'ostree-unverified-registry:'; then + target="$signed" + target_scheme='ostree-image-signed:docker://' + else + target="$unsigned" + target_scheme='ostree-unverified-registry:' + fi + attempts_file=/var/lib/clamps-bootstrap/reboot-attempts + if ! matches_image "$pending" "$target_scheme"; then + rpm-ostree rebase --bypass-driver "$target" + echo 1 > "$attempts_file" + else + echo "Target already pending: $target" + attempts="$(cat "$attempts_file" 2>/dev/null || echo 0)" + if ((attempts >= 2)); then + echo "Pending deployment failed to boot after two attempts: $target" >&2 + exit 1 + fi + echo "$((attempts + 1))" > "$attempts_file" + fi + echo "Rebooting to $target" + systemctl reboot --no-block systemd: units: - - name: ucore-clamps-unsigned-autorebase.service + - name: clamps-bootstrap.service enabled: true contents: | [Unit] - Description=Autorebase clamps to unsigned ucore-clamps OCI and reboot - ConditionPathExists=!/etc/ucore-clamps-autorebase/unverified - ConditionPathExists=!/etc/ucore-clamps-autorebase/signed + Description=Boot through the signed clamps image After=network-online.target Wants=network-online.target + [Service] Type=oneshot - StandardOutput=journal+console - ExecStart=/usr/bin/rpm-ostree rebase --bypass-driver ostree-unverified-registry:ghcr.io/gbagnoli/ucore-clamps:latest - ExecStart=/usr/bin/touch /etc/ucore-clamps-autorebase/unverified - ExecStart=/usr/bin/systemctl disable ucore-clamps-unsigned-autorebase.service - ExecStart=/usr/bin/systemctl reboot - [Install] - WantedBy=multi-user.target - - name: ucore-clamps-signed-autorebase.service - enabled: true - contents: | - [Unit] - Description=Autorebase clamps to signed ucore-clamps OCI and reboot - ConditionPathExists=/etc/ucore-clamps-autorebase/unverified - ConditionPathExists=!/etc/ucore-clamps-autorebase/signed - After=network-online.target - Wants=network-online.target - [Service] - Type=oneshot - StandardOutput=journal+console - ExecStart=/usr/bin/rpm-ostree rebase --bypass-driver ostree-image-signed:docker://ghcr.io/gbagnoli/ucore-clamps:latest - ExecStart=/usr/bin/touch /etc/ucore-clamps-autorebase/signed - ExecStart=/usr/bin/systemctl disable ucore-clamps-signed-autorebase.service - ExecStart=/usr/bin/systemctl reboot + ExecStart=/var/lib/clamps-bootstrap/rebase.sh + RemainAfterExit=yes + TimeoutStartSec=45min + [Install] WantedBy=multi-user.target diff --git a/ublue/butane/tests/bootstrap.sh b/ublue/butane/tests/bootstrap.sh new file mode 100644 index 00000000..7633e5cd --- /dev/null +++ b/ublue/butane/tests/bootstrap.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_dir="$(realpath "$(dirname "${BASH_SOURCE[0]}")/..")" +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT +mkdir "$tmp/bin" +yq -r '.storage.files[] | select(.path == "/var/lib/clamps-bootstrap/rebase.sh") | .contents.inline' \ + "$source_dir/includes/ucore-clamps.bu" > "$tmp/rebase.sh" + +cat > "$tmp/bin/rpm-ostree" <<'MOCK' +#!/usr/bin/env bash +if [[ "$1" == status && "$2" == --json ]]; then + cat "$CASE_STATUS" +elif [[ "$1" == rebase && "$2" == --bypass-driver ]]; then + printf '%s\n' "$3" >> "$CASE_REBASE_LOG" + [[ "${CASE_FAIL_REBASE:-0}" == 0 ]] +else + exit 2 +fi +MOCK +cat > "$tmp/bin/systemctl" <<'MOCK' +#!/usr/bin/env bash +[[ "$1" == reboot && "$2" == --no-block ]] || exit 2 +printf 'reboot\n' >> "$CASE_REBOOT_LOG" +MOCK +chmod +x "$tmp/bin/rpm-ostree" "$tmp/bin/systemctl" + +image=ghcr.io/gbagnoli/ucore-clamps +unsigned="ostree-unverified-registry:$image:latest" +signed="ostree-image-signed:docker://$image:latest" + +check() { + local name="$1" status="$2" expected_rebase="$3" expected_reboots="$4" + local expected_ready="$5" expected_exit="$6" attempts="${7:-}" fail_rebase="${8:-0}" + local case_dir="$tmp/$name" actual_exit=0 actual_rebase actual_reboots actual_ready + mkdir -p "$case_dir/state" + printf '%s\n' "$status" > "$case_dir/status.json" + if [[ -n "$attempts" ]]; then printf '%s\n' "$attempts" > "$case_dir/state/reboot-attempts"; fi + sed -e "s@/var/lib/clamps-bootstrap@$case_dir/state@g" \ + -e "s@/run/clamps-bootstrap-ready@$case_dir/ready@g" \ + "$tmp/rebase.sh" > "$case_dir/rebase.sh" + CASE_STATUS="$case_dir/status.json" CASE_REBASE_LOG="$case_dir/rebases" \ + CASE_REBOOT_LOG="$case_dir/reboots" CASE_FAIL_REBASE="$fail_rebase" \ + PATH="$tmp/bin:$PATH" bash "$case_dir/rebase.sh" > "$case_dir/output" 2>&1 || actual_exit=$? + actual_rebase="$(cat "$case_dir/rebases" 2>/dev/null || true)" + if [[ -f "$case_dir/reboots" ]]; then + actual_reboots="$(wc -l < "$case_dir/reboots")" + else + actual_reboots=0 + fi + actual_ready=0 + [[ ! -e "$case_dir/ready" ]] || actual_ready=1 + if [[ "$actual_rebase" != "$expected_rebase" || "$actual_reboots" != "$expected_reboots" || + "$actual_ready" != "$expected_ready" || "$actual_exit" != "$expected_exit" ]]; then + echo "FAIL $name: rebase=$actual_rebase reboots=$actual_reboots ready=$actual_ready exit=$actual_exit" >&2 + cat "$case_dir/output" >&2 + exit 1 + fi + echo "PASS $name" +} + +check fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' "$unsigned" 1 0 0 +check unsigned_with_signed_rollback \ + "{\"deployments\":[{\"booted\":true,\"container-image-reference\":\"$unsigned\"},{\"booted\":false,\"container-image-reference\":\"$signed\"}]}" \ + "$signed" 1 0 0 +check signed_pending \ + "{\"deployments\":[{\"booted\":false,\"container-image-reference\":\"$signed\"},{\"booted\":true,\"container-image-reference\":\"$unsigned\"}]}" \ + '' 1 0 0 1 +check signed_booted \ + "{\"deployments\":[{\"booted\":true,\"container-image-reference\":\"$signed\"}]}" \ + '' 0 1 0 1 +check signed_digest_booted \ + "{\"deployments\":[{\"booted\":true,\"container-image-reference\":\"ostree-image-signed:docker://$image@sha256:$(printf 'a%.0s' {1..64})\"}]}" \ + '' 0 1 0 +check pending_retry_limit \ + "{\"deployments\":[{\"booted\":false,\"container-image-reference\":\"$signed\"},{\"booted\":true,\"container-image-reference\":\"$unsigned\"}]}" \ + '' 0 0 1 2 +check rebase_failure '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ + "$unsigned" 0 0 1 '' 1 diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index b3791ba7..5c16b465 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -24,18 +24,15 @@ This document defines the architectural mandates and project structure for `skil - **Formatting & Linting**: Always run `cargo fmt` and `cargo clippy` after making changes to ensure code quality and consistency. **Clippy MUST be run with `pedantic` lints enabled (configured in `Cargo.toml`).** - **Verification**: Always run both: - **Unit Tests**: `cargo test` across the workspace. - - **Integration Tests**: `skillet test run ` for affected hosts to verify end-to-end correctness in a containerized environment. + - **Runtime Smoke**: Run `integration_tests/smoke-ssh.sh` against an explicitly named disposable VM with real systemd and Podman for affected container resources. Record the guest state snapshots and failure diagnostics. -## Testing & Recording Philosophy +## Testing Philosophy Skillet uses a multi-layered testing approach to ensure reliability and idempotency: 1. **Trait-based Abstraction**: Core resources (`FileResource`, `SystemResource`) are defined as traits. This allows for easy mocking using `MockFiles` and `MockSystem` in unit tests. -2. **The Recorder Wrapper**: A `Recorder` wrapper can be applied to any resource implementing these traits. It intercepts all operations (e.g., `EnsureFile`, `ServiceRestart`), records them into a sequence of `ResourceOp` enums, and then passes the call to the underlying implementation. -3. **Containerized Integration Tests**: - * **Record Mode**: The tool runs against a fresh container, and the `Recorder` saves the sequence of operations to a YAML file (e.g., `integration_tests/recordings/beezelbot.yaml`). - * **Run Mode**: The tool runs again, and the *actual* sequence of operations is compared against the *recorded* one. Any mismatch (e.g., a missing service restart or an extra file write) causes the test to fail. - * **Idempotency**: By running the tool twice in the same container, we can verify that the second run produces an empty (or minimal) set of operations, confirming idempotency. +2. **Diagnostic Recorder**: `apply --record PATH` can capture attempted resource operations for investigation. Recording equality is not an acceptance check. +3. **Real Runtime Smoke**: The disposable VM check exercises generated Quadlets, systemd startup, Podman secrets, configuration changes, repeated apply, failure recovery and reboot persistence. It compares managed bytes and metadata plus container identity and observed application state. ## Project Structure diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 476d1883..a5e08b41 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -293,39 +293,6 @@ dependencies = [ "piper", ] -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "cargo_metadata" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.18", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -957,10 +924,6 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] [[package]] name = "serde" @@ -1077,21 +1040,10 @@ name = "skillet" version = "0.1.0" dependencies = [ "anyhow", - "cargo_metadata", "clap", - "hex", - "serde", - "serde_yml", "skillet_cli_common", - "skillet_core", - "skillet_hardening", - "skillet_pihole", - "skillet_podman", - "tempfile", - "thiserror 1.0.69", "tracing", "tracing-subscriber", - "users", ] [[package]] @@ -1127,7 +1079,7 @@ dependencies = [ "skillet_pihole", "skillet_podman", "tempfile", - "thiserror 1.0.69", + "thiserror", "tracing", "tracing-subscriber", "users", @@ -1143,7 +1095,7 @@ dependencies = [ "serde", "sha2", "tempfile", - "thiserror 1.0.69", + "thiserror", "tracing", "users", "zbus", @@ -1153,9 +1105,11 @@ dependencies = [ name = "skillet_hardening" version = "0.1.0" dependencies = [ + "hex", + "sha2", "skillet_core", "tempfile", - "thiserror 1.0.69", + "thiserror", "tracing", ] @@ -1166,7 +1120,7 @@ dependencies = [ "askama", "skillet_core", "skillet_podman", - "thiserror 1.0.69", + "thiserror", "tracing", "users", ] @@ -1176,9 +1130,11 @@ name = "skillet_podman" version = "0.1.0" dependencies = [ "askama", + "hex", "serde", + "sha2", "skillet_core", - "thiserror 1.0.69", + "thiserror", "tracing", "users", ] @@ -1237,16 +1193,7 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl", ] [[package]] @@ -1260,17 +1207,6 @@ dependencies = [ "syn", ] -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "thread_local" version = "1.1.9" diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index b13eaf55..f47019c0 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -31,33 +31,50 @@ The per-host binaries land at ## Running -The tool provides an `apply` command to execute configuration. +The tool provides an `apply` command to execute configuration. `apply` defaults +to the full host and requires its application credentials. Use `--phase base` +to converge only the shared host baseline before credentials are available. - **Agent Mode**: Run generically on a host: ```bash - ./target/debug/skillet apply + ./target/x86_64-unknown-linux-musl/debug/skillet apply --host clamps --phase base ``` - **Host-Specific Configuration**: If a host-specific binary has been built (e.g., `skillet-beezelbot`), you can use it to apply specific configurations. -## Testing +## Disposable VM smoke check -Skillet uses containerized integration tests to verify idempotency and state changes. +Build both static binaries from this directory: -### Running Integration Tests -To verify an existing recording for a host (e.g., `beezelbot`): ```bash -./target/debug/skillet test run beezelbot --image fedora:latest +cargo build --release -p skillet-clamps -p skillet +sha256sum target/x86_64-unknown-linux-musl/release/skillet-clamps ``` -### Recording Integration Tests -To record a new configuration state for a host: +The host artifact is `target/x86_64-unknown-linux-musl/release/skillet-clamps`. +The current artifact SHA256 is +`55bf7584ce405dcf2c20956c182f4dbfe04bfbb20a9b649e48ab97877d9e8856`. +Supply that artifact to the guest, then invoke `skillet-clamps apply --phase base`. +The generic binary is used only by the disposable fixture smoke check: + ```bash -./target/debug/skillet test record beezelbot --image fedora:latest +integration_tests/smoke-ssh.sh \ + --target core@127.0.0.1 --disposable-target \ + --port 2201 \ + --identity ../butane/runs/clamps-test-example/ssh/id_ed25519 \ + --binary target/x86_64-unknown-linux-musl/release/skillet \ + --clamps-binary /var/lib/skillet/skillet-clamps ``` -You can append `--release` to these commands to test against production-optimized binaries. +The target and disposable opt-in are mandatory. The script checks baseline/full +separation, container startup and idempotency, configuration and dummy secret +rotation, failed startup recovery, interrupted activation, and reboot +persistence. It writes state snapshots and failure output under +`/var/lib/skillet-smoke/` in the guest. Run it on a fresh disposable VM; its +fixture unit and data use only `skillet-smoke` names. The guest needs passwordless +sudo for the SSH user and access to pull `docker.io/library/alpine:3.20`. +When `--identity` is supplied, SSH stores that VM's host key beside the private key. ## Architectural Mandates - **Error Handling**: Use `thiserror` in library crates; `anyhow` is reserved for CLI binaries. No `unwrap()` or `expect()` in library code. - **Idempotency**: All modules must ensure system state idempotently. - **System Interactions**: Prioritize Rust crates (e.g., `zbus`, `users`) over shelling out to system commands. -- **Linting**: All builds must pass `cargo clippy --pedantic`. +- **Linting**: Run `cargo clippy --workspace --all-targets -- -D warnings`; the workspace enables pedantic lints with documented exceptions. diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index e0887cd4..307d16bb 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -19,3 +19,6 @@ thiserror.workspace = true serde.workspace = true serde_yml.workspace = true tempfile.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 5352c039..aff973d3 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -14,6 +14,12 @@ use skillet_podman::{QuadletSecret, SecretTarget}; use std::collections::BTreeMap; use thiserror::Error; +#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] +pub enum ApplyPhase { + Base, + Full, +} + #[derive(Error, Debug)] pub enum ApplyError { #[error("System error: {0}")] @@ -26,6 +32,10 @@ pub enum ApplyError { Hardening(String), #[error("Pihole apply error: {0}")] Pihole(#[from] skillet_pihole::PiholeError), + #[error("Podman error: {0}")] + Podman(#[from] skillet_podman::PodmanError), + #[error("Fixture input error: {0}")] + FixtureInput(String), } mod user_lookup { @@ -50,11 +60,14 @@ pub fn apply_beezelbot( skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) } +/// Shared host baseline used by both CLI entry points. +pub fn apply_base(system: &dyn SystemResource, files: &dyn FileResource) -> Result<(), ApplyError> { + skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) +} + /// Name of the systemd credential (and podman secret) holding the Pi-hole -/// web UI password. Wired into `skillet-apply.service` via `LoadCredential=` -/// in `ublue/butane/includes/skillet.bu`; the credential file itself -/// (`/etc/skillet/credentials/pihole_web_password`) must be provisioned by -/// Ignition separately. +/// web UI password. Full `clamps` apply reads it from the systemd credential +/// directory supplied to that invocation. pub const PIHOLE_WEB_PASSWORD_CREDENTIAL: &str = "pihole_web_password"; /// Custom DNS records for the clamps Pi-hole (`ip -> fqdn`). @@ -72,7 +85,7 @@ pub fn apply_clamps( system: &dyn SystemResource, files: &dyn FileResource, ) -> Result<(), ApplyError> { - skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string()))?; + apply_base(system, files)?; // 1. Ingest secret from systemd (lazy: only this host needs secrets) let credentials = CredentialManager::new()?; @@ -131,8 +144,24 @@ pub fn apply_host( ) -> Result<(), ApplyError> { match hostname { "clamps" => apply_clamps(system, files), + "skillet-smoke" => fixture::apply(system, files), // beezelbot and unknown hostnames fall back to the hardening-only // baseline, matching the previous "(Agent Mode)" default behaviour. _ => apply_beezelbot(system, files), } } + +#[path = "hosts/fixture.rs"] +mod fixture; + +pub fn apply_host_phase( + hostname: &str, + phase: ApplyPhase, + system: &dyn SystemResource, + files: &dyn FileResource, +) -> Result<(), ApplyError> { + match phase { + ApplyPhase::Base => apply_base(system, files), + ApplyPhase::Full => apply_host(hostname, system, files), + } +} diff --git a/ublue/skillet/crates/cli-common/src/hosts/fixture.rs b/ublue/skillet/crates/cli-common/src/hosts/fixture.rs new file mode 100644 index 00000000..32bdebf7 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/hosts/fixture.rs @@ -0,0 +1,116 @@ +//! Disposable VM fixture. Only the explicit `skillet-smoke` host selects it. + +use super::ApplyError; +use skillet_core::{files::FileResource, system::SystemResource}; +use skillet_podman::{ContainerUser, PodmanConfig, QuadletSecret, SecretTarget, Volume}; +use std::{collections::BTreeMap, path::Path}; + +const INPUT_DIR: &str = "/var/lib/skillet-smoke/desired"; +const CONFIG_PATH: &str = "/etc/skillet-smoke/config"; +const SECRET_NAME: &str = "skillet-smoke-dummy"; +const ENTRYPOINT: &[u8] = b"#!/bin/sh\nset -eu\ncp /fixture/config /data/observed-config\nsha256sum /run/secrets/skillet-smoke-dummy | cut -d' ' -f1 > /data/observed-secret-sha\nwhile :; do sleep 3600; done\n"; + +pub(super) fn apply( + system: &dyn SystemResource, + files: &dyn FileResource, +) -> Result<(), ApplyError> { + let config = files + .read_file(&Path::new(INPUT_DIR).join("config"))? + .ok_or_else(|| ApplyError::FixtureInput("missing desired config".to_string()))?; + let secret = files + .read_file(&Path::new(INPUT_DIR).join("secret"))? + .ok_or_else(|| ApplyError::FixtureInput("missing desired dummy secret".to_string()))?; + let secret = String::from_utf8(secret) + .map_err(|_| ApplyError::FixtureInput("dummy secret must be UTF-8".to_string()))?; + + files.ensure_directory( + Path::new("/etc/skillet-smoke"), + Some(0o755), + Some("root"), + Some("root"), + )?; + files.ensure_directory( + Path::new("/var/lib/skillet-smoke/data"), + Some(0o755), + Some("root"), + Some("root"), + )?; + files.ensure_file( + Path::new(CONFIG_PATH), + &config, + Some(0o644), + Some("root"), + Some("root"), + )?; + files.ensure_file( + Path::new("/etc/skillet-smoke/entrypoint.sh"), + ENTRYPOINT, + Some(0o644), + Some("root"), + Some("root"), + )?; + system.ensure_podman_secret(SECRET_NAME, &secret)?; + + let mut extra_config = BTreeMap::new(); + extra_config.insert( + "Container".to_string(), + vec![ + "ContainerName=skillet-smoke-fixture".to_string(), + "Exec=/bin/sh /fixture/entrypoint.sh".to_string(), + ], + ); + extra_config.insert( + "Service".to_string(), + vec![ + "ExecStartPre=/usr/bin/test -e /var/lib/skillet-smoke/allow-start".to_string(), + "Restart=no".to_string(), + ], + ); + extra_config.insert( + "Install".to_string(), + vec!["WantedBy=multi-user.target".to_string()], + ); + + skillet_podman::container( + system, + files, + PodmanConfig { + name: "skillet-smoke-fixture".to_string(), + image: "docker.io/library/alpine:3.20".to_string(), + user: ContainerUser { + container_uid: 0, + container_gid: 0, + host_user: None, + }, + create_host_user: false, + volumes: vec![ + Volume { + host_path: "/etc/skillet-smoke".to_string(), + container_path: "/fixture".to_string(), + options: Some("ro,Z".to_string()), + }, + Volume { + host_path: "/var/lib/skillet-smoke/data".to_string(), + container_path: "/data".to_string(), + options: Some("Z".to_string()), + }, + ], + secrets: vec![QuadletSecret { + secret_name: SECRET_NAME.to_string(), + target: SecretTarget::File { + target_path: "/run/secrets/skillet-smoke-dummy".to_string(), + mode: Some("0400".to_string()), + uid: None, + gid: None, + }, + }], + config_revisions: vec![config], + extra_config, + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "fixture/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/cli-common/src/hosts/fixture/tests.rs b/ublue/skillet/crates/cli-common/src/hosts/fixture/tests.rs new file mode 100644 index 00000000..64aa7233 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/hosts/fixture/tests.rs @@ -0,0 +1,37 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; + +#[test] +fn fixture_requires_explicit_inputs() { + let system = MockSystem::new(); + let files = MockFiles::new(); + assert!(matches!( + apply(&system, &files), + Err(ApplyError::FixtureInput(_)) + )); +} + +#[test] +fn fixture_uses_the_host_container_resources() { + let system = MockSystem::new(); + let files = MockFiles::new(); + files + .files + .lock() + .unwrap() + .insert(format!("{INPUT_DIR}/config"), b"one".to_vec()); + files + .files + .lock() + .unwrap() + .insert(format!("{INPUT_DIR}/secret"), b"dummy".to_vec()); + apply(&system, &files).unwrap(); + let resources = files.files.lock().unwrap(); + let quadlet = String::from_utf8( + resources["/etc/containers/systemd/skillet-smoke-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("Secret=skillet-smoke-dummy")); + assert!(quadlet.contains("WantedBy=multi-user.target")); + assert!(resources.contains_key("/var/lib/skillet/containers/skillet-smoke-fixture.applied")); +} diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 0ed3c087..da6cb1f2 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -9,6 +9,7 @@ use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; pub mod hosts; +use hosts::ApplyPhase; #[derive(Error, Debug)] pub enum CliCommonError { @@ -39,6 +40,8 @@ pub struct HostArgs { pub enum HostCommands { /// Apply configuration Apply { + #[arg(long, value_enum, default_value_t = ApplyPhase::Full)] + phase: ApplyPhase, /// Optional: Output recorded actions to this file path #[arg(long)] record: Option, @@ -47,7 +50,7 @@ pub enum HostCommands { pub fn run_host(hostname: &str, apply_fn: F) -> Result<(), CliCommonError> where - F: Fn(&dyn SystemResource, &dyn FileResource) -> Result<(), String>, + F: Fn(ApplyPhase, &dyn SystemResource, &dyn FileResource) -> Result<(), String>, { let args = HostArgs::parse(); @@ -62,7 +65,9 @@ where tracing::subscriber::set_global_default(subscriber)?; match args.command { - HostCommands::Apply { record } => handle_apply(hostname, record, apply_fn), + HostCommands::Apply { record, phase } => handle_apply(hostname, record, |system, files| { + apply_fn(phase, system, files) + }), } } diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index ba9a5b10..d42f4783 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -7,19 +7,8 @@ edition = "2021" workspace = true [dependencies] -skillet_core.workspace = true -skillet_hardening.workspace = true -skillet_pihole.workspace = true -skillet_podman.workspace = true skillet_cli_common.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true anyhow = "1.0" -serde.workspace = true -serde_yml.workspace = true -hex.workspace = true -tempfile.workspace = true -users.workspace = true -cargo_metadata = "0.23.1" -thiserror.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index cede2932..235f3720 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,11 +1,8 @@ use anyhow::{anyhow, Context, Result}; -use clap::{Parser, Subcommand}; -use skillet_core::resource_op::ResourceOp; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::Command; -use tracing::{error, info, Level}; +use clap::Parser; +use skillet_cli_common::hosts::ApplyPhase; +use std::path::PathBuf; +use tracing::Level; use tracing_subscriber::FmtSubscriber; #[derive(Parser, Debug)] @@ -13,57 +10,28 @@ use tracing_subscriber::FmtSubscriber; struct Args { #[command(subcommand)] command: Commands, - - /// Enable verbose logging #[arg(short, long, global = true)] verbose: bool, } -#[derive(Subcommand, Debug)] +#[derive(clap::Subcommand, Debug)] enum Commands { - /// Apply configuration (Agent Mode) + /// Apply host configuration Apply { - /// Optional: Hostname to apply configuration for + #[arg(long, value_enum, default_value_t = ApplyPhase::Full)] + phase: ApplyPhase, #[arg(long)] host: Option, - /// Optional: File to read hostname from #[arg(long)] host_file: Option, - /// Optional: Output recorded actions to this file path + /// Optional diagnostic recording of resource operations #[arg(long)] record: Option, }, - /// Manage integration tests (Runner Mode) - Test { - #[command(subcommand)] - test_command: TestCommands, - }, -} - -#[derive(Subcommand, Debug)] -enum TestCommands { - Record { - hostname: String, - /// Container image to use - #[arg(long, default_value = "fedora:latest")] - image: String, - /// Inspect the container after application (interactive shell) - #[arg(long)] - inspect: bool, - }, - Run { - hostname: String, - #[arg(long, default_value = "fedora:latest")] - image: String, - /// Inspect the container after application (interactive shell) - #[arg(long)] - inspect: bool, - }, } fn main() -> Result<()> { let args = Args::parse(); - let subscriber = FmtSubscriber::builder() .with_max_level(if args.verbose { Level::DEBUG @@ -71,334 +39,28 @@ fn main() -> Result<()> { Level::INFO }) .finish(); - tracing::subscriber::set_global_default(subscriber) .context("setting default subscriber failed")?; match args.command { Commands::Apply { + phase, host, host_file, record, } => { let mut hostname = host.unwrap_or_else(|| "(Agent Mode)".to_string()); - if let Some(p) = host_file { - hostname = std::fs::read_to_string(p) + if let Some(path) = host_file { + hostname = std::fs::read_to_string(path) .context("Failed to read host file")? .trim() .to_string(); } - skillet_cli_common::handle_apply(&hostname, record, |system, files| { - skillet_cli_common::hosts::apply_host(&hostname, system, files) - .map_err(|e| e.to_string()) + skillet_cli_common::hosts::apply_host_phase(&hostname, phase, system, files) + .map_err(|error| error.to_string()) }) - .map_err(|e| anyhow!("Failed to apply configuration: {e}"))?; - } - Commands::Test { test_command } => handle_test(test_command)?, - } - Ok(()) -} - -fn handle_test(cmd: TestCommands) -> Result<()> { - match cmd { - TestCommands::Record { - hostname, - image, - inspect, - } => { - info!("Recording integration test for host: {}", hostname); - run_container_test(&hostname, &image, true, inspect)?; - } - TestCommands::Run { - hostname, - image, - inspect, - } => { - info!( - "Running integration test verification for host: {}", - hostname - ); - run_container_test(&hostname, &image, false, inspect)?; - } - } - Ok(()) -} - -fn run_container_test(hostname: &str, image: &str, is_record: bool, inspect: bool) -> Result<()> { - build_workspace()?; - - let binary_path = locate_binary(hostname)?; - let container_name = format!("skillet-test-{hostname}"); - - setup_container(&container_name, image, &binary_path.0)?; - - let result = (|| -> Result<()> { - prepare_and_run_skillet(&container_name, hostname, binary_path.1)?; - verify_or_record(hostname, &container_name, is_record)?; - Ok(()) - })(); - - if inspect { - inspect_container(&container_name)?; - } - - info!("Stopping container..."); - let _ = Command::new("podman") - .args(["rm", "-f", &container_name]) - .output(); - - result -} - -fn build_workspace() -> Result<()> { - info!("Building skillet workspace..."); - let build_status = Command::new("cargo") - .args(["build"]) - .status() - .context("Failed to run cargo build")?; - - if !build_status.success() { - return Err(anyhow!("Build failed")); - } - Ok(()) -} - -fn inspect_container(container_name: &str) -> Result<()> { - info!("Starting interactive inspection shell in {container_name}..."); - let _ = Command::new("podman") - .args(["exec", "-it", container_name, "/bin/bash"]) - .status() - .context("Failed to run inspection shell")?; - Ok(()) -} - -fn find_workspace_root() -> Result { - let metadata = cargo_metadata::MetadataCommand::new().exec()?; - Ok(metadata.workspace_root.into_std_path_buf()) -} - -fn locate_binary(hostname: &str) -> Result<(PathBuf, bool)> { - let host_binary_name = format!("skillet-{hostname}"); - let root = find_workspace_root()?; - - let musl = "x86_64-unknown-linux-musl"; - let target_dirs = [ - format!("target/{musl}/release"), - format!("target/{musl}/debug"), - "target/release".to_string(), - "target/debug".to_string(), - ]; - - // Prefer the host-specific binary ... - for dir in &target_dirs { - let path = root.join(dir).join(&host_binary_name); - if path.exists() { - info!("Using host-specific binary: {}", path.display()); - return Ok(( - fs::canonicalize(&path).context("Failed to canonicalize binary path")?, - true, - )); - } - } - - // ... but fall back to the generic CLI. The caller must then pass - // --host explicitly: without it the generic CLI applies its - // "(Agent Mode)" default and the test would silently verify the wrong - // host's configuration. - for dir in &target_dirs { - let path = root.join(dir).join("skillet"); - if path.exists() { - info!( - "Using generic binary, will pass --host explicitly: {}", - path.display() - ); - return Ok(( - fs::canonicalize(&path).context("Failed to canonicalize binary path")?, - false, - )); - } - } - - Err(anyhow!( - "No suitable skillet binary found under target/ (checked release, debug, and x86_64-unknown-linux-musl subdirs)" - )) -} - -fn setup_container(container_name: &str, image: &str, binary_path: &Path) -> Result<()> { - info!("Starting container {container_name} from image {image}..."); - - let _ = Command::new("podman") - .args(["rm", "-f", container_name]) - .output(); - - // Create a mock credentials directory - let root = find_workspace_root()?; - let mock_creds_dir = root.join("target/mock_creds"); - fs::create_dir_all(&mock_creds_dir)?; - fs::write( - mock_creds_dir.join(skillet_cli_common::hosts::PIHOLE_WEB_PASSWORD_CREDENTIAL), - "supersecret_payload", - )?; - - let run_status = Command::new("podman") - .args([ - "run", - "-d", - "--rm", - "--name", - container_name, - "-v", - &format!("{}:/usr/bin/skillet:ro", binary_path.display()), - "-v", - &format!("{}:/run/credentials:ro", mock_creds_dir.display()), - "-e", - "CREDENTIALS_DIRECTORY=/run/credentials", - image, - "sleep", - "infinity", - ]) - .status() - .context("Failed to start podman container")?; - - if !run_status.success() { - return Err(anyhow!("Failed to start container")); - } - Ok(()) -} - -fn prepare_and_run_skillet( - container_name: &str, - hostname: &str, - host_specific: bool, -) -> Result<()> { - // Prepare entrypoint script - let entrypoint_content = include_str!("test_entrypoint.sh"); - let mut temp_entrypoint = tempfile::Builder::new().suffix(".sh").tempfile()?; - temp_entrypoint.write_all(entrypoint_content.as_bytes())?; - let temp_entrypoint_path = temp_entrypoint - .path() - .to_str() - .ok_or_else(|| anyhow!("Entrypoint path is not valid UTF-8"))?; - - // Copy entrypoint to container - info!("Copying test entrypoint to container..."); - let cp_status = Command::new("podman") - .args([ - "cp", - temp_entrypoint_path, - &format!("{container_name}:/tmp/test_entrypoint.sh"), - ]) - .status() - .context("Failed to copy entrypoint")?; - - if !cp_status.success() { - return Err(anyhow!("Failed to copy entrypoint to container")); - } - - // Make executable - let chmod_status = Command::new("podman") - .args([ - "exec", - container_name, - "chmod", - "+x", - "/tmp/test_entrypoint.sh", - ]) - .status() - .context("Failed to chmod entrypoint")?; - - if !chmod_status.success() { - return Err(anyhow!("Failed to chmod entrypoint in container")); - } - - info!("Executing skillet inside container..."); - // The host-specific binary already knows its host; the generic CLI - // needs --host pinned explicitly, otherwise it falls back to - // "(Agent Mode)" and the test would exercise the wrong configuration. - let mut skillet_args = vec!["apply".to_string()]; - if !host_specific { - skillet_args.push("--host".to_string()); - skillet_args.push(hostname.to_string()); - } - skillet_args.push("--record".to_string()); - skillet_args.push("/tmp/ops.yaml".to_string()); - - let exec_status = Command::new("podman") - .args(["exec", container_name, "/tmp/test_entrypoint.sh", "skillet"]) - .args(&skillet_args) - .status() - .context("Failed to exec skillet")?; - - if !exec_status.success() { - return Err(anyhow!("skillet apply failed inside container")); - } - Ok(()) -} - -fn verify_or_record(hostname: &str, container_name: &str, is_record: bool) -> Result<()> { - let root = find_workspace_root()?; - let dest_dir = root.join("integration_tests/recordings"); - fs::create_dir_all(&dest_dir)?; - let dest_file = dest_dir.join(format!("{hostname}.yaml")); - - if is_record { - info!("Copying recording to {}", dest_file.display()); - // Use atomic write via temporary file - let temp_file = tempfile::Builder::new() - .suffix(".yaml") - .tempfile_in(&dest_dir)?; - let temp_path = temp_file - .path() - .to_str() - .ok_or_else(|| anyhow!("Temporary path is not valid UTF-8"))?; - - let cp_status = Command::new("podman") - .args(["cp", &format!("{container_name}:/tmp/ops.yaml"), temp_path]) - .status()?; - - if !cp_status.success() { - return Err(anyhow!("Failed to copy recording from container")); - } - - // Atomic rename - std::fs::rename(temp_path, &dest_file).context(format!( - "Failed to rename temporary recording file to {}", - dest_file.display() - ))?; - } else { - info!("Verifying recording..."); - let temp_dest = tempfile::Builder::new().suffix(".yaml").tempfile()?; - let temp_path = temp_dest - .path() - .to_str() - .ok_or_else(|| anyhow!("Temporary path is not valid UTF-8"))?; - - let cp_status = Command::new("podman") - .args(["cp", &format!("{container_name}:/tmp/ops.yaml"), temp_path]) - .status()?; - if !cp_status.success() { - return Err(anyhow!("Failed to copy recording from container")); - } - - let recorded_content = fs::read_to_string(&dest_file).context(format!( - "Failed to read existing recording at {}", - dest_file.display() - ))?; - let new_content = fs::read_to_string(temp_path)?; - - let recorded_ops: Vec = serde_yml::from_str(&recorded_content)?; - let new_ops: Vec = serde_yml::from_str(&new_content)?; - - if recorded_ops == new_ops { - info!("Integration test passed!"); - } else { - error!("Recording mismatch!"); - error!("Expected: {:?}", recorded_ops); - error!("Actual: {:?}", new_ops); - return Err(anyhow!( - "Integration test failed: Actions do not match recording." - )); + .map_err(|error| anyhow!("Failed to apply configuration: {error}"))?; } } Ok(()) diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh deleted file mode 100755 index ea7022f3..00000000 --- a/ublue/skillet/crates/cli/src/test_entrypoint.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh -set -e - -# Ensure /etc/sysctl.d exists (often missing in minimal containers) -mkdir -p /etc/sysctl.d - -# Mock systemctl if it doesn't exist -if [ ! -x /usr/bin/systemctl ]; then - echo "Mocking systemctl..." - cat < /usr/bin/systemctl -#!/bin/sh -# The default branch exits 0 for every invocation, which is what skillet -# expects from e.g. 'systemctl start --wait' (exit status = job result). -echo "Mock systemctl: \$@" -exit 0 -EOF - chmod +x /usr/bin/systemctl -fi - -# Mock podman if it doesn't exist -if [ ! -x /usr/bin/podman ]; then - echo "Mocking podman..." - cat < /usr/bin/podman -#!/bin/sh -case "\$*" in - "secret inspect"*) - exit 1 # Secret doesn't exist - ;; - "secret create"*) - exit 0 # Successfully created - ;; - *) - echo "Mock podman: \$@" - exit 0 - ;; -esac -EOF - chmod +x /usr/bin/podman -fi - -# Execute the passed command (skillet apply) -exec "$@" diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 163c89a6..ab1d818f 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -38,6 +38,7 @@ pub enum FileError { } pub trait FileResource { + fn read_file(&self, path: &Path) -> Result>, FileError>; fn ensure_file( &self, path: &Path, @@ -107,13 +108,9 @@ impl LocalFileResource { use std::os::unix::io::AsRawFd; if let Some(m) = mode { - let mut perms = file - .metadata() - .map_err(FileError::Io)? - .permissions(); + let mut perms = file.metadata().map_err(FileError::Io)?.permissions(); perms.set_mode(m); - file.set_permissions(perms) - .map_err(FileError::Io)?; + file.set_permissions(perms).map_err(FileError::Io)?; } if owner.is_some() || group.is_some() { @@ -188,6 +185,13 @@ impl Default for LocalFileResource { } impl FileResource for LocalFileResource { + fn read_file(&self, path: &Path) -> Result>, FileError> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(FileError::Read(path.display().to_string(), error)), + } + } fn ensure_file( &self, path: &Path, diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index 893962a4..c2a50867 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -42,6 +42,9 @@ impl Recorder { } impl FileResource for Recorder { + fn read_file(&self, path: &Path) -> Result>, FileError> { + self.inner.read_file(path) + } fn ensure_file( &self, path: &Path, @@ -91,6 +94,13 @@ impl FileResource for Recorder { } impl SystemResource for Recorder { + fn podman_secret_id(&self, name: &str) -> Result { + self.inner.podman_secret_id(name) + } + + fn service_is_active(&self, name: &str) -> Result { + self.inner.service_is_active(name) + } fn ensure_group(&self, name: &str, gid: Option) -> Result { self.record(ResourceOp::EnsureGroup { name: name.to_string(), diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index f3134708..cc6b315a 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -68,6 +68,8 @@ pub trait SystemResource { gid: Option, ) -> Result; fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result; + fn podman_secret_id(&self, name: &str) -> Result; + fn service_is_active(&self, name: &str) -> Result; fn service_start(&self, name: &str) -> Result<(), SystemError>; fn service_stop(&self, name: &str) -> Result<(), SystemError>; fn service_restart(&self, name: &str) -> Result<(), SystemError>; @@ -94,18 +96,12 @@ impl LinuxSystemResource { /// Run a systemctl action via `DBus` (preferred) or the CLI (fallback). /// - /// Actions are blocking: the CLI path never passes `--no-block`, and - /// `start`/`restart` additionally pass `--wait` so the call blocks until - /// the job completes. The exit status then reflects the job result, so a - /// failed service can no longer look successful. This is oneshot-safe: - /// unlike polling `is-active`, it does not mistake a successfully exited - /// oneshot unit for a failure. + /// CLI start/restart wait for the startup job to complete by default. + /// `--wait` would additionally wait for service termination. fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { let name_with_suffix = ensure_systemd_suffix(name); - // D-Bus `StartUnit`/`RestartUnit` only queue the job, so they cannot - // observe its result. Route start/restart through the CLI with - // `--wait` instead. + // D-Bus only queues the job; use the CLI to observe its result. if matches!(action, "start" | "restart") { return Self::run_systemctl_cli(action, &name_with_suffix); } @@ -141,15 +137,11 @@ impl LinuxSystemResource { /// Blocking `systemctl ` via the CLI. /// - /// `start`/`restart` pass `--wait`: the call blocks until the job - /// completes and the exit status reflects the job result. + /// The default call blocks until the startup job completes. fn run_systemctl_cli(action: &str, name_with_suffix: &str) -> Result<(), SystemError> { info!("Running systemctl {action} {name_with_suffix} via CLI"); let mut cmd = Command::new("systemctl"); cmd.arg(action); - if matches!(action, "start" | "restart") { - cmd.arg("--wait"); - } let output = cmd.arg(name_with_suffix).output()?; if !output.status.success() { @@ -292,53 +284,50 @@ impl SystemResource for LinuxSystemResource { hasher.update(payload.as_bytes()); let hash = hex::encode(hasher.finalize()); - let inspect_output = Command::new("podman") - .args([ - "secret", - "inspect", - "--format", - "{{ index .Spec.Labels \"skillet.payload_hash\" }}", - name, - ]) + let exists = Command::new("podman") + .args(["secret", "exists", name]) .output()?; - - if inspect_output.status.success() { - let existing_hash = String::from_utf8_lossy(&inspect_output.stdout) - .trim() - .to_string(); - // If the label is missing, the output will be empty - if existing_hash.is_empty() { - warn!("Podman secret {name} exists but lacks required label. Deleting and recreating."); - let rm_status = Command::new("podman") - .args(["secret", "rm", name]) - .status()?; - if !rm_status.success() { - return Err(SystemError::Command(format!( - "Failed to remove old secret {name}" - ))); - } - } else { - if existing_hash == hash { - debug!("Podman secret {name} already exists with correct hash"); - return Ok(false); - } - warn!("Podman secret {name} exists but hash mismatch. Deleting and recreating."); - let rm_status = Command::new("podman") - .args(["secret", "rm", name]) - .status()?; - if !rm_status.success() { - return Err(SystemError::Command(format!( - "Failed to remove old secret {name}" - ))); - } + let present = match exists.status.code() { + Some(0) => true, + Some(1) => false, + _ => { + return Err(SystemError::Command(format!( + "podman secret exists {name} failed: {}", + String::from_utf8_lossy(&exists.stderr) + ))) + } + }; + if present { + let inspect_output = Command::new("podman") + .args([ + "secret", + "inspect", + "--format", + "{{ index .Spec.Labels \"skillet.payload_hash\" }}", + name, + ]) + .output()?; + + if !inspect_output.status.success() { + return Err(SystemError::Command(format!( + "podman secret inspect {name} failed: {}", + String::from_utf8_lossy(&inspect_output.stderr) + ))); } + let existing_hash = String::from_utf8_lossy(&inspect_output.stdout); + if existing_hash.trim() == hash { + debug!("Podman secret {name} already exists with correct hash"); + return Ok(false); + } + warn!("Podman secret {name} needs replacement"); } - info!("Creating podman secret {name}"); + info!("Creating or replacing podman secret {name}"); let mut child = Command::new("podman") .args([ "secret", "create", + "--replace", "--label", &format!("skillet.payload_hash={hash}"), name, @@ -362,6 +351,40 @@ impl SystemResource for LinuxSystemResource { Ok(true) } + fn podman_secret_id(&self, name: &str) -> Result { + let output = Command::new("podman") + .args(["secret", "inspect", "--format", "{{.ID}}", name]) + .output()?; + if !output.status.success() { + return Err(SystemError::Command(format!( + "podman secret inspect {name} failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if id.is_empty() { + return Err(SystemError::Command(format!( + "podman secret inspect {name} returned an empty ID" + ))); + } + Ok(id) + } + + fn service_is_active(&self, name: &str) -> Result { + let name = ensure_systemd_suffix(name); + let output = Command::new("systemctl") + .args(["is-active", "--quiet", &name]) + .output()?; + match output.status.code() { + Some(0) => Ok(true), + Some(3) => Ok(false), + _ => Err(SystemError::Command(format!( + "systemctl is-active {name} failed: {}", + String::from_utf8_lossy(&output.stderr) + ))), + } + } + fn service_start(&self, name: &str) -> Result<(), SystemError> { self.run_systemctl("start", name) } diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 6edd4ebd..5cb57db1 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -1,13 +1,20 @@ use crate::files::{FileError, FileResource}; use crate::system::{SystemError, SystemResource}; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; pub struct MockSystem { pub groups: Arc>>, pub users: Arc>>, pub podman_secrets: Arc>>, + pub secret_ids: Arc>>, + pub fail_restart_once: Arc, + pub fail_reload_once: Arc, + pub restart_count: Arc, + pub start_count: Arc, pub services: Arc>>, // name -> state (started, stopped, restarted) } @@ -17,6 +24,11 @@ impl MockSystem { groups: Arc::new(Mutex::new(HashSet::new())), users: Arc::new(Mutex::new(HashSet::new())), podman_secrets: Arc::new(Mutex::new(HashSet::new())), + secret_ids: Arc::new(Mutex::new(HashMap::new())), + fail_restart_once: Arc::new(AtomicBool::new(false)), + fail_reload_once: Arc::new(AtomicBool::new(false)), + restart_count: Arc::new(AtomicUsize::new(0)), + start_count: Arc::new(AtomicUsize::new(0)), services: Arc::new(Mutex::new(HashMap::new())), } } @@ -29,8 +41,30 @@ impl Default for MockSystem { } impl SystemResource for MockSystem { + fn podman_secret_id(&self, name: &str) -> Result { + self.secret_ids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .cloned() + .ok_or_else(|| SystemError::Command(format!("secret {name} missing"))) + } + + fn service_is_active(&self, name: &str) -> Result { + Ok(matches!( + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .map(String::as_str), + Some("started" | "restarted") + )) + } fn ensure_group(&self, name: &str, _gid: Option) -> Result { - let mut groups = self.groups.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut groups = self + .groups + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); if groups.contains(name) { Ok(false) } else { @@ -45,7 +79,10 @@ impl SystemResource for MockSystem { _uid: Option, _gid: Option, ) -> Result { - let mut users = self.users.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut users = self + .users + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); if users.contains(name) { Ok(false) } else { @@ -54,17 +91,27 @@ impl SystemResource for MockSystem { } } - fn ensure_podman_secret(&self, name: &str, _payload: &str) -> Result { - let mut secrets = self.podman_secrets.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if secrets.contains(name) { + fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { + let mut secrets = self + .podman_secrets + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + secrets.insert(name.to_string()); + let id = hex::encode(Sha256::digest(payload.as_bytes())); + let mut ids = self + .secret_ids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ids.get(name) == Some(&id) { Ok(false) } else { - secrets.insert(name.to_string()); + ids.insert(name.to_string(), id); Ok(true) } } fn service_start(&self, name: &str) -> Result<(), SystemError> { + self.start_count.fetch_add(1, Ordering::SeqCst); self.services .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -81,6 +128,10 @@ impl SystemResource for MockSystem { } fn service_restart(&self, name: &str) -> Result<(), SystemError> { + self.restart_count.fetch_add(1, Ordering::SeqCst); + if self.fail_restart_once.swap(false, Ordering::SeqCst) { + return Err(SystemError::Command("injected restart failure".to_string())); + } self.services .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -105,6 +156,9 @@ impl SystemResource for MockSystem { } fn daemon_reload(&self) -> Result<(), SystemError> { + if self.fail_reload_once.swap(false, Ordering::SeqCst) { + return Err(SystemError::Command("injected reload failure".to_string())); + } self.services .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -138,6 +192,14 @@ impl Default for MockFiles { } impl FileResource for MockFiles { + fn read_file(&self, path: &Path) -> Result>, FileError> { + Ok(self + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&path.display().to_string()) + .cloned()) + } fn ensure_file( &self, path: &Path, @@ -147,8 +209,14 @@ impl FileResource for MockFiles { group: Option<&str>, ) -> Result { let path_str = path.display().to_string(); - let mut files = self.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let mut metadata = self.metadata.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut files = self + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut metadata = self + .metadata + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut changed = false; @@ -202,8 +270,14 @@ impl FileResource for MockFiles { fn delete_file(&self, path: &Path) -> Result { let path_str = path.display().to_string(); - let mut files = self.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let mut metadata = self.metadata.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut files = self + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut metadata = self + .metadata + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let f_removed = files.remove(&path_str).is_some(); let m_removed = metadata.remove(&path_str).is_some(); diff --git a/ublue/skillet/crates/hardening/Cargo.toml b/ublue/skillet/crates/hardening/Cargo.toml index 6d818c18..9debebd4 100644 --- a/ublue/skillet/crates/hardening/Cargo.toml +++ b/ublue/skillet/crates/hardening/Cargo.toml @@ -10,6 +10,8 @@ workspace = true skillet_core.workspace = true thiserror.workspace = true tracing.workspace = true +sha2.workspace = true +hex.workspace = true [dev-dependencies] skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index 47c47a6d..ac5240bd 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -1,3 +1,4 @@ +use sha2::{Digest, Sha256}; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; use std::path::Path; @@ -38,6 +39,40 @@ where Ok(()) } +fn converge_service_file( + system: &S, + files: &F, + path: &Path, + content: &[u8], + mode: u32, + service: &str, + ensure_active: bool, +) -> Result<(), HardeningError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + let state_dir = Path::new("/var/lib/skillet/hardening"); + files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; + let applied_path = state_dir.join(format!("{service}.applied")); + let revision = hex::encode(Sha256::digest(content)); + let pending = files.read_file(&applied_path)?.as_deref() != Some(revision.as_bytes()); + let changed = files.ensure_file(path, content, Some(mode), Some("root"), Some("root"))?; + if changed || pending { + system.service_restart(service)?; + files.ensure_file( + &applied_path, + revision.as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?; + } else if ensure_active && !system.service_is_active(service)? { + system.service_start(service)?; + } + Ok(()) +} + fn apply_sysctl_hardening(system: &S, files: &F) -> Result<(), HardeningError> where S: SystemResource + ?Sized, @@ -50,12 +85,15 @@ where let content = include_bytes!("../files/sysctl.boxy.conf"); let path = sysctl_dir.join("99-hardening.conf"); - let changed = files.ensure_file(&path, content, Some(0o644), Some("root"), Some("root"))?; - - if changed { - info!("Sysctl configuration changed, restarting systemd-sysctl..."); - system.service_restart("systemd-sysctl")?; - } + converge_service_file( + system, + files, + &path, + content, + 0o644, + "systemd-sysctl", + false, + )?; Ok(()) } @@ -73,12 +111,7 @@ where let content = include_bytes!("../files/sshd_config"); let path = Path::new("/etc/ssh/sshd_config"); - let changed = files.ensure_file(path, content, Some(0o600), Some("root"), Some("root"))?; - - if changed { - info!("SSH server configuration changed, restarting sshd..."); - system.service_restart("sshd")?; - } + converge_service_file(system, files, path, content, 0o600, "sshd", true)?; Ok(()) } diff --git a/ublue/skillet/crates/hardening/src/tests.rs b/ublue/skillet/crates/hardening/src/tests.rs index 6ef71625..ceda5fc2 100644 --- a/ublue/skillet/crates/hardening/src/tests.rs +++ b/ublue/skillet/crates/hardening/src/tests.rs @@ -1,5 +1,35 @@ use super::*; use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +#[test] +fn interrupted_service_activation_is_retried() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(apply(&system, &files).is_err()); + assert!(!files + .files + .lock() + .unwrap() + .contains_key("/var/lib/skillet/hardening/systemd-sysctl.applied")); + apply(&system, &files).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + apply(&system, &files).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); +} + +#[test] +fn stopped_sshd_is_started_without_rewriting_config() { + let system = MockSystem::new(); + let files = MockFiles::new(); + apply(&system, &files).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + system.service_stop("sshd").unwrap(); + apply(&system, &files).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + assert_eq!(system.start_count.load(Ordering::SeqCst), 1); +} #[test] fn test_hardening_applies_sysctl() { @@ -27,10 +57,14 @@ fn test_hardening_applies_ssh_server() { let system = MockSystem::new(); let files = MockFiles::new(); apply(&system, &files).unwrap(); - let files_map = files.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let files_map = files + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); assert!(files_map.contains_key("/etc/ssh/sshd_config")); - let content = String::from_utf8(files_map.get("/etc/ssh/sshd_config").unwrap().clone()).unwrap(); + let content = + String::from_utf8(files_map.get("/etc/ssh/sshd_config").unwrap().clone()).unwrap(); assert!(content.contains("PermitRootLogin without-password")); assert!(content.contains("Ciphers chacha20-poly1305@openssh.com")); @@ -50,7 +84,10 @@ fn test_hardening_applies_ssh_client() { let system = MockSystem::new(); let files = MockFiles::new(); apply(&system, &files).unwrap(); - let files_map = files.files.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let files_map = files + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); assert!(files_map.contains_key("/etc/ssh/ssh_config")); let content = String::from_utf8(files_map.get("/etc/ssh/ssh_config").unwrap().clone()).unwrap(); diff --git a/ublue/skillet/crates/hosts/beezelbot/src/main.rs b/ublue/skillet/crates/hosts/beezelbot/src/main.rs index 0c56465e..19d54a03 100644 --- a/ublue/skillet/crates/hosts/beezelbot/src/main.rs +++ b/ublue/skillet/crates/hosts/beezelbot/src/main.rs @@ -2,8 +2,8 @@ use anyhow::Result; use skillet_cli_common::{hosts, run_host}; fn main() -> Result<()> { - run_host("beezelbot", |system, files| { - hosts::apply_beezelbot(system, files).map_err(|e| e.to_string()) + run_host("beezelbot", |phase, system, files| { + hosts::apply_host_phase("beezelbot", phase, system, files).map_err(|e| e.to_string()) })?; Ok(()) } diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index 6d96bedc..14c41a56 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -2,8 +2,8 @@ use anyhow::Result; use skillet_cli_common::{hosts, run_host}; fn main() -> Result<()> { - run_host("clamps", |system, files| { - hosts::apply_clamps(system, files).map_err(|e| e.to_string()) + run_host("clamps", |phase, system, files| { + hosts::apply_host_phase("clamps", phase, system, files).map_err(|e| e.to_string()) })?; Ok(()) } diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 2f26e087..c53aaa5b 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -1,8 +1,9 @@ use askama::Template; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; -use skillet_core::templates::ensure_templated_file; -use skillet_podman::{self, ContainerUser, HostUser, PodmanError, Volume, PodmanConfig, QuadletSecret}; +use skillet_podman::{ + self, ContainerUser, HostUser, PodmanConfig, PodmanError, QuadletSecret, Volume, +}; use std::collections::BTreeMap; use std::path::Path; use thiserror::Error; @@ -73,10 +74,14 @@ where let template = CustomListTemplate { custom: custom_records, }; - ensure_templated_file( - files, + let custom_list = template.render().map_err(|error| { + FileError::Io(std::io::Error::other(format!( + "Template rendering failed: {error}" + ))) + })?; + files.ensure_file( &Path::new(root).join("conf/custom.list"), - &template, + custom_list.as_bytes(), Some(0o640), Some("root"), Some("root"), @@ -110,10 +115,7 @@ where ]; let mut extra_config = BTreeMap::new(); - extra_config.insert( - "Service".to_string(), - vec!["Restart=always".to_string()], - ); + extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); extra_config.insert( "Unit".to_string(), vec![ @@ -136,6 +138,7 @@ where create_host_user: false, volumes, secrets, + config_revisions: vec![custom_list.into_bytes()], extra_config, }, )?; diff --git a/ublue/skillet/crates/podman/Cargo.toml b/ublue/skillet/crates/podman/Cargo.toml index 97e8aed1..19105646 100644 --- a/ublue/skillet/crates/podman/Cargo.toml +++ b/ublue/skillet/crates/podman/Cargo.toml @@ -13,3 +13,8 @@ tracing.workspace = true serde.workspace = true askama.workspace = true users.workspace = true +sha2.workspace = true +hex.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 1bd6d909..6baff8ef 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -1,4 +1,5 @@ use askama::Template; +use sha2::{Digest, Sha256}; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; use std::collections::BTreeMap; @@ -80,7 +81,10 @@ impl QuadletSecret { s } SecretTarget::Environment { env_var_name } => { - format!("Secret={},type=env,target={}", self.secret_name, env_var_name) + format!( + "Secret={},type=env,target={}", + self.secret_name, env_var_name + ) } } } @@ -93,6 +97,8 @@ pub struct PodmanConfig { pub create_host_user: bool, pub volumes: Vec, pub secrets: Vec, + /// Content consumed at container startup outside the Quadlet definition. + pub config_revisions: Vec>, pub extra_config: BTreeMap>, } @@ -125,13 +131,9 @@ where container_section.push(format!("Image={}", config.image)); for vol in config.volumes { - let (owner, group) = if let Some((_, _, ref name)) = host_info { - (Some(name.as_str()), Some(name.as_str())) - } else { - (Some("root"), Some("root")) - }; - - files.ensure_directory(Path::new(&vol.host_path), Some(0o755), owner, group)?; + // The application owns volume metadata. Ensure only existence here so + // repeated applies cannot alternate ownership with its resource. + files.ensure_directory(Path::new(&vol.host_path), None, None, None)?; let mut vol_line = format!("Volume={}:{}", vol.host_path, vol.container_path); if let Some(opt) = vol.options { @@ -140,6 +142,11 @@ where container_section.push(vol_line); } + let secret_ids = config + .secrets + .iter() + .map(|secret| system.podman_secret_id(&secret.secret_name)) + .collect::, _>>()?; for secret in config.secrets { container_section.push(secret.to_directive()); } @@ -150,16 +157,14 @@ where } // 4. Render and ensure Quadlet file - let wants_enable = extra_config.contains_key("Install"); - let changed = render_and_ensure_quadlet(system, files, name, extra_config)?; - - // 5. A quadlet shipping an [Install] section is meant to persist across - // reboots; ensure it is enabled. `systemctl enable` is idempotent, so - // this is safe to run on every apply. - if wants_enable { - info!("Quadlet has [Install] section, enabling {name}"); - system.service_enable(name)?; - } + let changed = render_and_ensure_quadlet( + system, + files, + name, + extra_config, + &secret_ids, + &config.config_revisions, + )?; Ok(changed) } @@ -274,6 +279,8 @@ fn render_and_ensure_quadlet( files: &F, name: &str, sections: BTreeMap>, + secret_ids: &[String], + config_revisions: &[Vec], ) -> Result where S: SystemResource + ?Sized, @@ -286,6 +293,23 @@ where ))) })?; + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + for revision in config_revisions { + hasher.update((revision.len() as u64).to_le_bytes()); + hasher.update(revision); + } + for id in secret_ids { + hasher.update([0]); + hasher.update(id.as_bytes()); + } + let revision = hex::encode(hasher.finalize()); + let state_dir = Path::new("/var/lib/skillet/containers"); + files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; + let applied_path = state_dir.join(format!("{name}.applied")); + let applied = files.read_file(&applied_path)?; + let pending = applied.as_deref() != Some(revision.as_bytes()); + let quadlet_dir = Path::new("/etc/containers/systemd"); files.ensure_directory(quadlet_dir, Some(0o755), Some("root"), Some("root"))?; @@ -298,14 +322,26 @@ where Some("root"), )?; - if changed { - info!("Quadlet changed, triggering daemon-reload"); + if changed || pending { + info!("Quadlet activation pending, triggering daemon-reload"); system.daemon_reload()?; - // A daemon-reload alone leaves the old container running; restart - // so the new definition takes effect immediately. - info!("Restarting {name} to pick up the new quadlet definition"); + info!("Restarting {name} to consume the desired definition and secrets"); system.service_restart(name)?; + files.ensure_file( + &applied_path, + revision.as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?; + } else if !system.service_is_active(name)? { + info!("Starting inactive {name}"); + system.service_start(name)?; } Ok(changed) } + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs new file mode 100644 index 00000000..37f38837 --- /dev/null +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -0,0 +1,91 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +fn fixture() -> PodmanConfig { + let mut extra_config = BTreeMap::new(); + extra_config.insert( + "Install".to_string(), + vec!["WantedBy=multi-user.target".to_string()], + ); + PodmanConfig { + name: "unit-fixture".to_string(), + image: "example.invalid/fixture:1".to_string(), + user: ContainerUser { + container_uid: 0, + container_gid: 0, + host_user: None, + }, + create_host_user: false, + volumes: Vec::new(), + secrets: vec![QuadletSecret { + secret_name: "dummy".to_string(), + target: SecretTarget::File { + target_path: "/run/secrets/dummy".to_string(), + mode: None, + uid: None, + gid: None, + }, + }], + config_revisions: Vec::new(), + extra_config, + } +} + +#[test] +fn repeat_apply_and_stopped_service() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + assert!(container(&system, &files, fixture()).unwrap()); + assert!(!container(&system, &files, fixture()).unwrap()); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); + system.service_stop("unit-fixture").unwrap(); + assert!(!container(&system, &files, fixture()).unwrap()); + assert_eq!(system.start_count.load(Ordering::SeqCst), 1); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); +} + +#[test] +fn secret_rotation_and_interrupted_restart_recover() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + container(&system, &files, fixture()).unwrap(); + system.ensure_podman_secret("dummy", "second").unwrap(); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(container(&system, &files, fixture()).is_err()); + assert!(container(&system, &files, fixture()).is_ok()); + let after_recovery = system.restart_count.load(Ordering::SeqCst); + assert!(container(&system, &files, fixture()).is_ok()); + assert_eq!(system.restart_count.load(Ordering::SeqCst), after_recovery); +} + +#[test] +fn interrupted_reload_retries_unchanged_quadlet() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + system.fail_reload_once.store(true, Ordering::SeqCst); + assert!(container(&system, &files, fixture()).is_err()); + assert!(container(&system, &files, fixture()).is_ok()); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); +} + +#[test] +fn consumed_file_change_restarts_even_when_quadlet_is_unchanged() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let mut first = fixture(); + first.config_revisions = vec![b"one".to_vec()]; + container(&system, &files, first).unwrap(); + let mut second = fixture(); + second.config_revisions = vec![b"two".to_vec()]; + assert!(!container(&system, &files, second).unwrap()); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 2); + let mut repeat = fixture(); + repeat.config_revisions = vec![b"two".to_vec()]; + container(&system, &files, repeat).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 2); +} diff --git a/ublue/skillet/integration_tests/recordings/beezelbot.yaml b/ublue/skillet/integration_tests/recordings/beezelbot.yaml deleted file mode 100644 index 7cc20f21..00000000 --- a/ublue/skillet/integration_tests/recordings/beezelbot.yaml +++ /dev/null @@ -1,32 +0,0 @@ -- !EnsureDirectory - path: /etc/sysctl.d - mode: '0o755' - owner: root - group: root -- !EnsureFile - path: /etc/sysctl.d/99-hardening.conf - content_hash: c71e2f0edb84c44cfb601a2dc3d35df3b46afbbe9d28e02283a12d4b5f55b89d - mode: '0o644' - owner: root - group: root -- !ServiceRestart - name: systemd-sysctl -- !EnsureDirectory - path: /etc/ssh - mode: '0o755' - owner: root - group: root -- !EnsureFile - path: /etc/ssh/sshd_config - content_hash: '1355f199c4b2ed28c09c1cc2c7fc6fa44690f9b77d01412013f08118faa7b42b' - mode: '0o600' - owner: root - group: root -- !ServiceRestart - name: sshd -- !EnsureFile - path: /etc/ssh/ssh_config - content_hash: b1c686c7da8fcea74e83f6a2dbd5552f2fb16a58601f347058b5ba4529e6d602 - mode: '0o644' - owner: root - group: root diff --git a/ublue/skillet/integration_tests/recordings/clamps.yaml b/ublue/skillet/integration_tests/recordings/clamps.yaml deleted file mode 100644 index 121b4284..00000000 --- a/ublue/skillet/integration_tests/recordings/clamps.yaml +++ /dev/null @@ -1,95 +0,0 @@ -- !EnsureDirectory - path: /etc/sysctl.d - mode: '0o755' - owner: root - group: root -- !EnsureFile - path: /etc/sysctl.d/99-hardening.conf - content_hash: c71e2f0edb84c44cfb601a2dc3d35df3b46afbbe9d28e02283a12d4b5f55b89d - mode: '0o644' - owner: root - group: root -- !ServiceRestart - name: systemd-sysctl -- !EnsureDirectory - path: /etc/ssh - mode: '0o755' - owner: root - group: root -- !EnsureFile - path: /etc/ssh/sshd_config - content_hash: '1355f199c4b2ed28c09c1cc2c7fc6fa44690f9b77d01412013f08118faa7b42b' - mode: '0o600' - owner: root - group: root -- !ServiceRestart - name: sshd -- !EnsureFile - path: /etc/ssh/ssh_config - content_hash: b1c686c7da8fcea74e83f6a2dbd5552f2fb16a58601f347058b5ba4529e6d602 - mode: '0o644' - owner: root - group: root -- !EnsurePodmanSecret - name: pihole_web_password - payload_hash: '08c11e0cf8665e346fb2058d8e896a4a315ad839b0db208cd90fc92112071f79' -- !EnsureGroup - name: pihole -- !EnsureUser - name: pihole - uid: 40000 - gid: 40000 -- !EnsureDirectory - path: /etc/pihole - mode: '0o755' - owner: root - group: root -- !EnsureDirectory - path: /etc/pihole/conf - mode: '0o755' - owner: root - group: root -- !EnsureDirectory - path: /etc/pihole/dnsmasq.d - mode: '0o755' - owner: root - group: root -- !EnsureDirectory - path: /var/log/pihole - mode: '0o755' - owner: root - group: root -- !EnsureFile - path: /etc/pihole/conf/custom.list - content_hash: dbdc31014de7ebb475613c9258d7378edde4bb19fe61da7565cb8056370c8fa5 - mode: '0o640' - owner: root - group: root -- !EnsureDirectory - path: /etc/pihole/conf - mode: '0o755' - owner: pihole - group: pihole -- !EnsureDirectory - path: /etc/pihole/dnsmasq.d - mode: '0o755' - owner: pihole - group: pihole -- !EnsureDirectory - path: /var/log/pihole - mode: '0o755' - owner: pihole - group: pihole -- !EnsureDirectory - path: /etc/containers/systemd - mode: '0o755' - owner: root - group: root -- !EnsureFile - path: /etc/containers/systemd/pihole.container - content_hash: '8183a070ffaa22adefeb374aa059355890d1a4adf37ec4f863aae3e14d34115c' - mode: '0o644' - owner: root - group: root -- !ServiceReload - name: daemon-reload diff --git a/ublue/skillet/integration_tests/smoke-guest.sh b/ublue/skillet/integration_tests/smoke-guest.sh new file mode 100755 index 00000000..5926efff --- /dev/null +++ b/ublue/skillet/integration_tests/smoke-guest.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +phase=${1:?phase required} +binary=/var/lib/skillet/skillet-smoke +clamps_binary=${2:?clamps binary path required} +root=/var/lib/skillet-smoke +service=skillet-smoke-fixture.service +container=skillet-smoke-fixture + +fail() { + echo "smoke failed: $*" >&2 + systemctl --no-pager status "$service" >&2 || true + journalctl -u "$service" -n 30 --no-pager >&2 || true + exit 1 +} +trap 'fail "unexpected command at line $LINENO"' ERR + +apply() { + timeout 180s "$binary" apply --host skillet-smoke || fail "fixture apply" +} + +running() { + systemctl is-active --quiet "$service" || fail "service is inactive" + podman inspect "$container" >/dev/null || fail "container is missing" +} + +wait_running() { + local end=$((SECONDS + 60)) + while (( SECONDS < end )); do + if systemctl is-active --quiet "$service" && podman inspect "$container" >/dev/null 2>&1; then + return + fi + sleep 1 + done + fail "service did not start after reboot" +} + +observed() { + local wanted=$1 + local end=$((SECONDS + 30)) + while (( SECONDS < end )); do + if [[ -f "$root/data/observed-config" ]] && cmp -s "$root/data/observed-config" "$root/desired/config"; then + if [[ $wanted == config ]]; then return; fi + if [[ -f "$root/data/observed-secret-sha" ]] && + [[ $(sha256sum "$root/desired/secret" | cut -d ' ' -f 1) == $(cat "$root/data/observed-secret-sha") ]]; then + return + fi + fi + sleep 1 + done + fail "fixture did not consume desired $wanted" +} + +snapshot() { + sha256sum /etc/skillet-smoke/config /etc/skillet-smoke/entrypoint.sh /etc/containers/systemd/skillet-smoke-fixture.container + stat -c '%n %a %u %g' /etc/skillet-smoke /etc/skillet-smoke/config /etc/skillet-smoke/entrypoint.sh /etc/containers/systemd/skillet-smoke-fixture.container "$root/data" + systemctl show "$service" -p ActiveState -p Result + podman inspect --format '{{.Id}} {{.State.StartedAt}}' "$container" +} + +case "$phase" in + before-reboot) + [[ ! -e "$root/data/sentinel" ]] || fail "fixture data exists; use a fresh disposable VM" + mkdir -p "$root/desired" "$root/data" + touch "$root/allow-start" + printf 'revision-one\n' > "$root/desired/config" + printf 'dummy-secret-one\n' > "$root/desired/secret" + chmod 0600 "$root/desired/secret" + timeout 180s podman pull docker.io/library/alpine:3.20 >/dev/null || fail "fixture image pull" + + timeout 120s "$clamps_binary" apply --phase base || fail "base apply" + if env -u CREDENTIALS_DIRECTORY timeout 120s "$clamps_binary" apply >"$root/full-apply.stdout" 2>"$root/full-apply.stderr"; then + fail "full clamps apply accepted missing credentials" + fi + if ! grep -Eq 'CREDENTIALS_DIRECTORY|credential' "$root/full-apply.stderr"; then + fail "full clamps apply failed for an unexpected reason" + fi + + apply + running + observed secret + printf 'persistent-test-data\n' > "$root/data/sentinel" + snapshot > "$root/first.snapshot" + apply + running + observed secret + snapshot > "$root/repeat.snapshot" + cmp -s "$root/first.snapshot" "$root/repeat.snapshot" || fail "repeat apply changed managed state or container identity" + + systemctl stop "$service" || fail "stopping fixture" + apply + running + observed secret + + printf 'revision-two\n' > "$root/desired/config" + apply + running + observed secret + snapshot > "$root/config-change.snapshot" + apply + snapshot > "$root/config-repeat.snapshot" + cmp -s "$root/config-change.snapshot" "$root/config-repeat.snapshot" || fail "configuration repeat recreated container" + + printf 'dummy-secret-two\n' > "$root/desired/secret" + apply + running + observed secret + snapshot > "$root/secret-change.snapshot" + apply + snapshot > "$root/secret-repeat.snapshot" + cmp -s "$root/secret-change.snapshot" "$root/secret-repeat.snapshot" || fail "secret repeat recreated container" + + systemctl stop "$service" || fail "stopping fixture before startup failure" + rm "$root/allow-start" + if timeout 120s "$binary" apply --host skillet-smoke >"$root/startup-failure.stdout" 2>"$root/startup-failure.stderr"; then + fail "startup failure returned success" + fi + systemctl show "$service" -p ActiveState -p Result > "$root/startup-failure.systemd" + journalctl -u "$service" -n 30 --no-pager > "$root/startup-failure.journal" + grep -q 'Result=exit-code' "$root/startup-failure.systemd" || fail "startup failure did not report the injected exit code" + touch "$root/allow-start" + apply + running + observed secret + + rm "$root/allow-start" + printf 'revision-three\n' > "$root/desired/config" + if timeout 120s "$binary" apply --host skillet-smoke >"$root/interrupted.stdout" 2>"$root/interrupted.stderr"; then + fail "interrupted activation returned success" + fi + systemctl show "$service" -p ActiveState -p Result > "$root/interrupted.systemd" + journalctl -u "$service" -n 30 --no-pager > "$root/interrupted.journal" + grep -q 'Result=exit-code' "$root/interrupted.systemd" || fail "interrupted activation did not report the injected exit code" + touch "$root/allow-start" + apply + running + observed secret + snapshot > "$root/pre-reboot.snapshot" + echo "pre-reboot smoke cases passed; evidence: $root/*.snapshot, $root/*.systemd and $root/*.journal" + ;; + after-reboot) + [[ $(cat "$root/data/sentinel") == persistent-test-data ]] || fail "persistent volume lost data" + wait_running + observed secret + snapshot > "$root/post-reboot.snapshot" + apply + snapshot > "$root/post-apply.snapshot" + cmp -s "$root/post-reboot.snapshot" "$root/post-apply.snapshot" || fail "post-reboot apply recreated container" + echo "all fixture smoke cases passed; evidence: $root/*.snapshot" + ;; + *) echo "unknown phase: $phase" >&2; exit 2 ;; +esac diff --git a/ublue/skillet/integration_tests/smoke-ssh.sh b/ublue/skillet/integration_tests/smoke-ssh.sh new file mode 100755 index 00000000..6047ce3c --- /dev/null +++ b/ublue/skillet/integration_tests/smoke-ssh.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +target= +binary= +clamps_binary= +port=22 +identity= +disposable=false +while (($#)); do + case "$1" in + --target) target=${2:?}; shift 2 ;; + --binary) binary=${2:?}; shift 2 ;; + --clamps-binary) clamps_binary=${2:?}; shift 2 ;; + --port) port=${2:?}; shift 2 ;; + --identity) identity=${2:?}; shift 2 ;; + --disposable-target) disposable=true; shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +if [[ -z $target || -z $binary || -z $clamps_binary || $disposable != true ]]; then + echo 'usage: smoke-ssh.sh --target USER@DISPOSABLE_VM [--port PORT] [--identity PRIVATE_KEY] --disposable-target --binary LOCAL_SKILLET --clamps-binary REMOTE_SKILLET_CLAMPS' >&2 + exit 2 +fi +if [[ ! $target =~ ^[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+$ ]]; then + echo 'target must be an explicit USER@HOST or USER@IPv4 address' >&2 + exit 2 +fi +if [[ $target == *@clamps || $target == *@clamps.* ]] || + { [[ $target == *@localhost || $target == *@127.0.0.1 ]] && [[ $port == 22 ]]; }; then + echo 'refusing production clamps or a local SSH service on port 22' >&2 + exit 2 +fi +if [[ ! -f $binary || $clamps_binary != /* || ! $clamps_binary =~ ^/[A-Za-z0-9_./-]+$ ]]; then + echo 'binary paths must name a local file and an absolute remote path' >&2 + exit 2 +fi +if [[ ! $port =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then + echo 'port must be between 1 and 65535' >&2 + exit 2 +fi +if [[ -n $identity && ! -f $identity ]]; then + echo 'identity must name an existing private-key file' >&2 + exit 2 +fi + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +ssh_opts=(-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -p "$port") +scp_opts=(-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -P "$port") +if [[ -n $identity ]]; then + known_hosts="$(dirname -- "$identity")/known_hosts" + ssh_opts+=(-i "$identity" -o IdentitiesOnly=yes -o UserKnownHostsFile="$known_hosts") + scp_opts+=(-i "$identity" -o IdentitiesOnly=yes -o UserKnownHostsFile="$known_hosts") +fi +scp "${scp_opts[@]}" "$binary" "$target:/var/tmp/skillet-smoke" +scp "${scp_opts[@]}" "$script_dir/smoke-guest.sh" "$target:/var/tmp/skillet-smoke-guest.sh" +ssh "${ssh_opts[@]}" "$target" 'sudo install -m 0755 /var/tmp/skillet-smoke /var/lib/skillet/skillet-smoke' +boot_before=$(ssh "${ssh_opts[@]}" "$target" 'cat /proc/sys/kernel/random/boot_id') +# The remote path is validated above and intentionally expanded on the client. +# shellcheck disable=SC2029 +ssh "${ssh_opts[@]}" "$target" "sudo bash /var/tmp/skillet-smoke-guest.sh before-reboot '$clamps_binary'" +ssh "${ssh_opts[@]}" "$target" 'sudo systemctl reboot' || true + +deadline=$((SECONDS + 240)) +while (( SECONDS < deadline )); do + boot_after=$(ssh "${ssh_opts[@]}" "$target" 'cat /proc/sys/kernel/random/boot_id' 2>/dev/null || true) + if [[ -n $boot_after && $boot_after != "$boot_before" ]]; then + # shellcheck disable=SC2029 + ssh "${ssh_opts[@]}" "$target" "sudo bash /var/tmp/skillet-smoke-guest.sh after-reboot '$clamps_binary'" + exit 0 + fi + sleep 5 +done +echo 'VM did not return after reboot within 240 seconds' >&2 +exit 1 From d36a22ed059fb457d6b7aaa1cda084f03548d076 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 12:40:27 +0200 Subject: [PATCH 068/252] skillet: restore integration test runner command --- ublue/skillet/Cargo.toml | 2 +- ublue/skillet/README.md | 24 ++ ublue/skillet/crates/cli/src/main.rs | 279 +++++++++++++++++- .../skillet/crates/cli/src/test_entrypoint.sh | 46 +++ 4 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/test_entrypoint.sh diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 125d09e5..74792f0a 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -21,7 +21,7 @@ thiserror = "1.0" sha2 = "0.10" users = "0.11" nix = { version = "0.29", features = ["user", "fs"] } -clap = { version = "4.4", features = ["derive"] } +clap = { version = "4.4", features = ["derive", "env"] } tracing = "0.1" tracing-subscriber = "0.3" tempfile = "3.8" diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index f47019c0..d7a83298 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -42,6 +42,30 @@ to converge only the shared host baseline before credentials are available. ## Disposable VM smoke check +The legacy CI command `skillet test run beezelbot --image fedora:latest` runs +the host apply twice in a uniquely named, temporary Podman container. It +builds the selected host binary, checks both applies succeed, and checks the +second apply does not start or restart a service. `clamps` is also supported. +This fast sandbox uses mock `systemctl` and `podman` executables, so it checks +apply behavior and repeat convergence but does not establish real systemd or +Podman operation. `--inspect` opens the container before cleanup. + +For real systemd/Podman and reboot coverage, use `skillet test smoke clamps`. +Configure its disposable target and artifacts with options or environment +variables: + +```bash +SKILLET_TEST_TARGET=core@127.0.0.1 \ +SKILLET_TEST_PORT=2201 \ +SKILLET_TEST_IDENTITY=../butane/runs/clamps-test-example/ssh/id_ed25519 \ +SKILLET_TEST_BINARY=target/x86_64-unknown-linux-musl/release/skillet \ +SKILLET_TEST_CLAMPS_BINARY=/var/lib/skillet/skillet-clamps \ +target/x86_64-unknown-linux-musl/release/skillet test smoke clamps +``` + +The VM target is always explicit and disposable; the command does not infer a +VM or fall back to local `cargo test`. The VM fixture controls its own image. + Build both static binaries from this directory: ```bash diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 235f3720..ed739683 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,8 +1,8 @@ use anyhow::{anyhow, Context, Result}; use clap::Parser; use skillet_cli_common::hosts::ApplyPhase; -use std::path::PathBuf; -use tracing::Level; +use std::{fs, path::PathBuf, process::Command}; +use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; #[derive(Parser, Debug)] @@ -28,6 +28,53 @@ enum Commands { #[arg(long)] record: Option, }, + /// Run the real-runtime smoke scenario against a disposable VM + Test { + #[command(subcommand)] + command: TestCommands, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum TestCommands { + /// Apply configuration twice in a fresh disposable Podman container + Run(ContainerArgs), + /// Exercise the real-systemd VM scenario using an explicit disposable SSH target + Smoke(SmokeArgs), +} + +#[derive(clap::Args, Debug)] +struct ContainerArgs { + hostname: String, + #[arg(long, default_value = "fedora:latest")] + image: String, + #[arg(long)] + inspect: bool, +} + +#[derive(clap::Args, Debug)] +struct SmokeArgs { + /// Host configuration to exercise (currently `clamps`) + hostname: String, + /// Explicit disposable SSH target, USER@HOST + #[arg(long, env = "SKILLET_TEST_TARGET")] + target: String, + /// SSH port + #[arg(long, default_value_t = 22, env = "SKILLET_TEST_PORT")] + port: u16, + /// SSH private key + #[arg(long, env = "SKILLET_TEST_IDENTITY")] + identity: Option, + /// Local generic Skillet binary to upload + #[arg(long, env = "SKILLET_TEST_BINARY")] + binary: PathBuf, + /// Host-specific clamps binary already installed in the VM + #[arg(long, env = "SKILLET_TEST_CLAMPS_BINARY")] + clamps_binary: PathBuf, + /// Retained for compatibility with the former container runner. The VM + /// smoke fixture selects its own image and does not use this value. + #[arg(long, default_value = "fedora:latest")] + image: String, } fn main() -> Result<()> { @@ -62,6 +109,234 @@ fn main() -> Result<()> { }) .map_err(|error| anyhow!("Failed to apply configuration: {error}"))?; } + Commands::Test { + command: TestCommands::Run(args), + } => run_container_test(&args)?, + Commands::Test { + command: TestCommands::Smoke(args), + } => run_smoke(&args)?, + } + Ok(()) +} + +fn run_smoke(args: &SmokeArgs) -> Result<()> { + if args.hostname != "clamps" { + return Err(anyhow!( + "the VM smoke scenario currently supports only hostname `clamps`" + )); + } + if args.image != "fedora:latest" { + return Err(anyhow!("--image is retained for command compatibility; the VM smoke scenario controls its fixture image and does not accept a container image")); + } + let script = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../integration_tests/smoke-ssh.sh"); + if !script.is_file() { + return Err(anyhow!("smoke runner not found at {}", script.display())); + } + let status = std::process::Command::new("bash") + .arg(script) + .args(["--target", &args.target, "--disposable-target", "--port"]) + .arg(args.port.to_string()) + .args(["--binary"]) + .arg(&args.binary) + .args(["--clamps-binary"]) + .arg(&args.clamps_binary) + .args(args.identity.as_ref().map(|_| "--identity")) + .args(args.identity.as_deref()) + .status() + .context("starting disposable-VM smoke runner failed")?; + if !status.success() { + return Err(anyhow!( + "disposable-VM smoke scenario failed with status {status}" + )); + } + Ok(()) +} + +fn workspace_root() -> Result { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + loop { + if path.join("integration_tests").is_dir() && path.join("Cargo.toml").is_file() { + return Ok(path); + } + if !path.pop() { + return Err(anyhow!("could not locate workspace root")); + } + } +} + +fn run_container_test(args: &ContainerArgs) -> Result<()> { + if !matches!(args.hostname.as_str(), "beezelbot" | "clamps") { + return Err(anyhow!( + "unsupported host {}; expected beezelbot or clamps", + args.hostname + )); + } + let root = workspace_root()?; + let package = format!("skillet-{}", args.hostname); + let status = Command::new("cargo") + .current_dir(&root) + .args(["build", "--package", &package]) + .status() + .context("building host binary failed")?; + if !status.success() { + return Err(anyhow!("cargo build --package {package} failed")); + } + let mut binary = None; + for subdir in [ + "target/x86_64-unknown-linux-musl/debug", + "target/debug", + "target/x86_64-unknown-linux-musl/release", + "target/release", + ] { + let candidate = root.join(subdir).join(&package); + if candidate.is_file() { + binary = Some(fs::canonicalize(candidate)?); + break; + } + } + let binary = binary.ok_or_else(|| anyhow!("built binary {package} not found under target"))?; + let id = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + ); + let name = format!("skillet-test-{}-{id}", args.hostname); + let creds = root + .join("target") + .join(format!("skillet-test-credentials-{id}")); + fs::create_dir_all(&creds)?; + fs::write( + creds.join("pihole_web_password"), + "skillet-test-dummy-secret", + )?; + let start = Command::new("podman") + .args(["run", "-d", "--rm", "--name", &name, "-v"]) + .arg(format!("{}:/usr/bin/skillet:ro", binary.display())) + .arg("-v") + .arg(format!("{}:/run/credentials:ro", creds.display())) + .args([ + "-e", + "CREDENTIALS_DIRECTORY=/run/credentials", + &args.image, + "sleep", + "infinity", + ]) + .status(); + let start = match start { + Ok(status) => status, + Err(error) => { + fs::remove_dir_all(&creds) + .context("cleaning test credentials after Podman error failed")?; + return Err(error).context("starting isolated Podman container failed"); + } + }; + if !start.success() { + fs::remove_dir_all(&creds) + .context("cleaning test credentials after Podman start failure failed")?; + return Err(anyhow!("Podman failed to start {name}")); + } + let result = run_twice_and_check(&name, args.inspect); + let cleanup = Command::new("podman") + .args(["rm", "-f", &name]) + .status() + .context("removing disposable test container failed"); + let credential_cleanup = + fs::remove_dir_all(&creds).context("removing disposable test credentials failed"); + result?; + if !cleanup?.success() { + return Err(anyhow!("failed to remove own container {name}")); + } + credential_cleanup?; + Ok(()) +} + +fn run_twice_and_check(name: &str, inspect: bool) -> Result<()> { + let entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_entrypoint.sh"); + for round in ["first", "second"] { + let status = Command::new("podman") + .args(["exec", name, "/bin/sh", "-s", "--", round]) + .stdin(fs::File::open(&entry)?) + .status() + .context("running apply in container failed")?; + if !status.success() { + return Err(anyhow!("{round} apply failed in {name}")); + } + } + let first = podman_capture( + name, + "sed -n '1,/^SECOND$/p' /tmp/skillet-test.systemctl.log", + )?; + let second = podman_capture( + name, + "sed -n '/^SECOND$/,$p' /tmp/skillet-test.systemctl.log", + )?; + if first.trim().is_empty() { + return Err(anyhow!("first apply emitted no systemd operations")); + } + if second + .lines() + .any(|line| line.starts_with("start ") || line.starts_with("restart ")) + { + return Err(anyhow!("second apply restarted a service: {second}")); + } + if inspect { + let status = Command::new("podman") + .args(["exec", "-it", name, "/bin/bash"]) + .status() + .context("starting inspection shell failed")?; + if !status.success() { + return Err(anyhow!("inspection shell failed")); + } } + info!("Both applies succeeded; repeat apply issued no service start/restart"); Ok(()) } + +fn podman_capture(name: &str, shell: &str) -> Result { + let out = Command::new("podman") + .args(["exec", name, "/bin/sh", "-c", shell]) + .output()?; + if !out.status.success() { + return Err(anyhow!("failed to inspect apply operation log")); + } + String::from_utf8(out.stdout).context("operation log was not UTF-8") +} + +#[cfg(test)] +mod tests { + use super::Args; + use clap::Parser; + + #[test] + fn test_run_accepts_legacy_command() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "run", + "beezelbot", + "--image", + "fedora:latest", + ]); + assert!(parsed.is_ok()); + } + + #[test] + fn test_smoke_accepts_explicit_target() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "smoke", + "clamps", + "--target", + "core@192.0.2.5", + "--binary", + "target/skillet", + "--clamps-binary", + "/var/lib/skillet/skillet-clamps", + ]); + assert!(parsed.is_ok()); + } +} diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh new file mode 100644 index 00000000..b352d4fa --- /dev/null +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu +round=$1 +mock=/tmp/skillet-test-mocks +state=/tmp/skillet-test-state +mkdir -p "$mock" "$state" +if [ ! -x "$mock/systemctl" ]; then + cat >"$mock/systemctl" <<'MOCK' +#!/bin/sh +set -eu +printf '%s\n' "$*" >> /tmp/skillet-test.systemctl.log +case "$1" in + is-active) [ -f "/tmp/skillet-test-state/active-$3" ] ;; + start|restart) touch "/tmp/skillet-test-state/active-$2" ;; + enable|daemon-reload|stop|reload) exit 0 ;; + *) exit 2 ;; +esac +MOCK + cat >"$mock/podman" <<'MOCK' +#!/bin/sh +set -eu +printf '%s\n' "$*" >> /tmp/skillet-test.podman.log +case "$1 $2" in + "secret exists") [ -f /tmp/skillet-test-state/secret ] ;; + "secret inspect") + case "$*" in *'{{.ID}}'*) printf 'skillet-test-secret-id\n' ;; *) cat /tmp/skillet-test-state/secret-hash ;; esac ;; + ;; + "secret create") + shift 2 + hash= + while [ "$#" -gt 0 ]; do + case "$1" in --replace) shift ;; --label) hash=${2#skillet.payload_hash=}; shift 2 ;; *) break ;; esac + done + name=$1 + cat > /tmp/skillet-test-state/secret-payload + printf '%s\n' "$hash" > /tmp/skillet-test-state/secret-hash + touch /tmp/skillet-test-state/secret + ;; + *) exit 2 ;; +esac +MOCK + chmod +x "$mock/systemctl" "$mock/podman" +fi +export PATH="$mock:$PATH" +if [ "$round" = second ]; then printf '\nSECOND\n' >> /tmp/skillet-test.systemctl.log; fi +/usr/bin/skillet apply From 13d1fc3d1af3ea5d3e762b74fce38a1db5b98309 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 12:46:28 +0200 Subject: [PATCH 069/252] skillet: auto-select smoke test binaries --- ublue/skillet/README.md | 26 ++++--------- ublue/skillet/crates/cli/src/main.rs | 56 +++++++++++++++++----------- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index d7a83298..7cd84df1 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -51,22 +51,24 @@ apply behavior and repeat convergence but does not establish real systemd or Podman operation. `--inspect` opens the container before cleanup. For real systemd/Podman and reboot coverage, use `skillet test smoke clamps`. -Configure its disposable target and artifacts with options or environment -variables: +It takes the generic binary from the running executable and builds the +matching `skillet-clamps` binary automatically. Only the disposable target +and optional SSH settings need configuration: ```bash SKILLET_TEST_TARGET=core@127.0.0.1 \ SKILLET_TEST_PORT=2201 \ SKILLET_TEST_IDENTITY=../butane/runs/clamps-test-example/ssh/id_ed25519 \ -SKILLET_TEST_BINARY=target/x86_64-unknown-linux-musl/release/skillet \ -SKILLET_TEST_CLAMPS_BINARY=/var/lib/skillet/skillet-clamps \ target/x86_64-unknown-linux-musl/release/skillet test smoke clamps ``` The VM target is always explicit and disposable; the command does not infer a -VM or fall back to local `cargo test`. The VM fixture controls its own image. +VM or fall back to local `cargo test`. It requires the Skillet workspace and +Cargo so it can build the host binary alongside the running executable. The +VM fixture controls its own image. -Build both static binaries from this directory: +Build both static binaries from this directory if you want to install the host +binary separately: ```bash cargo build --release -p skillet-clamps -p skillet @@ -74,19 +76,7 @@ sha256sum target/x86_64-unknown-linux-musl/release/skillet-clamps ``` The host artifact is `target/x86_64-unknown-linux-musl/release/skillet-clamps`. -The current artifact SHA256 is -`55bf7584ce405dcf2c20956c182f4dbfe04bfbb20a9b649e48ab97877d9e8856`. Supply that artifact to the guest, then invoke `skillet-clamps apply --phase base`. -The generic binary is used only by the disposable fixture smoke check: - -```bash -integration_tests/smoke-ssh.sh \ - --target core@127.0.0.1 --disposable-target \ - --port 2201 \ - --identity ../butane/runs/clamps-test-example/ssh/id_ed25519 \ - --binary target/x86_64-unknown-linux-musl/release/skillet \ - --clamps-binary /var/lib/skillet/skillet-clamps -``` The target and disposable opt-in are mandatory. The script checks baseline/full separation, container startup and idempotency, configuration and dummy secret diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index ed739683..16569898 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -65,16 +65,6 @@ struct SmokeArgs { /// SSH private key #[arg(long, env = "SKILLET_TEST_IDENTITY")] identity: Option, - /// Local generic Skillet binary to upload - #[arg(long, env = "SKILLET_TEST_BINARY")] - binary: PathBuf, - /// Host-specific clamps binary already installed in the VM - #[arg(long, env = "SKILLET_TEST_CLAMPS_BINARY")] - clamps_binary: PathBuf, - /// Retained for compatibility with the former container runner. The VM - /// smoke fixture selects its own image and does not use this value. - #[arg(long, default_value = "fedora:latest")] - image: String, } fn main() -> Result<()> { @@ -125,11 +115,39 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { "the VM smoke scenario currently supports only hostname `clamps`" )); } - if args.image != "fedora:latest" { - return Err(anyhow!("--image is retained for command compatibility; the VM smoke scenario controls its fixture image and does not accept a container image")); + let root = workspace_root()?; + let binary = std::env::current_exe().context("locating the running Skillet binary failed")?; + let profile_dir = binary + .parent() + .ok_or_else(|| anyhow!("Skillet binary has no parent directory"))?; + let profile = profile_dir.file_name().and_then(|name| name.to_str()); + let package = format!("skillet-{}", args.hostname); + let mut build = Command::new("cargo"); + build + .current_dir(&root) + .args(["build", "--package", &package]); + if profile == Some("release") { + build.arg("--release"); + } + let status = build + .status() + .context("building the clamps host binary failed")?; + if !status.success() { + return Err(anyhow!("building the clamps host binary failed")); } - let script = - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../integration_tests/smoke-ssh.sh"); + let host_binary = [ + profile_dir.join(&package), + root.join("target/x86_64-unknown-linux-musl") + .join(profile.unwrap_or("debug")) + .join(&package), + root.join("target") + .join(profile.unwrap_or("debug")) + .join(&package), + ] + .into_iter() + .find(|candidate| candidate.is_file()) + .ok_or_else(|| anyhow!("built host binary {package} was not found"))?; + let script = root.join("integration_tests/smoke-ssh.sh"); if !script.is_file() { return Err(anyhow!("smoke runner not found at {}", script.display())); } @@ -138,9 +156,9 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { .args(["--target", &args.target, "--disposable-target", "--port"]) .arg(args.port.to_string()) .args(["--binary"]) - .arg(&args.binary) + .arg(&binary) .args(["--clamps-binary"]) - .arg(&args.clamps_binary) + .arg(&host_binary) .args(args.identity.as_ref().map(|_| "--identity")) .args(args.identity.as_deref()) .status() @@ -324,7 +342,7 @@ mod tests { } #[test] - fn test_smoke_accepts_explicit_target() { + fn test_smoke_autoselects_binaries_from_hostname() { let parsed = Args::try_parse_from([ "skillet", "test", @@ -332,10 +350,6 @@ mod tests { "clamps", "--target", "core@192.0.2.5", - "--binary", - "target/skillet", - "--clamps-binary", - "/var/lib/skillet/skillet-clamps", ]); assert!(parsed.is_ok()); } From b1556e1995bdb9aab9536769f2e3c676622f23e7 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 13:12:20 +0200 Subject: [PATCH 070/252] skillet: manage disposable smoke VM lifecycle --- ublue/skillet/Cargo.toml | 2 +- ublue/skillet/README.md | 54 ++++--- ublue/skillet/crates/cli/src/main.rs | 144 +++++++++++++++++- .../skillet/integration_tests/smoke-guest.sh | 9 +- 4 files changed, 180 insertions(+), 29 deletions(-) diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 74792f0a..125d09e5 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -21,7 +21,7 @@ thiserror = "1.0" sha2 = "0.10" users = "0.11" nix = { version = "0.29", features = ["user", "fs"] } -clap = { version = "4.4", features = ["derive", "env"] } +clap = { version = "4.4", features = ["derive"] } tracing = "0.1" tracing-subscriber = "0.3" tempfile = "3.8" diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 7cd84df1..1cbac059 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -50,22 +50,36 @@ This fast sandbox uses mock `systemctl` and `podman` executables, so it checks apply behavior and repeat convergence but does not establish real systemd or Podman operation. `--inspect` opens the container before cleanup. -For real systemd/Podman and reboot coverage, use `skillet test smoke clamps`. -It takes the generic binary from the running executable and builds the -matching `skillet-clamps` binary automatically. Only the disposable target -and optional SSH settings need configuration: +For real systemd/Podman and reboot coverage, create a disposable VM first: ```bash -SKILLET_TEST_TARGET=core@127.0.0.1 \ -SKILLET_TEST_PORT=2201 \ -SKILLET_TEST_IDENTITY=../butane/runs/clamps-test-example/ssh/id_ed25519 \ -target/x86_64-unknown-linux-musl/release/skillet test smoke clamps +cargo run --release -p skillet -- test vm create ``` -The VM target is always explicit and disposable; the command does not infer a -VM or fall back to local `cargo test`. It requires the Skillet workspace and -Cargo so it can build the host binary alongside the running executable. The -VM fixture controls its own image. +This provisions `clamps-test-smoke` on `core@127.0.0.1:2201`, creates its SSH +key, and waits for the guest to pass `clamps-ready`. Then run the smoke scenario: + +```bash +cargo run --release -p skillet -- test smoke clamps +``` + +The smoke command defaults to that target, port, and generated key. Override +them with `--target`, `--port`, and `--identity` when using a separately +provisioned VM. It takes the generic binary from the running executable and +builds the matching `skillet-clamps` binary automatically. Repeated smoke runs +reset only the namespaced `/var/lib/skillet-smoke` fixture and its managed +files, so you can inspect a run and rerun it on the same disposable VM. + +When finished, remove the disposable VM, disk, SSH key, and run artifacts: + +```bash +cargo run --release -p skillet -- test vm destroy +``` + +The VM helpers require the host setup documented in `../butane/README.md`. +The VM fixture controls its own image. Smoke still requires the Skillet +workspace and Cargo so it can build the host binary alongside the running +executable. Build both static binaries from this directory if you want to install the host binary separately: @@ -78,14 +92,14 @@ sha256sum target/x86_64-unknown-linux-musl/release/skillet-clamps The host artifact is `target/x86_64-unknown-linux-musl/release/skillet-clamps`. Supply that artifact to the guest, then invoke `skillet-clamps apply --phase base`. -The target and disposable opt-in are mandatory. The script checks baseline/full -separation, container startup and idempotency, configuration and dummy secret -rotation, failed startup recovery, interrupted activation, and reboot -persistence. It writes state snapshots and failure output under -`/var/lib/skillet-smoke/` in the guest. Run it on a fresh disposable VM; its -fixture unit and data use only `skillet-smoke` names. The guest needs passwordless -sudo for the SSH user and access to pull `docker.io/library/alpine:3.20`. -When `--identity` is supplied, SSH stores that VM's host key beside the private key. +The smoke scenario checks baseline/full separation, container startup and +idempotency, configuration and dummy secret rotation, failed startup recovery, +interrupted activation, and reboot persistence. It writes state snapshots and +failure output under `/var/lib/skillet-smoke/` in the guest. It resets only its +own fixture files before each run, so repeated runs on the same disposable VM +are supported. The guest needs passwordless sudo for the SSH user and access to +pull `docker.io/library/alpine:3.20`. When `--identity` is supplied, SSH stores +that VM's host key beside the private key. ## Architectural Mandates - **Error Handling**: Use `thiserror` in library crates; `anyhow` is reserved for CLI binaries. No `unwrap()` or `expect()` in library code. diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 16569898..98d7186f 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,7 +1,11 @@ use anyhow::{anyhow, Context, Result}; use clap::Parser; use skillet_cli_common::hosts::ApplyPhase; -use std::{fs, path::PathBuf, process::Command}; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; @@ -41,6 +45,33 @@ enum TestCommands { Run(ContainerArgs), /// Exercise the real-systemd VM scenario using an explicit disposable SSH target Smoke(SmokeArgs), + /// Create or destroy the default disposable clamps VM + Vm { + #[command(subcommand)] + command: VmCommands, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum VmCommands { + /// Provision a disposable clamps VM and wait for it to become ready + Create(VmCreateArgs), + /// Destroy the disposable clamps VM and remove its temporary key and artifacts + Destroy(VmDestroyArgs), +} + +#[derive(clap::Args, Debug)] +struct VmCreateArgs { + #[arg(long, default_value = "clamps-test-smoke")] + name: String, + #[arg(long, default_value_t = 2201)] + port: u16, +} + +#[derive(clap::Args, Debug)] +struct VmDestroyArgs { + #[arg(long, default_value = "clamps-test-smoke")] + name: String, } #[derive(clap::Args, Debug)] @@ -57,13 +88,13 @@ struct SmokeArgs { /// Host configuration to exercise (currently `clamps`) hostname: String, /// Explicit disposable SSH target, USER@HOST - #[arg(long, env = "SKILLET_TEST_TARGET")] + #[arg(long, default_value = "core@127.0.0.1")] target: String, /// SSH port - #[arg(long, default_value_t = 22, env = "SKILLET_TEST_PORT")] + #[arg(long, default_value_t = 2201)] port: u16, - /// SSH private key - #[arg(long, env = "SKILLET_TEST_IDENTITY")] + /// SSH private key (defaults to the key generated for `test vm create`) + #[arg(long)] identity: Option, } @@ -105,6 +136,18 @@ fn main() -> Result<()> { Commands::Test { command: TestCommands::Smoke(args), } => run_smoke(&args)?, + Commands::Test { + command: + TestCommands::Vm { + command: VmCommands::Create(args), + }, + } => run_vm_create(&args)?, + Commands::Test { + command: + TestCommands::Vm { + command: VmCommands::Destroy(args), + }, + } => run_vm_destroy(&args)?, } Ok(()) } @@ -116,6 +159,17 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { )); } let root = workspace_root()?; + let identity = args.identity.clone().unwrap_or_else(|| { + root.parent() + .unwrap_or(&root) + .join("butane/runs/clamps-test-smoke/ssh/id_ed25519") + }); + if !identity.is_file() { + return Err(anyhow!( + "SSH key not found at {}; create the default VM with `skillet test vm create` or pass --identity", + identity.display() + )); + } let binary = std::env::current_exe().context("locating the running Skillet binary failed")?; let profile_dir = binary .parent() @@ -159,8 +213,8 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { .arg(&binary) .args(["--clamps-binary"]) .arg(&host_binary) - .args(args.identity.as_ref().map(|_| "--identity")) - .args(args.identity.as_deref()) + .args(["--identity"]) + .arg(&identity) .status() .context("starting disposable-VM smoke runner failed")?; if !status.success() { @@ -171,6 +225,82 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { Ok(()) } +fn run_vm_create(args: &VmCreateArgs) -> Result<()> { + if !valid_test_vm_name(&args.name) { + return Err(anyhow!( + "VM name must start with clamps-test- and contain lowercase letters, digits, or hyphens" + )); + } + if !(2200..=2299).contains(&args.port) { + return Err(anyhow!("VM SSH port must be between 2200 and 2299")); + } + let butane = butane_root()?; + let helper = butane.join("bin/clamps-vm"); + let port = args.port.to_string(); + run_helper(&helper, &["create", &args.name, &port])?; + + let run_dir = butane.join("runs").join(&args.name); + let ready = butane.join("bin/clamps-ready"); + let run_dir_arg = run_dir + .to_str() + .ok_or_else(|| anyhow!("VM run path is not valid UTF-8"))?; + if let Err(error) = run_helper(&ready, &[run_dir_arg]) { + return Err(anyhow!( + "VM created but readiness failed; inspect it with `clamps-vm logs {}` or destroy it with `skillet test vm destroy --name {}`: {error}", + args.name, args.name + )); + } + info!( + "Disposable VM {} is ready at core@127.0.0.1:{}; smoke key: {}", + args.name, + args.port, + run_dir.join("ssh/id_ed25519").display() + ); + Ok(()) +} + +fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { + if !valid_test_vm_name(&args.name) { + return Err(anyhow!("VM name must start with clamps-test-")); + } + let helper = butane_root()?.join("bin/clamps-vm"); + run_helper(&helper, &["destroy", &args.name]) +} + +fn valid_test_vm_name(name: &str) -> bool { + name.len() > "clamps-test-".len() + && name.starts_with("clamps-test-") + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn run_helper(path: &Path, args: &[&str]) -> Result<()> { + let status = Command::new(path) + .args(args) + .status() + .with_context(|| format!("running {} failed", path.display()))?; + if !status.success() { + return Err(anyhow!("{} failed with status {status}", path.display())); + } + Ok(()) +} + +fn butane_root() -> Result { + let root = workspace_root()?; + let butane = root + .parent() + .ok_or_else(|| anyhow!("Skillet workspace has no parent directory"))? + .join("butane"); + if !butane.join("bin/clamps-vm").is_file() || !butane.join("bin/clamps-ready").is_file() { + return Err(anyhow!( + "clamps VM helpers not found under {}; check out the sibling butane directory", + butane.display() + )); + } + Ok(butane) +} + fn workspace_root() -> Result { let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); loop { diff --git a/ublue/skillet/integration_tests/smoke-guest.sh b/ublue/skillet/integration_tests/smoke-guest.sh index 5926efff..097f3892 100755 --- a/ublue/skillet/integration_tests/smoke-guest.sh +++ b/ublue/skillet/integration_tests/smoke-guest.sh @@ -61,7 +61,14 @@ snapshot() { case "$phase" in before-reboot) - [[ ! -e "$root/data/sentinel" ]] || fail "fixture data exists; use a fresh disposable VM" + # The smoke fixture is namespaced and the target was explicitly opted in + # as disposable. Reset only this fixture so `test smoke` can be rerun on + # the same VM after inspecting a previous run. + systemctl stop "$service" >/dev/null 2>&1 || true + systemctl disable "$service" >/dev/null 2>&1 || true + podman rm -f "$container" >/dev/null 2>&1 || true + rm -rf "$root" /etc/skillet-smoke /etc/containers/systemd/skillet-smoke-fixture.container + systemctl daemon-reload mkdir -p "$root/desired" "$root/data" touch "$root/allow-start" printf 'revision-one\n' > "$root/desired/config" From 8019d669d9bee7c2a7645231166587aec540941b Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 17:34:34 +0200 Subject: [PATCH 071/252] butane: avoid oversized Ignition fw_cfg payload --- ublue/butane/ACCEPTANCE.md | 10 ++++++++ ublue/butane/README.md | 12 ++++++---- ublue/butane/bin/clamps-ready | 41 ++++++++++++++++++++++++++++++--- ublue/butane/bin/coreos-install | 9 ++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index cfa5a91c..b8e9b3dc 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -25,6 +25,16 @@ install. a successful guest boot. The helper now selects a separate native libvirt runtime to avoid mixing native and Flatpak daemon filesystem views; that approach still requires end-to-end validation. +- `runs/clamps-test-smoke` created a running VM, but SSH on port 2201 never + became available. The serial console showed Ignition still reading the + 2.5 MiB `fw_cfg` payload after about 30 host minutes; the dominant + embedded entry was the 4.9 MiB `skillet-clamps` binary. Ignition documents + that large QEMU `fw_cfg` configs can take an unreasonable time to read + ([release notes](https://coreos.github.io/ignition/release-notes/)). The VM + launcher now omits that binary from the VM-only Ignition config and + `clamps-ready` transfers it over SSH after first boot. This fix still needs + validation on a newly created VM; the existing `clamps-test-smoke` domain + uses the old Ignition and must be destroyed and recreated first. ## Static checks diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 447e6f59..9cedbbd7 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -23,11 +23,13 @@ independent disposable runs, use a unique name and loopback SSH port: ``` The launcher builds the current static `skillet-clamps` host binary, or accepts -`--artifact PATH` to use a specific binary. It records its SHA256 in `runs/NAME/skillet.sha256`, copies -the binary into the generated Ignition, and delivers it at -`/var/lib/skillet/skillet-clamps`. Generated Ignition, SSH key, VM disk and logs -stay under `runs/NAME`, which Git ignores. The test key is private and only its -public half enters Ignition. `--image PATH` selects a specific FCOS qcow2; +`--artifact PATH` to use a specific binary. It records its SHA256 in +`runs/NAME/skillet.sha256`. To keep the QEMU `fw_cfg` Ignition payload small, +the VM config omits this multi-megabyte binary; `clamps-ready` transfers it +over SSH after first boot and installs it at `/var/lib/skillet/skillet-clamps`. +Generated Ignition, SSH key, VM disk and logs stay under `runs/NAME`, which Git +ignores. The test key is private and only its public half enters Ignition. +`--image PATH` selects a specific FCOS qcow2; the default is `images/coreos.qcow2` when present. The VM uses passt with an inbound forward bound to `127.0.0.1`. diff --git a/ublue/butane/bin/clamps-ready b/ublue/butane/bin/clamps-ready index 64037359..5b92a282 100755 --- a/ublue/butane/bin/clamps-ready +++ b/ublue/butane/bin/clamps-ready @@ -21,6 +21,8 @@ vm_virsh() { known_hosts="$run_dir/ssh/known_hosts" ssh_args=(-i "$run_dir/ssh/id_ed25519" -p "$ssh_port" -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts") +scp_args=(-i "$run_dir/ssh/id_ed25519" -P "$ssh_port" -o BatchMode=yes + -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts") # shellcheck disable=SC2029 ssh_guest() { ssh "${ssh_args[@]}" core@127.0.0.1 "$@"; } @@ -34,19 +36,52 @@ if ! rg -Fq "$run_dir/$vm.qcow2" "$run_dir/disks.txt"; then exit 1 fi +artifact="$run_dir/source/files/skillet-clamps" +[[ -x "$artifact" ]] || { echo "Missing host artifact: $artifact" >&2; exit 1; } deadline=$((SECONDS + 2700)) +next_report=$((SECONDS + 60)) +echo "Waiting for SSH on core@127.0.0.1:$ssh_port before delivering Skillet..." while ((SECONDS < deadline)); do - if ssh_guest 'sudo -n test -e /run/clamps-bootstrap-ready && sudo -n systemctl is-active --quiet skillet-apply.service' &>/dev/null; then + if ssh_guest 'sudo -n true' &>/dev/null; then break fi - sleep 15 + if ((SECONDS >= next_report)); then + echo "Still waiting for guest SSH; elapsed $((2700 - (deadline - SECONDS))) seconds" + next_report=$((SECONDS + 60)) + fi + sleep 5 done if ((SECONDS >= deadline)); then - echo "Timed out waiting for signed clamps boot and Skillet base apply" >&2 + echo "Timed out waiting for guest SSH; inspect the VM serial console and $run_dir/domain.txt" >&2 + exit 1 +fi +scp "${scp_args[@]}" "$artifact" "core@127.0.0.1:/var/tmp/skillet-clamps" +ssh_guest 'sudo -n install -m 0755 /var/tmp/skillet-clamps /var/lib/skillet/skillet-clamps && rm -f /var/tmp/skillet-clamps' +echo "Installed Skillet artifact; waiting for the signed clamps deployment..." + +deadline=$((SECONDS + 2700)) +next_report=$((SECONDS + 60)) +while ((SECONDS < deadline)); do + if ssh_guest 'sudo -n test -e /run/clamps-bootstrap-ready' &>/dev/null; then + break + fi + if ((SECONDS >= next_report)); then + echo "Still waiting for signed deployment; elapsed $((2700 - (deadline - SECONDS))) seconds" + next_report=$((SECONDS + 60)) + fi + sleep 10 +done +if ((SECONDS >= deadline)); then + echo "Timed out waiting for signed clamps boot" >&2 ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true echo "Diagnostics: $run_dir/failure.log" >&2 exit 1 fi +if ! ssh_guest 'sudo -n systemctl reset-failed skillet-apply.service && sudo -n systemctl --wait restart skillet-apply.service'; then + ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true + echo "Skillet base apply failed; diagnostics: $run_dir/failure.log" >&2 + exit 1 +fi ssh_guest 'sudo -n rpm-ostree status --json' > "$run_dir/final-status.json" ssh_guest 'cat /proc/sys/kernel/random/boot_id' > "$run_dir/final-boot-id" diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 59dddd2e..f3fdb3cd 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -93,6 +93,11 @@ cp "$input" "$output_dir/source/clamps.bu" cp "$butane_dir"/includes/*.bu "$output_dir/source/includes/" cp "$artifact" "$output_dir/source/files/skillet-clamps" chmod 755 "$output_dir/source/files/skillet-clamps" +# Keep the multi-megabyte static binary out of QEMU fw_cfg. Ignition reads +# fw_cfg very slowly for large configs; clamps-ready installs this artifact +# over SSH after the guest has completed its first Ignition boot. +yq -i 'del(.storage.files[] | select(.path == "/var/lib/skillet/skillet-clamps"))' \ + "$output_dir/source/includes/skillet.bu" ssh-keygen -q -t ed25519 -N '' -f "$output_dir/ssh/id_ed25519" chmod 600 "$output_dir/ssh/id_ed25519" public_key="$(cat "$output_dir/ssh/id_ed25519.pub")" @@ -104,6 +109,10 @@ VM_INCLUDE='includes/vm-access.bu' yq -i \ "$output_dir/source/clamps.bu" ignition="$("$script_dir/butane" -s -o "$output_dir/ignition" "$output_dir/source/clamps.bu" clamps.ign)" +ignition_bytes="$(stat -c '%s' "$ignition")" +if ((ignition_bytes > 1048576)); then + echo "Warning: Ignition config is ${ignition_bytes} bytes; large QEMU fw_cfg configs can take a long time to read" >&2 +fi cp --reflink=auto "$image" "$output_dir/$vm_name.qcow2" sha256sum "$output_dir/source/files/skillet-clamps" > "$output_dir/skillet.sha256" sha256sum "$image" > "$output_dir/fcos-image.sha256" From fa5904f2319e7a435ba4f02abfa5756723922c25 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 18:11:44 +0200 Subject: [PATCH 072/252] butane: execute clamps bootstrap and skillet from labeled bin path --- ublue/butane/README.md | 7 +++++-- ublue/butane/bin/clamps-ready | 9 +++++++-- ublue/butane/bin/coreos-install | 2 +- ublue/butane/includes/skillet.bu | 7 ++----- ublue/butane/includes/ucore-clamps.bu | 4 ++-- ublue/butane/tests/bootstrap.sh | 2 +- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 9cedbbd7..7291457a 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -26,7 +26,10 @@ The launcher builds the current static `skillet-clamps` host binary, or accepts `--artifact PATH` to use a specific binary. It records its SHA256 in `runs/NAME/skillet.sha256`. To keep the QEMU `fw_cfg` Ignition payload small, the VM config omits this multi-megabyte binary; `clamps-ready` transfers it -over SSH after first boot and installs it at `/var/lib/skillet/skillet-clamps`. +over SSH after first boot and installs it at `/var/usrlocal/bin/skillet-clamps`. +The bootstrap script also lives in `/var/usrlocal/bin`: Fedora CoreOS labels +that persistent directory `bin_t`, allowing systemd to execute these files +with SELinux enforcing. Generated Ignition, SSH key, VM disk and logs stay under `runs/NAME`, which Git ignores. The test key is private and only its public half enters Ignition. `--image PATH` selects a specific FCOS qcow2; @@ -52,7 +55,7 @@ and then to the signed image. It checks the *booted* rpm-ostree deployment on every boot. It never starts the base apply until the signed deployment boots. A failed rebase stops without rebooting; an already pending deployment gets at most two reboot attempts. The base unit runs -`/var/lib/skillet/skillet-clamps apply --phase base` with no app credentials. +`/var/usrlocal/bin/skillet-clamps apply --phase base` with no app credentials. `./bin/clamps-ready runs/NAME` waits up to 45 minutes, checks the expected domain/disk, noninteractive SSH and sudo, signed booted deployment, successful diff --git a/ublue/butane/bin/clamps-ready b/ublue/butane/bin/clamps-ready index 5b92a282..67fef36b 100755 --- a/ublue/butane/bin/clamps-ready +++ b/ublue/butane/bin/clamps-ready @@ -56,7 +56,7 @@ if ((SECONDS >= deadline)); then exit 1 fi scp "${scp_args[@]}" "$artifact" "core@127.0.0.1:/var/tmp/skillet-clamps" -ssh_guest 'sudo -n install -m 0755 /var/tmp/skillet-clamps /var/lib/skillet/skillet-clamps && rm -f /var/tmp/skillet-clamps' +ssh_guest 'sudo -n install -m 0755 /var/tmp/skillet-clamps /var/usrlocal/bin/skillet-clamps && rm -f /var/tmp/skillet-clamps' echo "Installed Skillet artifact; waiting for the signed clamps deployment..." deadline=$((SECONDS + 2700)) @@ -65,6 +65,11 @@ while ((SECONDS < deadline)); do if ssh_guest 'sudo -n test -e /run/clamps-bootstrap-ready' &>/dev/null; then break fi + if ssh_guest 'sudo -n systemctl is-failed --quiet clamps-bootstrap.service' &>/dev/null; then + ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true + echo "Clamps bootstrap failed; diagnostics: $run_dir/failure.log" >&2 + exit 1 + fi if ((SECONDS >= next_report)); then echo "Still waiting for signed deployment; elapsed $((2700 - (deadline - SECONDS))) seconds" next_report=$((SECONDS + 60)) @@ -85,7 +90,7 @@ fi ssh_guest 'sudo -n rpm-ostree status --json' > "$run_dir/final-status.json" ssh_guest 'cat /proc/sys/kernel/random/boot_id' > "$run_dir/final-boot-id" -ssh_guest 'sha256sum /var/lib/skillet/skillet-clamps' > "$run_dir/guest-skillet.sha256" +ssh_guest 'sha256sum /var/usrlocal/bin/skillet-clamps' > "$run_dir/guest-skillet.sha256" ssh_guest 'getenforce; readlink -f /etc/resolv.conf; cat /etc/resolv.conf; getent hosts ghcr.io; sudo -n systemctl is-enabled systemd-resolved.service; sudo -n systemctl status --no-pager skillet-apply.service' > "$run_dir/readiness.log" 2>&1 || true expected="$(cut -d ' ' -f 1 "$run_dir/skillet.sha256")" actual="$(cut -d ' ' -f 1 "$run_dir/guest-skillet.sha256")" diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index f3fdb3cd..c18bbeec 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -96,7 +96,7 @@ chmod 755 "$output_dir/source/files/skillet-clamps" # Keep the multi-megabyte static binary out of QEMU fw_cfg. Ignition reads # fw_cfg very slowly for large configs; clamps-ready installs this artifact # over SSH after the guest has completed its first Ignition boot. -yq -i 'del(.storage.files[] | select(.path == "/var/lib/skillet/skillet-clamps"))' \ +yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet-clamps"))' \ "$output_dir/source/includes/skillet.bu" ssh-keygen -q -t ed25519 -N '' -f "$output_dir/ssh/id_ed25519" chmod 600 "$output_dir/ssh/id_ed25519" diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index 1ec16453..e33a3a21 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -1,11 +1,8 @@ variant: fcos version: 1.6.0 storage: - directories: - - path: /var/lib/skillet - mode: 0755 files: - - path: /var/lib/skillet/skillet-clamps + - path: /var/usrlocal/bin/skillet-clamps mode: 0755 contents: local: files/skillet-clamps @@ -22,7 +19,7 @@ systemd: [Service] Type=oneshot - ExecStart=/var/lib/skillet/skillet-clamps apply --phase base + ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase base RemainAfterExit=yes [Install] diff --git a/ublue/butane/includes/ucore-clamps.bu b/ublue/butane/includes/ucore-clamps.bu index 774a0a67..b6910a1b 100644 --- a/ublue/butane/includes/ucore-clamps.bu +++ b/ublue/butane/includes/ucore-clamps.bu @@ -7,7 +7,7 @@ storage: - path: /var/lib/clamps-bootstrap mode: 0755 files: - - path: /var/lib/clamps-bootstrap/rebase.sh + - path: /var/usrlocal/bin/clamps-rebase.sh mode: 0755 contents: inline: | @@ -63,7 +63,7 @@ systemd: [Service] Type=oneshot - ExecStart=/var/lib/clamps-bootstrap/rebase.sh + ExecStart=/var/usrlocal/bin/clamps-rebase.sh RemainAfterExit=yes TimeoutStartSec=45min diff --git a/ublue/butane/tests/bootstrap.sh b/ublue/butane/tests/bootstrap.sh index 7633e5cd..4e343c8c 100644 --- a/ublue/butane/tests/bootstrap.sh +++ b/ublue/butane/tests/bootstrap.sh @@ -5,7 +5,7 @@ source_dir="$(realpath "$(dirname "${BASH_SOURCE[0]}")/..")" tmp="$(mktemp -d)" trap 'rm -rf -- "$tmp"' EXIT mkdir "$tmp/bin" -yq -r '.storage.files[] | select(.path == "/var/lib/clamps-bootstrap/rebase.sh") | .contents.inline' \ +yq -r '.storage.files[] | select(.path == "/var/usrlocal/bin/clamps-rebase.sh") | .contents.inline' \ "$source_dir/includes/ucore-clamps.bu" > "$tmp/rebase.sh" cat > "$tmp/bin/rpm-ostree" <<'MOCK' From 304dca36163800716f14e3b2acd5a6e5e2d3ec8e Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 21:22:26 +0200 Subject: [PATCH 073/252] skillet: preserve ignition SSH access across clamps rebase --- ublue/butane/ACCEPTANCE.md | 62 ++++++++++++------- ublue/butane/README.md | 12 ++-- ublue/butane/bin/clamps-ready | 10 +-- ublue/butane/bin/clamps-vm | 4 +- ublue/butane/bin/coreos-install | 20 ++++-- ublue/butane/includes/skillet.bu | 1 - ublue/skillet/README.md | 4 +- ublue/skillet/crates/cli/src/main.rs | 38 ++---------- .../crates/hardening/files/sshd_config | 1 + .../skillet/integration_tests/smoke-guest.sh | 2 +- ublue/skillet/integration_tests/smoke-ssh.sh | 6 +- 11 files changed, 78 insertions(+), 82 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index b8e9b3dc..0a648692 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,8 +1,27 @@ # Clamps VM acceptance log -Status: integration incomplete, reviewed 2026-09-26. The current source has not yet -completed a fresh VM boot. Nothing below is claimed as a passing end-to-end -install. +Status: clean disposable VM create, readiness, and the real Skillet smoke +scenario passed on 2026-09-26. Bootstrap interruption recovery remains +unverified. + +## Passing clean run + +- `cargo run --release -p skillet -- test vm create` created + `clamps-test-smoke` from a fresh FCOS qcow2 and returned success without + manual guest repair. The VM remains available for smoke runs. +- `runs/clamps-test-smoke/final-status.json` records the booted origin as + `ostree-image-signed:docker://ghcr.io/gbagnoli/ucore-clamps:latest`; the + previous deployment is the unsigned clamps image. +- `clamps-ready` verified the generated SSH key, guest binary SHA, successful + repeated base apply, enforcing SELinux, NetworkManager's resolver, working + DNS, and masked `systemd-resolved`. +- Ignition keys survived under `~/.ssh/authorized_keys.d/ignition`. Skillet's + SSH config now includes that path. The image repo has the matching change, + which will take effect after its next image build. +- `cargo run --release -p skillet -- test smoke clamps` passed the real + Podman/systemd fixture, repeated apply, config and secret changes, injected + startup failures, and post-reboot persistence. Snapshots and journals remain + under `/var/lib/skillet-smoke` in the disposable VM. ## Preflight and failed attempts @@ -16,7 +35,7 @@ install. - `clamps-agent2-0925-03` produced the same QEMU error with artifacts under `/tmp`. No domain remained after either failed virt-install. The current source adds the Ignition file as a read-only libvirt-managed raw attachment; - this change still needs a fresh VM attempt. Per-VM confinement was not + a later clean VM create passed. Per-VM confinement was not disabled and host security policy was not changed. - Failed generated artifacts remain in ignored `runs/` and `/tmp` paths for diagnosis. They contain private test SSH keys and should not be committed. @@ -24,7 +43,7 @@ install. but no recorded domain UUID or acceptance results. This is not evidence of a successful guest boot. The helper now selects a separate native libvirt runtime to avoid mixing native and Flatpak daemon filesystem views; that - approach still requires end-to-end validation. + approach passed the clean create recorded above. - `runs/clamps-test-smoke` created a running VM, but SSH on port 2201 never became available. The serial console showed Ignition still reading the 2.5 MiB `fw_cfg` payload after about 30 host minutes; the dominant @@ -32,15 +51,14 @@ install. that large QEMU `fw_cfg` configs can take an unreasonable time to read ([release notes](https://coreos.github.io/ignition/release-notes/)). The VM launcher now omits that binary from the VM-only Ignition config and - `clamps-ready` transfers it over SSH after first boot. This fix still needs - validation on a newly created VM; the existing `clamps-test-smoke` domain - uses the old Ignition and must be destroyed and recreated first. + `clamps-ready` transfers it over SSH after first boot. The clean create + recorded above validated this change. ## Static checks - ShellCheck passes for the four launcher/readiness scripts, bootstrap regression script and both Skillet smoke scripts. -- Full strict Butane compilation passed on 2026-09-26 with the final staged +- Full strict Butane compilation passed on 2026-09-26 with an earlier staged source and rebuilt host artifact. Local output: `/tmp/clamps-commit-validation-93upz2et/ignition/clamps.ign`. - `bash tests/bootstrap.sh` passes seven mocked deployment-state cases: @@ -52,27 +70,25 @@ install. launcher now rebuilds; `--artifact` remains an explicit override. - Skillet review passed formatting, 25 workspace tests, configured pedantic Clippy with warnings denied, and the offline static release build. -- Latest Skillet handoff artifact: `skillet-clamps` SHA256 - `55bf7584ce405dcf2c20956c182f4dbfe04bfbb20a9b649e48ab97877d9e8856`. - No VM has consumed this final artifact yet. +- Current VM Skillet artifact SHA256: + `6cdb80f03221c0fb9521f7ff9a58ee33844fb7262651ff47793b9a9f70ec7784`. ## Exit criteria | Criterion | Result | Evidence / next command | | --- | --- | --- | -| Clean install | Fail | QEMU fw_cfg EACCES on attempts 02/03; retry with current source | -| Access | Not run | `bin/clamps-ready RUN_DIR` after guest boot | -| Rebase sequence | Not run | Guest `rpm-ostree status --json` and bootstrap journals | -| Resolver continuity | Not run | `bin/clamps-ready` DNS/link checks on each boot | -| Artifact delivery | Not run | Guest SHA versus `skillet.sha256` | -| Automatic apply | Not run | `systemctl status skillet-apply.service` | -| Repeat apply | Not run | Two base applies with state/service timestamps | -| Reboot | Not run | Ordinary VM reboot and repeated readiness checks | +| Clean install | Pass | Clean `test vm create` returned success | +| Access | Pass | Generated key authenticated after signed boot | +| Rebase sequence | Pass | Booted signed and previous unsigned clamps deployments | +| Resolver continuity | Partial | Final boot passed; each intermediate boot not checked | +| Artifact delivery | Pass | Guest SHA matched `skillet.sha256` | +| Automatic apply | Pass | Boot unit completed with status 0 | +| Repeat apply | Pass | `clamps-ready` reran the base unit with `systemctl --wait` | +| Reboot | Pass | Smoke reboot preserved data and container state | | Interruption | Not run | Interrupt an isolated bootstrap, then recover | | Repeatability | Not run | Second fresh VM after first passes | -| Real Skillet scenario | Not run | `skillet/integration_tests/smoke-ssh.sh` with VM identity | +| Real Skillet scenario | Pass | `skillet test smoke clamps` returned success | -No successful VM is established by the saved evidence. Next, create a new -`clamps-test-*` domain and retain a passing guest for acceptance. The mutable +The passing VM is retained for further inspection. The mutable source-tree helper has not been installed as a frozen, persistently approved host tool; it still uses normal command approvals. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 7291457a..50b5db79 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -1,7 +1,7 @@ # Clamps VM bootstrap -Implementation checkpoint: source validation passes; a complete VM boot and -runtime acceptance are still pending. See [ACCEPTANCE.md](ACCEPTANCE.md). +A clean disposable VM create passed signed boot and readiness on 2026-09-26. +See [ACCEPTANCE.md](ACCEPTANCE.md) for the checks performed and remaining work. `clamps.bu` is a VM configuration. It resizes and reformats partition 4 on `/dev/vda`, the virtio disk attached by this launcher. Do not use it as a @@ -32,6 +32,8 @@ that persistent directory `bin_t`, allowing systemd to execute these files with SELinux enforcing. Generated Ignition, SSH key, VM disk and logs stay under `runs/NAME`, which Git ignores. The test key is private and only its public half enters Ignition. +The VM staging step skips the dotfiles installer because it replaces +`authorized_keys` and would remove the temporary test key after the rebase. `--image PATH` selects a specific FCOS qcow2; the default is `images/coreos.qcow2` when present. The VM uses passt with an inbound forward bound to `127.0.0.1`. @@ -46,10 +48,10 @@ Connect with: ```bash ssh -i runs/NAME/ssh/id_ed25519 -p PORT \ - -o UserKnownHostsFile=runs/NAME/ssh/known_hosts core@127.0.0.1 + -o UserKnownHostsFile=runs/NAME/ssh/known_hosts giacomo@127.0.0.1 ``` -The `core` account has passwordless sudo on FCOS. The guest starts +The VM staging step grants `giacomo` passwordless sudo. The guest starts `clamps-bootstrap.service`, which rebases first to the unsigned clamps image and then to the signed image. It checks the *booted* rpm-ostree deployment on every boot. It never starts the base apply until the signed deployment boots. @@ -68,7 +70,7 @@ For manual diagnostics: ```bash virsh -c qemu:///session dominfo NAME virsh -c qemu:///session domblklist NAME -ssh -i runs/NAME/ssh/id_ed25519 -p PORT core@127.0.0.1 \ +ssh -i runs/NAME/ssh/id_ed25519 -p PORT giacomo@127.0.0.1 \ 'sudo rpm-ostree status; sudo journalctl -b -u clamps-bootstrap.service -u skillet-apply.service' ``` diff --git a/ublue/butane/bin/clamps-ready b/ublue/butane/bin/clamps-ready index 67fef36b..973ff7c0 100755 --- a/ublue/butane/bin/clamps-ready +++ b/ublue/butane/bin/clamps-ready @@ -24,7 +24,7 @@ ssh_args=(-i "$run_dir/ssh/id_ed25519" -p "$ssh_port" -o BatchMode=yes scp_args=(-i "$run_dir/ssh/id_ed25519" -P "$ssh_port" -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts") # shellcheck disable=SC2029 -ssh_guest() { ssh "${ssh_args[@]}" core@127.0.0.1 "$@"; } +ssh_guest() { ssh "${ssh_args[@]}" giacomo@127.0.0.1 "$@"; } if ! vm_virsh dominfo "$vm" > "$run_dir/domain.txt"; then echo "Domain $vm unavailable on $uri" >&2 @@ -40,7 +40,7 @@ artifact="$run_dir/source/files/skillet-clamps" [[ -x "$artifact" ]] || { echo "Missing host artifact: $artifact" >&2; exit 1; } deadline=$((SECONDS + 2700)) next_report=$((SECONDS + 60)) -echo "Waiting for SSH on core@127.0.0.1:$ssh_port before delivering Skillet..." +echo "Waiting for SSH on giacomo@127.0.0.1:$ssh_port before delivering Skillet..." while ((SECONDS < deadline)); do if ssh_guest 'sudo -n true' &>/dev/null; then break @@ -55,7 +55,7 @@ if ((SECONDS >= deadline)); then echo "Timed out waiting for guest SSH; inspect the VM serial console and $run_dir/domain.txt" >&2 exit 1 fi -scp "${scp_args[@]}" "$artifact" "core@127.0.0.1:/var/tmp/skillet-clamps" +scp -O "${scp_args[@]}" "$artifact" "giacomo@127.0.0.1:/var/tmp/skillet-clamps" ssh_guest 'sudo -n install -m 0755 /var/tmp/skillet-clamps /var/usrlocal/bin/skillet-clamps && rm -f /var/tmp/skillet-clamps' echo "Installed Skillet artifact; waiting for the signed clamps deployment..." @@ -103,5 +103,5 @@ if ! jq -e \ fi # The substitutions run on the guest. # shellcheck disable=SC2016 -ssh_guest 'sudo -n systemctl is-active --quiet skillet-apply.service && test "$(getenforce)" = Enforcing && test "$(readlink -f /etc/resolv.conf)" = /run/NetworkManager/resolv.conf && getent hosts ghcr.io >/dev/null && test "$(systemctl is-enabled systemd-resolved.service)" = masked' -echo "Ready: $vm on $uri; SSH: ssh -i $run_dir/ssh/id_ed25519 -p $ssh_port -o UserKnownHostsFile=$known_hosts core@127.0.0.1" +ssh_guest 'test "$(systemctl show -p Result --value skillet-apply.service)" = success && test "$(systemctl show -p ExecMainStatus --value skillet-apply.service)" = 0 && test "$(getenforce)" = Enforcing && test "$(readlink -f /etc/resolv.conf)" = /run/NetworkManager/resolv.conf && getent hosts ghcr.io >/dev/null && test "$(systemctl is-enabled systemd-resolved.service)" = masked' +echo "Ready: $vm on $uri; SSH: ssh -i $run_dir/ssh/id_ed25519 -p $ssh_port -o UserKnownHostsFile=$known_hosts giacomo@127.0.0.1" diff --git a/ublue/butane/bin/clamps-vm b/ublue/butane/bin/clamps-vm index d453dea1..1324b35c 100755 --- a/ublue/butane/bin/clamps-vm +++ b/ublue/butane/bin/clamps-vm @@ -85,13 +85,13 @@ case "$operation" in ensure_native_runtime inspect_owned ssh_args - exec ssh "${args[@]}" core@127.0.0.1 ;; + exec ssh "${args[@]}" giacomo@127.0.0.1 ;; logs) (($# == 0)) || usage ensure_native_runtime inspect_owned ssh_args - ssh "${args[@]}" core@127.0.0.1 \ + ssh "${args[@]}" giacomo@127.0.0.1 \ 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' ;; reboot) (($# == 0)) || usage diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index c18bbeec..2d06c409 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -101,12 +101,20 @@ yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet-clamps" ssh-keygen -q -t ed25519 -N '' -f "$output_dir/ssh/id_ed25519" chmod 600 "$output_dir/ssh/id_ed25519" public_key="$(cat "$output_dir/ssh/id_ed25519.pub")" -PUBLIC_KEY="$public_key" yq -n \ - '{"variant":"fcos","version":"1.6.0","passwd":{"users":[{"name":"core","ssh_authorized_keys":[strenv(PUBLIC_KEY)]}]}}' \ - > "$output_dir/source/includes/vm-access.bu" -VM_INCLUDE='includes/vm-access.bu' yq -i \ - '.ignition.config.merge += [{"local":strenv(VM_INCLUDE)}]' \ - "$output_dir/source/clamps.bu" +# The existing giacomo account survives the FCOS-to-uCore rebase. Inject the +# disposable key into that same account and skip its interactive password +# expiry service only in this staged VM copy. +PUBLIC_KEY="$public_key" yq -i \ + '(.passwd.users[] | select(.name == "giacomo") | .ssh_authorized_keys) += [strenv(PUBLIC_KEY)] | + del(.systemd.units[] | select(.name == "force-pw-change.service")) | + del(.storage.files[] | select(.path == "/usr/local/bin/force_pw_change.sh")) | + .storage.files += [{"path":"/etc/sudoers.d/90-skillet-vm","mode":288,"contents":{"inline":"giacomo ALL=(ALL) NOPASSWD: ALL\n"}}]' \ + "$output_dir/source/includes/passwd.bu" +# The dotfiles installer replaces authorized_keys with its own symlink and +# drops the disposable key during the rebase. Keep the host source unchanged; +# skip that installer in this VM's staged Butane source. +yq -i 'del(.systemd.units[] | select(.name == "dotfiles-install.service"))' \ + "$output_dir/source/includes/brew.bu" ignition="$("$script_dir/butane" -s -o "$output_dir/ignition" "$output_dir/source/clamps.bu" clamps.ign)" ignition_bytes="$(stat -c '%s' "$ignition")" diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index e33a3a21..f70dad21 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -20,7 +20,6 @@ systemd: [Service] Type=oneshot ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase base - RemainAfterExit=yes [Install] WantedBy=multi-user.target diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 1cbac059..45972dda 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -56,7 +56,7 @@ For real systemd/Podman and reboot coverage, create a disposable VM first: cargo run --release -p skillet -- test vm create ``` -This provisions `clamps-test-smoke` on `core@127.0.0.1:2201`, creates its SSH +This provisions `clamps-test-smoke` on `giacomo@127.0.0.1:2201`, creates its SSH key, and waits for the guest to pass `clamps-ready`. Then run the smoke scenario: ```bash @@ -66,7 +66,7 @@ cargo run --release -p skillet -- test smoke clamps The smoke command defaults to that target, port, and generated key. Override them with `--target`, `--port`, and `--identity` when using a separately provisioned VM. It takes the generic binary from the running executable and -builds the matching `skillet-clamps` binary automatically. Repeated smoke runs +uses the `skillet-clamps` binary installed by VM creation. Repeated smoke runs reset only the namespaced `/var/lib/skillet-smoke` fixture and its managed files, so you can inspect a run and rerun it on the same disposable VM. diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 98d7186f..795eb965 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -88,7 +88,7 @@ struct SmokeArgs { /// Host configuration to exercise (currently `clamps`) hostname: String, /// Explicit disposable SSH target, USER@HOST - #[arg(long, default_value = "core@127.0.0.1")] + #[arg(long, default_value = "giacomo@127.0.0.1")] target: String, /// SSH port #[arg(long, default_value_t = 2201)] @@ -171,36 +171,6 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { )); } let binary = std::env::current_exe().context("locating the running Skillet binary failed")?; - let profile_dir = binary - .parent() - .ok_or_else(|| anyhow!("Skillet binary has no parent directory"))?; - let profile = profile_dir.file_name().and_then(|name| name.to_str()); - let package = format!("skillet-{}", args.hostname); - let mut build = Command::new("cargo"); - build - .current_dir(&root) - .args(["build", "--package", &package]); - if profile == Some("release") { - build.arg("--release"); - } - let status = build - .status() - .context("building the clamps host binary failed")?; - if !status.success() { - return Err(anyhow!("building the clamps host binary failed")); - } - let host_binary = [ - profile_dir.join(&package), - root.join("target/x86_64-unknown-linux-musl") - .join(profile.unwrap_or("debug")) - .join(&package), - root.join("target") - .join(profile.unwrap_or("debug")) - .join(&package), - ] - .into_iter() - .find(|candidate| candidate.is_file()) - .ok_or_else(|| anyhow!("built host binary {package} was not found"))?; let script = root.join("integration_tests/smoke-ssh.sh"); if !script.is_file() { return Err(anyhow!("smoke runner not found at {}", script.display())); @@ -212,7 +182,7 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { .args(["--binary"]) .arg(&binary) .args(["--clamps-binary"]) - .arg(&host_binary) + .arg("/var/usrlocal/bin/skillet-clamps") .args(["--identity"]) .arg(&identity) .status() @@ -251,7 +221,7 @@ fn run_vm_create(args: &VmCreateArgs) -> Result<()> { )); } info!( - "Disposable VM {} is ready at core@127.0.0.1:{}; smoke key: {}", + "Disposable VM {} is ready at giacomo@127.0.0.1:{}; smoke key: {}", args.name, args.port, run_dir.join("ssh/id_ed25519").display() @@ -472,7 +442,7 @@ mod tests { } #[test] - fn test_smoke_autoselects_binaries_from_hostname() { + fn test_smoke_parses_without_binary_arguments() { let parsed = Args::try_parse_from([ "skillet", "test", diff --git a/ublue/skillet/crates/hardening/files/sshd_config b/ublue/skillet/crates/hardening/files/sshd_config index ec954166..1c206183 100644 --- a/ublue/skillet/crates/hardening/files/sshd_config +++ b/ublue/skillet/crates/hardening/files/sshd_config @@ -78,6 +78,7 @@ MaxStartups 10:30:100 # Enable public key authentication PubkeyAuthentication yes +AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys.d/ignition # Never use host-based authentication. It can be exploited. diff --git a/ublue/skillet/integration_tests/smoke-guest.sh b/ublue/skillet/integration_tests/smoke-guest.sh index 097f3892..3e437f66 100755 --- a/ublue/skillet/integration_tests/smoke-guest.sh +++ b/ublue/skillet/integration_tests/smoke-guest.sh @@ -2,7 +2,7 @@ set -euo pipefail phase=${1:?phase required} -binary=/var/lib/skillet/skillet-smoke +binary=/var/usrlocal/bin/skillet-smoke clamps_binary=${2:?clamps binary path required} root=/var/lib/skillet-smoke service=skillet-smoke-fixture.service diff --git a/ublue/skillet/integration_tests/smoke-ssh.sh b/ublue/skillet/integration_tests/smoke-ssh.sh index 6047ce3c..5c86abcc 100755 --- a/ublue/skillet/integration_tests/smoke-ssh.sh +++ b/ublue/skillet/integration_tests/smoke-ssh.sh @@ -53,9 +53,9 @@ if [[ -n $identity ]]; then ssh_opts+=(-i "$identity" -o IdentitiesOnly=yes -o UserKnownHostsFile="$known_hosts") scp_opts+=(-i "$identity" -o IdentitiesOnly=yes -o UserKnownHostsFile="$known_hosts") fi -scp "${scp_opts[@]}" "$binary" "$target:/var/tmp/skillet-smoke" -scp "${scp_opts[@]}" "$script_dir/smoke-guest.sh" "$target:/var/tmp/skillet-smoke-guest.sh" -ssh "${ssh_opts[@]}" "$target" 'sudo install -m 0755 /var/tmp/skillet-smoke /var/lib/skillet/skillet-smoke' +scp -O "${scp_opts[@]}" "$binary" "$target:/var/tmp/skillet-smoke" +scp -O "${scp_opts[@]}" "$script_dir/smoke-guest.sh" "$target:/var/tmp/skillet-smoke-guest.sh" +ssh "${ssh_opts[@]}" "$target" 'sudo install -m 0755 /var/tmp/skillet-smoke /var/usrlocal/bin/skillet-smoke' boot_before=$(ssh "${ssh_opts[@]}" "$target" 'cat /proc/sys/kernel/random/boot_id') # The remote path is validated above and intentionally expanded on the client. # shellcheck disable=SC2029 From 832d784b1db64769090a6d772d754d76ac55463f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 22:08:03 +0200 Subject: [PATCH 074/252] Share uCore bootstrap and disposable VM tooling --- ublue/butane/ACCEPTANCE.md | 35 ++++++-- ublue/butane/README.md | 82 ++++++++++++------- ublue/butane/bin/coreos-install | 44 +++++----- ublue/butane/bin/{clamps-vm => test-vm} | 37 ++++++--- .../bin/{clamps-ready => test-vm-ready} | 49 +++++++---- ublue/butane/clamps.bu | 1 + ublue/butane/includes/brew.bu | 22 +++-- ublue/butane/includes/skillet.bu | 6 +- ublue/butane/includes/ucore-bootstrap.bu | 75 +++++++++++++++++ ublue/butane/includes/ucore-clamps.bu | 69 +--------------- ublue/butane/tests/bootstrap.sh | 14 +++- ublue/skillet/README.md | 4 +- ublue/skillet/crates/cli/src/main.rs | 77 +++++++++-------- 13 files changed, 309 insertions(+), 206 deletions(-) rename ublue/butane/bin/{clamps-vm => test-vm} (74%) rename ublue/butane/bin/{clamps-ready => test-vm-ready} (56%) create mode 100644 ublue/butane/includes/ucore-bootstrap.bu diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 0a648692..2c7967e9 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,8 +1,29 @@ # Clamps VM acceptance log Status: clean disposable VM create, readiness, and the real Skillet smoke -scenario passed on 2026-09-26. Bootstrap interruption recovery remains -unverified. +scenario passed on 2026-09-26. A subsequent fresh VM reached the signed +deployment and installed the full user environment. Bootstrap interruption +recovery remains unverified. + +## Shared bootstrap and user environment + +- The host-independent `ucore-bootstrap.service` read clamps' image reference + from `/etc/ucore-bootstrap-image` and completed both rebases on a fresh VM. +- `brew-install.service` and `dotfiles-install.service` completed with status + 0. The `core` Brewfile bundle check passed, and the Brewfile and SSH key + symlinks resolve into the dotfiles checkout. Completion markers survive + reboot and allow an interrupted installation to retry. +- The first readiness run failed only because its symlink check compared a + resolved `/var/home` path with an unresolved `/home` path. After correcting + the comparison, `test-vm clamps-test-smoke ready` passed on that same VM. +- `test-vm clamps-test-smoke reboot` followed by `test-vm clamps-test-smoke ready` passed; the + temporary SSH key and completion markers survived the reboot. +- The VM interface is now `test-vm `. The + Skillet wrapper accepts a host for `test vm create` and `test vm destroy`. + Other hosts still need their own Butane configuration and host artifact. +- A second fresh VM, `clamps-test-generic` on port 2202, was created with the + generalized command and passed signed-boot and full user-environment + readiness. ## Passing clean run @@ -12,7 +33,7 @@ unverified. - `runs/clamps-test-smoke/final-status.json` records the booted origin as `ostree-image-signed:docker://ghcr.io/gbagnoli/ucore-clamps:latest`; the previous deployment is the unsigned clamps image. -- `clamps-ready` verified the generated SSH key, guest binary SHA, successful +- The readiness helper verified the generated SSH key, guest binary SHA, successful repeated base apply, enforcing SELinux, NetworkManager's resolver, working DNS, and masked `systemd-resolved`. - Ignition keys survived under `~/.ssh/authorized_keys.d/ignition`. Skillet's @@ -51,7 +72,7 @@ unverified. that large QEMU `fw_cfg` configs can take an unreasonable time to read ([release notes](https://coreos.github.io/ignition/release-notes/)). The VM launcher now omits that binary from the VM-only Ignition config and - `clamps-ready` transfers it over SSH after first boot. The clean create + the readiness helper transfers it over SSH after first boot. The clean create recorded above validated this change. ## Static checks @@ -61,8 +82,8 @@ unverified. - Full strict Butane compilation passed on 2026-09-26 with an earlier staged source and rebuilt host artifact. Local output: `/tmp/clamps-commit-validation-93upz2et/ignition/clamps.ign`. -- `bash tests/bootstrap.sh` passes seven mocked deployment-state cases: - fresh boot, unsigned boot with signed rollback, signed pending, signed +- `bash tests/bootstrap.sh` passes eight mocked deployment-state cases: + fresh clamps and bender boots, unsigned boot with signed rollback, signed pending, signed booted (tag and digest), reboot retry limit, and rebase failure. These are regression checks, not a replacement for actual VM boots. - Review corrected duplicate `:latest` matching, rollback selection as a @@ -83,7 +104,7 @@ unverified. | Resolver continuity | Partial | Final boot passed; each intermediate boot not checked | | Artifact delivery | Pass | Guest SHA matched `skillet.sha256` | | Automatic apply | Pass | Boot unit completed with status 0 | -| Repeat apply | Pass | `clamps-ready` reran the base unit with `systemctl --wait` | +| Repeat apply | Pass | `test-vm clamps-test-smoke ready` reran the base unit with `systemctl --wait` | | Reboot | Pass | Smoke reboot preserved data and container state | | Interruption | Not run | Interrupt an isolated bootstrap, then recover | | Repeatability | Not run | Second fresh VM after first passes | diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 50b5db79..af8252ba 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -6,40 +6,61 @@ See [ACCEPTANCE.md](ACCEPTANCE.md) for the checks performed and remaining work. `clamps.bu` is a VM configuration. It resizes and reformats partition 4 on `/dev/vda`, the virtio disk attached by this launcher. Do not use it as a physical-disk install file. The host needs native libvirt on `qemu:///session`, -`virt-install`, `passt`, Podman, `yq`, `ssh-keygen`, and KVM. +`virt-install`, `passt`, Podman, `yq`, `rg`, `ssh-keygen`, and KVM. -From this directory, the simple clean install command is: +From this directory, create and check the default disposable clamps VM with: ```bash -./bin/coreos-install clamps.bu +./bin/test-vm clamps-test-smoke create +./bin/test-vm clamps-test-smoke ready ``` -It refuses an existing `clamps` domain or `runs/clamps` directory. For -independent disposable runs, use a unique name and loopback SSH port: +For another disposable run, choose a unique test name and loopback SSH port: ```bash -./bin/coreos-install --name clamps-test-example --ssh-port 2201 clamps.bu -./bin/clamps-ready runs/clamps-test-example +./bin/test-vm clamps-test-example create 2202 +./bin/test-vm clamps-test-example ready ``` -The launcher builds the current static `skillet-clamps` host binary, or accepts -`--artifact PATH` to use a specific binary. It records its SHA256 in +The command form is `./bin/test-vm `. The VM name must be +`HOST-test-SUFFIX`: `HOST` selects `HOST.bu` and `skillet-HOST`, while `SUFFIX` +distinguishes disposable runs. The suffix may contain lowercase letters, +digits, and hyphens. Use these commands after creation: + +```bash +./bin/test-vm clamps-test-smoke status # domain and attached disks +./bin/test-vm clamps-test-smoke logs # bootstrap and Skillet journals +./bin/test-vm clamps-test-smoke ssh # interactive guest shell +./bin/test-vm clamps-test-smoke reboot # reboot the guest +./bin/test-vm clamps-test-smoke ready # rerun readiness after a reboot or repair +./bin/test-vm clamps-test-smoke destroy # delete the VM, disk, test key, and run artifacts +``` + +`cargo run --release -p skillet -- test vm create` runs both `create` and +`ready` for clamps. `cargo run --release -p skillet -- test vm destroy` +removes it. Add a host argument for another host once its `HOST.bu` and +`skillet-HOST` crate exist, for example `test vm create HOST`. + +The helper calls `coreos-install`, which builds the current static +`skillet-clamps` host binary. The lower-level launcher accepts `--artifact PATH` +to use a specific binary. It records its SHA256 in `runs/NAME/skillet.sha256`. To keep the QEMU `fw_cfg` Ignition payload small, -the VM config omits this multi-megabyte binary; `clamps-ready` transfers it +the VM config omits this multi-megabyte binary; `test-vm ready` transfers it over SSH after first boot and installs it at `/var/usrlocal/bin/skillet-clamps`. -The bootstrap script also lives in `/var/usrlocal/bin`: Fedora CoreOS labels +The shared uCore bootstrap script also lives in `/var/usrlocal/bin`: Fedora CoreOS labels that persistent directory `bin_t`, allowing systemd to execute these files with SELinux enforcing. Generated Ignition, SSH key, VM disk and logs stay under `runs/NAME`, which Git ignores. The test key is private and only its public half enters Ignition. -The VM staging step skips the dotfiles installer because it replaces -`authorized_keys` and would remove the temporary test key after the rebase. +Homebrew and the `core` dotfiles profile install after the signed clamps boot. +The dotfiles installer links `authorized_keys`; the SSH configuration also +reads Ignition's key file, so the generated VM key remains usable. `--image PATH` selects a specific FCOS qcow2; the default is `images/coreos.qcow2` when present. The VM uses passt with an inbound forward bound to `127.0.0.1`. -`bin/clamps-vm` starts a separate native user libvirt daemon with -`XDG_RUNTIME_DIR=/run/user/UID/clamps-native-libvirt`. This keeps the native +`bin/test-vm` starts a separate native user libvirt daemon with +`XDG_RUNTIME_DIR=/run/user/UID/skillet-test-libvirt`. This keeps the native daemon in the same filesystem view as generated artifacts when Flatpak virt-manager has its own session daemon. For manual virsh inspection of a helper-created VM, set that environment variable on each virsh invocation. @@ -52,38 +73,41 @@ ssh -i runs/NAME/ssh/id_ed25519 -p PORT \ ``` The VM staging step grants `giacomo` passwordless sudo. The guest starts -`clamps-bootstrap.service`, which rebases first to the unsigned clamps image +`ucore-bootstrap.service`, which reads the host image from +`/etc/ucore-bootstrap-image` and rebases first to the unsigned clamps image and then to the signed image. It checks the *booted* rpm-ostree deployment on every boot. It never starts the base apply until the signed deployment boots. A failed rebase stops without rebooting; an already pending deployment gets at most two reboot attempts. The base unit runs `/var/usrlocal/bin/skillet-clamps apply --phase base` with no app credentials. -`./bin/clamps-ready runs/NAME` waits up to 45 minutes, checks the expected +`./bin/test-vm NAME ready` waits up to 45 minutes, checks the expected domain/disk, noninteractive SSH and sudo, signed booted deployment, successful -Skillet unit, guest artifact SHA, enforcing SELinux, DNS and masked resolved. +Skillet unit, guest artifact SHA, enforcing SELinux, DNS, masked resolved, +Homebrew, dotfiles links, and the complete `core` Brewfile bundle. It writes `domain.txt`, `disks.txt`, `final-status.json`, `final-boot-id`, -`guest-skillet.sha256`, and `readiness.log` into the run directory. On timeout, -it writes `failure.log` with the current deployment and relevant journals. -For manual diagnostics: +`guest-skillet.sha256`, `readiness.log`, and `user-environment.log` into the run +directory. On timeout, it writes `failure.log` or +`user-environment-failure.log` with relevant journals. +For manual diagnostics, use `test-vm NAME status` and `test-vm NAME logs`. +The equivalent guest SSH command is: ```bash -virsh -c qemu:///session dominfo NAME -virsh -c qemu:///session domblklist NAME ssh -i runs/NAME/ssh/id_ed25519 -p PORT giacomo@127.0.0.1 \ - 'sudo rpm-ostree status; sudo journalctl -b -u clamps-bootstrap.service -u skillet-apply.service' + 'sudo rpm-ostree status; sudo journalctl -b -u ucore-bootstrap.service -u skillet-apply.service' ``` -`bin/clamps-vm` is a constrained source-tree helper for test domains named -`clamps-test-*` under the ignored `butane/runs` directory. It offers -`create|status|ssh|logs|reboot|destroy`; `destroy` checks the recorded domain -UUID and disk before removing test-owned resources. Its source-tree path is +`bin/test-vm` is a constrained source-tree helper for test domains named +`HOST-test-*` under the ignored `butane/runs` directory. `create` infers +`HOST.bu`, builds `skillet-HOST`, and uses port 2201 by default. Other commands check the +recorded host, domain UUID, and disk before acting. `destroy` removes only +the selected test-owned resources. Its source-tree path is mutable, so an enduring elevated approval requires reviewed, frozen helper code and executable dependencies installed outside writable roots. The VM and its disk remain available after readiness checks. Cleanup is explicit: inspect the domain and disk, then call -`./bin/clamps-vm destroy clamps-test-example` for a helper-created run. For a +`./bin/test-vm clamps-test-example destroy` for a helper-created run. For a plain launcher run, shut down and undefine the named test domain with virsh, then remove only its recorded `runs/NAME` directory after checking `domblklist`. diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 2d06c409..10e1d03d 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -9,20 +9,20 @@ usage() { cat <&2; exit 2; } input="$(realpath "$1")" +host="$(basename "$input" .bu)" +[[ "$host" =~ ^[a-z][a-z0-9-]*$ && "$input" == "$butane_dir/$host.bu" ]] || { echo "Use a host Butane file from $butane_dir" >&2; exit 2; } +vm_name="${vm_name:-$host}" [[ "$vm_name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] || { echo "Invalid VM name" >&2; exit 2; } if [[ ! "$ssh_port" =~ ^[0-9]+$ ]] || ((ssh_port < 1024 || ssh_port > 65535)); then echo "Invalid SSH port" >&2 exit 2 fi -[[ "$input" == "$butane_dir/clamps.bu" ]] || { echo "This VM launcher currently supports clamps.bu only" >&2; exit 2; } output_dir="$(realpath -m "${output_dir:-$butane_dir/runs/$vm_name}")" vm_command virsh -c "$libvirt_uri" uri >/dev/null || { echo "Cannot access libvirt URI $libvirt_uri" >&2; exit 1; } command -v passt >/dev/null || { echo "passt is required for loopback SSH forwarding" >&2; exit 1; } @@ -66,8 +68,8 @@ if [[ -e "$output_dir" || -L "$output_dir" ]]; then exit 1 fi if [[ -z "$artifact" ]]; then - artifact="$repo_dir/skillet/target/x86_64-unknown-linux-musl/release/skillet-clamps" - (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p skillet-clamps) + artifact="$repo_dir/skillet/target/x86_64-unknown-linux-musl/release/skillet-$host" + (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p "skillet-$host") fi [[ -f "$artifact" ]] || { echo "Missing Skillet artifact: $artifact" >&2; exit 1; } artifact="$(realpath "$artifact")" @@ -89,15 +91,17 @@ image="$(realpath "$image")" mkdir -p "$(dirname "$output_dir")" mkdir "$output_dir" mkdir -p "$output_dir/source/includes" "$output_dir/source/files" "$output_dir/ssh" "$output_dir/ignition" -cp "$input" "$output_dir/source/clamps.bu" +cp "$input" "$output_dir/source/$host.bu" cp "$butane_dir"/includes/*.bu "$output_dir/source/includes/" -cp "$artifact" "$output_dir/source/files/skillet-clamps" -chmod 755 "$output_dir/source/files/skillet-clamps" +cp "$artifact" "$output_dir/source/files/skillet-$host" +chmod 755 "$output_dir/source/files/skillet-$host" # Keep the multi-megabyte static binary out of QEMU fw_cfg. Ignition reads -# fw_cfg very slowly for large configs; clamps-ready installs this artifact +# fw_cfg very slowly for large configs; test-vm-ready installs this artifact # over SSH after the guest has completed its first Ignition boot. -yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet-clamps"))' \ - "$output_dir/source/includes/skillet.bu" +mapfile -t skillet_includes < <(rg -l -F "/var/usrlocal/bin/skillet-$host" "$output_dir/source/includes" --glob '*.bu') +((${#skillet_includes[@]} == 1)) || { echo "Expected one Butane include with skillet-$host" >&2; exit 1; } +HOST="$host" yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet-" + strenv(HOST)))' \ + "${skillet_includes[0]}" ssh-keygen -q -t ed25519 -N '' -f "$output_dir/ssh/id_ed25519" chmod 600 "$output_dir/ssh/id_ed25519" public_key="$(cat "$output_dir/ssh/id_ed25519.pub")" @@ -110,22 +114,16 @@ PUBLIC_KEY="$public_key" yq -i \ del(.storage.files[] | select(.path == "/usr/local/bin/force_pw_change.sh")) | .storage.files += [{"path":"/etc/sudoers.d/90-skillet-vm","mode":288,"contents":{"inline":"giacomo ALL=(ALL) NOPASSWD: ALL\n"}}]' \ "$output_dir/source/includes/passwd.bu" -# The dotfiles installer replaces authorized_keys with its own symlink and -# drops the disposable key during the rebase. Keep the host source unchanged; -# skip that installer in this VM's staged Butane source. -yq -i 'del(.systemd.units[] | select(.name == "dotfiles-install.service"))' \ - "$output_dir/source/includes/brew.bu" - -ignition="$("$script_dir/butane" -s -o "$output_dir/ignition" "$output_dir/source/clamps.bu" clamps.ign)" +ignition="$("$script_dir/butane" -s -o "$output_dir/ignition" "$output_dir/source/$host.bu" "$host.ign")" ignition_bytes="$(stat -c '%s' "$ignition")" if ((ignition_bytes > 1048576)); then echo "Warning: Ignition config is ${ignition_bytes} bytes; large QEMU fw_cfg configs can take a long time to read" >&2 fi cp --reflink=auto "$image" "$output_dir/$vm_name.qcow2" -sha256sum "$output_dir/source/files/skillet-clamps" > "$output_dir/skillet.sha256" +sha256sum "$output_dir/source/files/skillet-$host" > "$output_dir/skillet.sha256" sha256sum "$image" > "$output_dir/fcos-image.sha256" -printf 'vm=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir=%s\nsource_commit=%s\n' \ - "$vm_name" "$libvirt_uri" "$ssh_port" "$image" "$artifact" "${CLAMPS_LIBVIRT_RUNTIME_DIR:-}" \ +printf 'vm=%s\nhost=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir=%s\nsource_commit=%s\n' \ + "$vm_name" "$host" "$libvirt_uri" "$ssh_port" "$image" "$artifact" "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" \ "$(git -C "$repo_dir" rev-parse HEAD)" > "$output_dir/run.conf" { virt-install --version diff --git a/ublue/butane/bin/clamps-vm b/ublue/butane/bin/test-vm similarity index 74% rename from ublue/butane/bin/clamps-vm rename to ublue/butane/bin/test-vm index 1324b35c..6491128f 100755 --- a/ublue/butane/bin/clamps-vm +++ b/ublue/butane/bin/test-vm @@ -6,7 +6,7 @@ set -euo pipefail script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" root="$(realpath -m "$script_dir/../runs")" uri=qemu:///session -runtime_dir="/run/user/$(id -u)/clamps-native-libvirt" +runtime_dir="/run/user/$(id -u)/skillet-test-libvirt" vm_virsh() { env XDG_RUNTIME_DIR="$runtime_dir" /usr/bin/virsh -c "$uri" "$@"; } ensure_native_runtime() { [[ ! -L "$runtime_dir" ]] || { echo "Symlinked libvirt runtime refused" >&2; exit 1; } @@ -21,14 +21,17 @@ ensure_native_runtime() { fi } usage() { - echo "Usage: $0 create|status|ssh|logs|reboot|destroy clamps-test-NAME [SSH_PORT for create]" >&2 + echo "Usage: $0 HOST-test-NAME create|ready|status|ssh|logs|reboot|destroy [SSH_PORT for create]" >&2 exit 2 } (($# >= 2)) || usage -operation="$1" -name="$2" +selector="$1" +operation="$2" shift 2 -[[ "$name" =~ ^clamps-test-[a-z0-9-]+$ ]] || usage +host="${selector%%-test-*}" +[[ "$host" =~ ^[a-z][a-z0-9-]*$ ]] || usage +name="$selector" +[[ "$name" =~ ^[a-z][a-z0-9-]*-test-[a-z0-9-]+$ ]] || usage run_dir="$root/$name" [[ ! -L "$root" && ! -L "$run_dir" ]] || { echo "Symlinked artifact path refused" >&2; exit 1; } @@ -38,14 +41,16 @@ inspect_owned() { echo "Missing owned run manifest for $name" >&2; exit 1; } [[ "$(realpath -e "$run_dir")" == "$run_dir" ]] || { echo "Artifact path escapes root" >&2; exit 1; } - [[ "$(config_value vm)" == "$name" && "$(config_value uri)" == "$uri" ]] || { + [[ "$(config_value vm)" == "$name" && "$(config_value host)" == "$host" && "$(config_value uri)" == "$uri" ]] || { echo "Run manifest does not match requested domain" >&2; exit 1; } + runtime_dir="$(config_value runtime_dir)" + [[ "$runtime_dir" == /run/user/"$(id -u)"/* ]] || { echo "Invalid recorded libvirt runtime" >&2; exit 1; } actual_uuid="$(vm_virsh domuuid "$name")" [[ "$actual_uuid" == "$(cat "$run_dir/domain.uuid")" ]] || { echo "Domain UUID mismatch" >&2; exit 1; } disk="$run_dir/$name.qcow2" [[ -f "$disk" && ! -L "$disk" ]] || { echo "Expected test disk is missing or symlinked" >&2; exit 1; } - ignition="$run_dir/ignition/clamps.ign" + ignition="$run_dir/ignition/$host.ign" [[ -f "$ignition" && ! -L "$ignition" ]] || { echo "Expected Ignition file is missing or symlinked" >&2; exit 1; } mapfile -t disks < <(vm_virsh domblklist "$name" --details | awk '$2 == "disk" {print $4}') [[ "${#disks[@]}" -eq 2 && "${disks[0]}" == "$disk" && "${disks[1]}" == "$ignition" ]] || { @@ -61,19 +66,25 @@ ssh_args() { } case "$operation" in create) - (($# == 1)) || usage - port="$1" + (($# <= 1)) || usage + port="${1:-2201}" [[ "$port" =~ ^22[0-9][0-9]$ ]] || { echo "Use a port from 2200 to 2299" >&2; exit 2; } [[ ! -e "$run_dir" && ! -L "$run_dir" ]] || { echo "Run path already exists" >&2; exit 1; } + [[ -f "$script_dir/../$host.bu" ]] || { echo "Missing host Butane file: $host.bu" >&2; exit 1; } ensure_native_runtime if vm_virsh dominfo "$name" &>/dev/null; then echo "Domain already exists" >&2; exit 1 fi mkdir -p "$root" - CLAMPS_LIBVIRT_RUNTIME_DIR="$runtime_dir" "$script_dir/coreos-install" --name "$name" --output-dir "$run_dir" \ - --ssh-port "$port" --libvirt-uri "$uri" "$script_dir/../clamps.bu" + TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" "$script_dir/coreos-install" --name "$name" --output-dir "$run_dir" \ + --ssh-port "$port" --libvirt-uri "$uri" "$script_dir/../$host.bu" vm_virsh domuuid "$name" > "$run_dir/domain.uuid" echo "Run: $run_dir" ;; + ready) + (($# == 0)) || usage + ensure_native_runtime + inspect_owned + "$script_dir/test-vm-ready" "$run_dir" ;; status) (($# == 0)) || usage ensure_native_runtime @@ -92,7 +103,7 @@ case "$operation" in inspect_owned ssh_args ssh "${args[@]}" giacomo@127.0.0.1 \ - 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' ;; + 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' ;; reboot) (($# == 0)) || usage ensure_native_runtime @@ -106,7 +117,7 @@ case "$operation" in vm_virsh destroy "$name" fi vm_virsh undefine "$name" - [[ "$(realpath -e "$run_dir")" == "$run_dir" && "$run_dir" == "$root"/clamps-test-* ]] || exit 1 + [[ "$(realpath -e "$run_dir")" == "$run_dir" && "$run_dir" == "$root"/"$host"-test-* ]] || exit 1 rm -rf -- "$run_dir" ;; *) usage ;; esac diff --git a/ublue/butane/bin/clamps-ready b/ublue/butane/bin/test-vm-ready similarity index 56% rename from ublue/butane/bin/clamps-ready rename to ublue/butane/bin/test-vm-ready index 973ff7c0..962ef68c 100755 --- a/ublue/butane/bin/clamps-ready +++ b/ublue/butane/bin/test-vm-ready @@ -8,9 +8,11 @@ fi run_dir="$(realpath "$1")" config_value() { sed -n "s/^$1=//p" "$run_dir/run.conf" | head -n 1; } vm="$(config_value vm)" +host="$(config_value host)" uri="$(config_value uri)" ssh_port="$(config_value ssh_port)" runtime_dir="$(config_value runtime_dir)" +[[ "$host" =~ ^[a-z][a-z0-9-]*$ && "$vm" == "$host"-test-* ]] || { echo "Invalid host VM manifest" >&2; exit 1; } vm_virsh() { if [[ -n "$runtime_dir" ]]; then env XDG_RUNTIME_DIR="$runtime_dir" virsh -c "$uri" "$@" @@ -36,7 +38,7 @@ if ! rg -Fq "$run_dir/$vm.qcow2" "$run_dir/disks.txt"; then exit 1 fi -artifact="$run_dir/source/files/skillet-clamps" +artifact="$run_dir/source/files/skillet-$host" [[ -x "$artifact" ]] || { echo "Missing host artifact: $artifact" >&2; exit 1; } deadline=$((SECONDS + 2700)) next_report=$((SECONDS + 60)) @@ -55,19 +57,19 @@ if ((SECONDS >= deadline)); then echo "Timed out waiting for guest SSH; inspect the VM serial console and $run_dir/domain.txt" >&2 exit 1 fi -scp -O "${scp_args[@]}" "$artifact" "giacomo@127.0.0.1:/var/tmp/skillet-clamps" -ssh_guest 'sudo -n install -m 0755 /var/tmp/skillet-clamps /var/usrlocal/bin/skillet-clamps && rm -f /var/tmp/skillet-clamps' -echo "Installed Skillet artifact; waiting for the signed clamps deployment..." +scp -O "${scp_args[@]}" "$artifact" "giacomo@127.0.0.1:/var/tmp/skillet-$host" +ssh_guest "sudo -n install -m 0755 /var/tmp/skillet-$host /var/usrlocal/bin/skillet-$host && rm -f /var/tmp/skillet-$host" +echo "Installed Skillet artifact; waiting for the signed $host deployment..." deadline=$((SECONDS + 2700)) next_report=$((SECONDS + 60)) while ((SECONDS < deadline)); do - if ssh_guest 'sudo -n test -e /run/clamps-bootstrap-ready' &>/dev/null; then + if ssh_guest 'sudo -n test -e /run/ucore-bootstrap-ready' &>/dev/null; then break fi - if ssh_guest 'sudo -n systemctl is-failed --quiet clamps-bootstrap.service' &>/dev/null; then - ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true - echo "Clamps bootstrap failed; diagnostics: $run_dir/failure.log" >&2 + if ssh_guest 'sudo -n systemctl is-failed --quiet ucore-bootstrap.service' &>/dev/null; then + ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true + echo "$host bootstrap failed; diagnostics: $run_dir/failure.log" >&2 exit 1 fi if ((SECONDS >= next_report)); then @@ -77,31 +79,46 @@ while ((SECONDS < deadline)); do sleep 10 done if ((SECONDS >= deadline)); then - echo "Timed out waiting for signed clamps boot" >&2 - ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true + echo "Timed out waiting for signed $host boot" >&2 + ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true echo "Diagnostics: $run_dir/failure.log" >&2 exit 1 fi if ! ssh_guest 'sudo -n systemctl reset-failed skillet-apply.service && sudo -n systemctl --wait restart skillet-apply.service'; then - ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u clamps-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true + ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true echo "Skillet base apply failed; diagnostics: $run_dir/failure.log" >&2 exit 1 fi +echo "Waiting for Homebrew and the core dotfiles profile..." +if ! ssh_guest 'sudo -n systemctl reset-failed brew-install.service dotfiles-install.service && sudo -n systemctl --wait start brew-install.service && sudo -n systemctl --wait start dotfiles-install.service'; then + ssh_guest 'sudo -n journalctl -b -u brew-install.service -u dotfiles-install.service --no-pager -n 200' > "$run_dir/user-environment-failure.log" 2>&1 || true + echo "User environment install failed; diagnostics: $run_dir/user-environment-failure.log" >&2 + exit 1 +fi ssh_guest 'sudo -n rpm-ostree status --json' > "$run_dir/final-status.json" ssh_guest 'cat /proc/sys/kernel/random/boot_id' > "$run_dir/final-boot-id" -ssh_guest 'sha256sum /var/usrlocal/bin/skillet-clamps' > "$run_dir/guest-skillet.sha256" +ssh_guest "sha256sum /var/usrlocal/bin/skillet-$host" > "$run_dir/guest-skillet.sha256" ssh_guest 'getenforce; readlink -f /etc/resolv.conf; cat /etc/resolv.conf; getent hosts ghcr.io; sudo -n systemctl is-enabled systemd-resolved.service; sudo -n systemctl status --no-pager skillet-apply.service' > "$run_dir/readiness.log" 2>&1 || true +ssh_guest 'systemctl show brew-install.service dotfiles-install.service -p Id -p Result -p ExecMainStatus; readlink -f /home/giacomo/.config/Brewfile; readlink -f /home/giacomo/.ssh/authorized_keys; /home/linuxbrew/.linuxbrew/bin/brew --version' > "$run_dir/user-environment.log" 2>&1 || true expected="$(cut -d ' ' -f 1 "$run_dir/skillet.sha256")" actual="$(cut -d ' ' -f 1 "$run_dir/guest-skillet.sha256")" [[ "$expected" == "$actual" ]] || { echo "Guest Skillet SHA mismatch" >&2; exit 1; } -if ! jq -e \ - '.deployments[] | select(.booted) | .["container-image-reference"] | test("^ostree-image-signed:docker://ghcr\\.io/gbagnoli/ucore-clamps(:latest|@sha256:[0-9a-f]{64})$")' \ +if ! jq -e --arg image "ostree-image-signed:docker://ghcr.io/gbagnoli/ucore-$host" \ + '.deployments[] | select(.booted) | .["container-image-reference"] | . == ($image + ":latest") or (startswith($image + "@sha256:") and (split("@sha256:")[1] | test("^[0-9a-f]{64}$")))' \ "$run_dir/final-status.json" >/dev/null; then - echo "Booted deployment is not the expected signed clamps origin" >&2 + echo "Booted deployment is not the expected signed $host origin" >&2 exit 1 fi # The substitutions run on the guest. # shellcheck disable=SC2016 -ssh_guest 'test "$(systemctl show -p Result --value skillet-apply.service)" = success && test "$(systemctl show -p ExecMainStatus --value skillet-apply.service)" = 0 && test "$(getenforce)" = Enforcing && test "$(readlink -f /etc/resolv.conf)" = /run/NetworkManager/resolv.conf && getent hosts ghcr.io >/dev/null && test "$(systemctl is-enabled systemd-resolved.service)" = masked' +ssh_guest 'test "$(systemctl show -p Result --value skillet-apply.service)" = success && test "$(systemctl show -p ExecMainStatus --value skillet-apply.service)" = 0 && test "$(getenforce)" = Enforcing && test "$(readlink -f /etc/resolv.conf)" = /run/NetworkManager/resolv.conf && getent hosts ghcr.io >/dev/null' +if [[ "$host" == clamps ]]; then + # The substitution runs on the guest. + # shellcheck disable=SC2016 + ssh_guest 'test "$(systemctl is-enabled systemd-resolved.service)" = masked' +fi +# The substitutions run on the guest. +# shellcheck disable=SC2016 +ssh_guest 'test -e /var/lib/ucore-bootstrap/brew-installed && test -e /var/lib/ucore-bootstrap/dotfiles-installed && test "$(readlink -f /home/giacomo/.config/Brewfile)" = "$(readlink -f /home/giacomo/.local/src/dotfiles/brew/Brewfile.core)" && test "$(readlink -f /home/giacomo/.ssh/authorized_keys)" = "$(readlink -f /home/giacomo/.local/src/dotfiles/ssh/authorized_keys)" && /home/linuxbrew/.linuxbrew/bin/brew bundle check --file /home/giacomo/.local/src/dotfiles/brew/Brewfile.core' echo "Ready: $vm on $uri; SSH: ssh -i $run_dir/ssh/id_ed25519 -p $ssh_port -o UserKnownHostsFile=$known_hosts giacomo@127.0.0.1" diff --git a/ublue/butane/clamps.bu b/ublue/butane/clamps.bu index ebe66458..78c1ee49 100644 --- a/ublue/butane/clamps.bu +++ b/ublue/butane/clamps.bu @@ -10,6 +10,7 @@ ignition: - local: includes/resolver.bu - local: includes/brew.bu - local: includes/skillet.bu + - local: includes/ucore-bootstrap.bu - local: includes/ucore-clamps.bu storage: disks: diff --git a/ublue/butane/includes/brew.bu b/ublue/butane/includes/brew.bu index 5f1851a9..78f61b95 100644 --- a/ublue/butane/includes/brew.bu +++ b/ublue/butane/includes/brew.bu @@ -27,9 +27,11 @@ systemd: contents: | [Unit] Description=Install Homebrew - After=network-online.target + Requires=ucore-bootstrap.service + After=ucore-bootstrap.service network-online.target Wants=network-online.target - ConditionPathExists=!/home/linuxbrew/.linuxbrew/bin/brew + ConditionPathExists=/run/ucore-bootstrap-ready + ConditionPathExists=!/var/lib/ucore-bootstrap/brew-installed [Service] Type=oneshot @@ -38,7 +40,8 @@ systemd: Environment="NONINTERACTIVE=1" ExecStart=curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o /tmp/install_brew.sh ExecStart=/bin/bash /tmp/install_brew.sh - RemainAfterExit=yes + ExecStartPost=+/usr/bin/touch /var/lib/ucore-bootstrap/brew-installed + TimeoutStartSec=30min [Install] WantedBy=multi-user.target @@ -47,11 +50,11 @@ systemd: contents: | [Unit] Description=Install dotfiles - # This ensures the service waits for brew to finish - After=brew-install.service - Requires=brew-install.service + Requires=ucore-bootstrap.service brew-install.service + After=ucore-bootstrap.service brew-install.service Wants=network-online.target - ConditionPathExists=!/home/giacomo/.local/src/dotfiles + ConditionPathExists=/run/ucore-bootstrap-ready + ConditionPathExists=!/var/lib/ucore-bootstrap/dotfiles-installed [Service] Type=oneshot @@ -59,9 +62,10 @@ systemd: StandardOutput=journal+console WorkingDirectory=/home/giacomo/.local/src Environment="PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - ExecStart=/usr/bin/git clone https://github.com/gbagnoli/dotfiles.git + ExecStart=/usr/bin/bash -c 'if ! test -d /home/giacomo/.local/src/dotfiles/.git; then git clone https://github.com/gbagnoli/dotfiles.git /home/giacomo/.local/src/dotfiles; fi' ExecStart=/bin/bash /home/giacomo/.local/src/dotfiles/install.sh -b core -V - RemainAfterExit=yes + ExecStartPost=+/usr/bin/touch /var/lib/ucore-bootstrap/dotfiles-installed + TimeoutStartSec=30min [Install] WantedBy=multi-user.target diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index f70dad21..a71bbb49 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -13,9 +13,9 @@ systemd: contents: | [Unit] Description=Apply Skillet clamps base configuration - Requires=clamps-bootstrap.service - After=clamps-bootstrap.service - ConditionPathExists=/run/clamps-bootstrap-ready + Requires=ucore-bootstrap.service + After=ucore-bootstrap.service + ConditionPathExists=/run/ucore-bootstrap-ready [Service] Type=oneshot diff --git a/ublue/butane/includes/ucore-bootstrap.bu b/ublue/butane/includes/ucore-bootstrap.bu new file mode 100644 index 00000000..b9489725 --- /dev/null +++ b/ublue/butane/includes/ucore-bootstrap.bu @@ -0,0 +1,75 @@ +# The script follows the booted rpm-ostree deployment, not a marker recording +# that a rebase command was requested. A failed rebase stops until the next boot. +variant: fcos +version: 1.6.0 +storage: + directories: + - path: /var/lib/ucore-bootstrap + mode: 0755 + files: + - path: /var/usrlocal/bin/ucore-rebase.sh + mode: 0755 + contents: + inline: | + #!/usr/bin/env bash + set -euo pipefail + image="$(cat /etc/ucore-bootstrap-image)" + [[ "$image" =~ ^ghcr\.io/gbagnoli/ucore-[a-z0-9-]+$ ]] || { + echo "Invalid uCore image: $image" >&2 + exit 1 + } + unsigned="ostree-unverified-registry:$image:latest" + signed="ostree-image-signed:docker://$image:latest" + status="$(rpm-ostree status --json)" + booted="$(jq -r '.deployments[] | select(.booted) | .["container-image-reference"] // ""' <<< "$status")" + pending="$(jq -r '.deployments[0] | select(.booted == false) | .["container-image-reference"] // ""' <<< "$status")" + matches_image() { + local reference="$1" scheme="$2" + [[ "$reference" == "$scheme$image:latest" || "$reference" == "$scheme$image@sha256:"* ]] + } + if matches_image "$booted" 'ostree-image-signed:docker://'; then + rm -f /var/lib/ucore-bootstrap/reboot-attempts + touch /run/ucore-bootstrap-ready + echo "Booted signed uCore deployment: $booted" + exit 0 + fi + if matches_image "$booted" 'ostree-unverified-registry:'; then + target="$signed" + target_scheme='ostree-image-signed:docker://' + else + target="$unsigned" + target_scheme='ostree-unverified-registry:' + fi + attempts_file=/var/lib/ucore-bootstrap/reboot-attempts + if ! matches_image "$pending" "$target_scheme"; then + rpm-ostree rebase --bypass-driver "$target" + echo 1 > "$attempts_file" + else + echo "Target already pending: $target" + attempts="$(cat "$attempts_file" 2>/dev/null || echo 0)" + if ((attempts >= 2)); then + echo "Pending deployment failed to boot after two attempts: $target" >&2 + exit 1 + fi + echo "$((attempts + 1))" > "$attempts_file" + fi + echo "Rebooting to $target" + systemctl reboot --no-block +systemd: + units: + - name: ucore-bootstrap.service + enabled: true + contents: | + [Unit] + Description=Boot through the configured signed uCore image + After=network-online.target + Wants=network-online.target + + [Service] + Type=oneshot + ExecStart=/var/usrlocal/bin/ucore-rebase.sh + RemainAfterExit=yes + TimeoutStartSec=45min + + [Install] + WantedBy=multi-user.target diff --git a/ublue/butane/includes/ucore-clamps.bu b/ublue/butane/includes/ucore-clamps.bu index b6910a1b..59de876e 100644 --- a/ublue/butane/includes/ucore-clamps.bu +++ b/ublue/butane/includes/ucore-clamps.bu @@ -1,71 +1,10 @@ -# The script follows the booted rpm-ostree deployment, not a marker recording -# that a rebase command was requested. A failed rebase stops until the next boot. +# Clamps supplies its image name to the shared uCore bootstrap service. variant: fcos version: 1.6.0 storage: - directories: - - path: /var/lib/clamps-bootstrap - mode: 0755 files: - - path: /var/usrlocal/bin/clamps-rebase.sh - mode: 0755 + - path: /etc/ucore-bootstrap-image + mode: 0644 contents: inline: | - #!/usr/bin/env bash - set -euo pipefail - image=ghcr.io/gbagnoli/ucore-clamps - unsigned="ostree-unverified-registry:$image:latest" - signed="ostree-image-signed:docker://$image:latest" - status="$(rpm-ostree status --json)" - booted="$(jq -r '.deployments[] | select(.booted) | .["container-image-reference"] // ""' <<< "$status")" - pending="$(jq -r '.deployments[0] | select(.booted == false) | .["container-image-reference"] // ""' <<< "$status")" - matches_image() { - local reference="$1" scheme="$2" - [[ "$reference" == "$scheme$image:latest" || "$reference" == "$scheme$image@sha256:"* ]] - } - if matches_image "$booted" 'ostree-image-signed:docker://'; then - rm -f /var/lib/clamps-bootstrap/reboot-attempts - touch /run/clamps-bootstrap-ready - echo "Booted signed clamps deployment: $booted" - exit 0 - fi - if matches_image "$booted" 'ostree-unverified-registry:'; then - target="$signed" - target_scheme='ostree-image-signed:docker://' - else - target="$unsigned" - target_scheme='ostree-unverified-registry:' - fi - attempts_file=/var/lib/clamps-bootstrap/reboot-attempts - if ! matches_image "$pending" "$target_scheme"; then - rpm-ostree rebase --bypass-driver "$target" - echo 1 > "$attempts_file" - else - echo "Target already pending: $target" - attempts="$(cat "$attempts_file" 2>/dev/null || echo 0)" - if ((attempts >= 2)); then - echo "Pending deployment failed to boot after two attempts: $target" >&2 - exit 1 - fi - echo "$((attempts + 1))" > "$attempts_file" - fi - echo "Rebooting to $target" - systemctl reboot --no-block -systemd: - units: - - name: clamps-bootstrap.service - enabled: true - contents: | - [Unit] - Description=Boot through the signed clamps image - After=network-online.target - Wants=network-online.target - - [Service] - Type=oneshot - ExecStart=/var/usrlocal/bin/clamps-rebase.sh - RemainAfterExit=yes - TimeoutStartSec=45min - - [Install] - WantedBy=multi-user.target + ghcr.io/gbagnoli/ucore-clamps diff --git a/ublue/butane/tests/bootstrap.sh b/ublue/butane/tests/bootstrap.sh index 4e343c8c..829a7a74 100644 --- a/ublue/butane/tests/bootstrap.sh +++ b/ublue/butane/tests/bootstrap.sh @@ -5,8 +5,8 @@ source_dir="$(realpath "$(dirname "${BASH_SOURCE[0]}")/..")" tmp="$(mktemp -d)" trap 'rm -rf -- "$tmp"' EXIT mkdir "$tmp/bin" -yq -r '.storage.files[] | select(.path == "/var/usrlocal/bin/clamps-rebase.sh") | .contents.inline' \ - "$source_dir/includes/ucore-clamps.bu" > "$tmp/rebase.sh" +yq -r '.storage.files[] | select(.path == "/var/usrlocal/bin/ucore-rebase.sh") | .contents.inline' \ + "$source_dir/includes/ucore-bootstrap.bu" > "$tmp/rebase.sh" cat > "$tmp/bin/rpm-ostree" <<'MOCK' #!/usr/bin/env bash @@ -33,12 +33,15 @@ signed="ostree-image-signed:docker://$image:latest" check() { local name="$1" status="$2" expected_rebase="$3" expected_reboots="$4" local expected_ready="$5" expected_exit="$6" attempts="${7:-}" fail_rebase="${8:-0}" + local configured_image="${9:-$image}" local case_dir="$tmp/$name" actual_exit=0 actual_rebase actual_reboots actual_ready mkdir -p "$case_dir/state" + printf '%s\n' "$configured_image" > "$case_dir/image" printf '%s\n' "$status" > "$case_dir/status.json" if [[ -n "$attempts" ]]; then printf '%s\n' "$attempts" > "$case_dir/state/reboot-attempts"; fi - sed -e "s@/var/lib/clamps-bootstrap@$case_dir/state@g" \ - -e "s@/run/clamps-bootstrap-ready@$case_dir/ready@g" \ + sed -e "s@/etc/ucore-bootstrap-image@$case_dir/image@g" \ + -e "s@/var/lib/ucore-bootstrap@$case_dir/state@g" \ + -e "s@/run/ucore-bootstrap-ready@$case_dir/ready@g" \ "$tmp/rebase.sh" > "$case_dir/rebase.sh" CASE_STATUS="$case_dir/status.json" CASE_REBASE_LOG="$case_dir/rebases" \ CASE_REBOOT_LOG="$case_dir/reboots" CASE_FAIL_REBASE="$fail_rebase" \ @@ -61,6 +64,9 @@ check() { } check fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' "$unsigned" 1 0 0 +check bender_fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ + 'ostree-unverified-registry:ghcr.io/gbagnoli/ucore-bender:latest' 1 0 0 '' 0 \ + 'ghcr.io/gbagnoli/ucore-bender' check unsigned_with_signed_rollback \ "{\"deployments\":[{\"booted\":true,\"container-image-reference\":\"$unsigned\"},{\"booted\":false,\"container-image-reference\":\"$signed\"}]}" \ "$signed" 1 0 0 diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 45972dda..687f964b 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -57,7 +57,9 @@ cargo run --release -p skillet -- test vm create ``` This provisions `clamps-test-smoke` on `giacomo@127.0.0.1:2201`, creates its SSH -key, and waits for the guest to pass `clamps-ready`. Then run the smoke scenario: +key, and waits for the guest to pass `test-vm clamps-test-smoke ready`. The same command +accepts another host when its `HOST.bu` and `skillet-HOST` are available. +Then run the clamps smoke scenario: ```bash cargo run --release -p skillet -- test smoke clamps diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 795eb965..044775cd 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -45,7 +45,7 @@ enum TestCommands { Run(ContainerArgs), /// Exercise the real-systemd VM scenario using an explicit disposable SSH target Smoke(SmokeArgs), - /// Create or destroy the default disposable clamps VM + /// Create or destroy a disposable host VM Vm { #[command(subcommand)] command: VmCommands, @@ -54,24 +54,28 @@ enum TestCommands { #[derive(clap::Subcommand, Debug)] enum VmCommands { - /// Provision a disposable clamps VM and wait for it to become ready + /// Provision a disposable host VM and wait for it to become ready Create(VmCreateArgs), - /// Destroy the disposable clamps VM and remove its temporary key and artifacts + /// Destroy a disposable host VM and remove its temporary key and artifacts Destroy(VmDestroyArgs), } #[derive(clap::Args, Debug)] struct VmCreateArgs { - #[arg(long, default_value = "clamps-test-smoke")] - name: String, + #[arg(default_value = "clamps")] + hostname: String, + #[arg(long)] + name: Option, #[arg(long, default_value_t = 2201)] port: u16, } #[derive(clap::Args, Debug)] struct VmDestroyArgs { - #[arg(long, default_value = "clamps-test-smoke")] - name: String, + #[arg(default_value = "clamps")] + hostname: String, + #[arg(long)] + name: Option, } #[derive(clap::Args, Debug)] @@ -196,33 +200,25 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { } fn run_vm_create(args: &VmCreateArgs) -> Result<()> { - if !valid_test_vm_name(&args.name) { - return Err(anyhow!( - "VM name must start with clamps-test- and contain lowercase letters, digits, or hyphens" - )); - } + let name = vm_name(&args.hostname, args.name.as_deref())?; if !(2200..=2299).contains(&args.port) { return Err(anyhow!("VM SSH port must be between 2200 and 2299")); } let butane = butane_root()?; - let helper = butane.join("bin/clamps-vm"); + let helper = butane.join("bin/test-vm"); let port = args.port.to_string(); - run_helper(&helper, &["create", &args.name, &port])?; + run_helper(&helper, &[&name, "create", &port])?; - let run_dir = butane.join("runs").join(&args.name); - let ready = butane.join("bin/clamps-ready"); - let run_dir_arg = run_dir - .to_str() - .ok_or_else(|| anyhow!("VM run path is not valid UTF-8"))?; - if let Err(error) = run_helper(&ready, &[run_dir_arg]) { + let run_dir = butane.join("runs").join(&name); + if let Err(error) = run_helper(&helper, &[&name, "ready"]) { return Err(anyhow!( - "VM created but readiness failed; inspect it with `clamps-vm logs {}` or destroy it with `skillet test vm destroy --name {}`: {error}", - args.name, args.name + "VM created but readiness failed; inspect it with `test-vm {} logs` or destroy it with `skillet test vm destroy {} --name {}`: {error}", + name, args.hostname, name )); } info!( "Disposable VM {} is ready at giacomo@127.0.0.1:{}; smoke key: {}", - args.name, + name, args.port, run_dir.join("ssh/id_ed25519").display() ); @@ -230,19 +226,28 @@ fn run_vm_create(args: &VmCreateArgs) -> Result<()> { } fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { - if !valid_test_vm_name(&args.name) { - return Err(anyhow!("VM name must start with clamps-test-")); - } - let helper = butane_root()?.join("bin/clamps-vm"); - run_helper(&helper, &["destroy", &args.name]) + let name = vm_name(&args.hostname, args.name.as_deref())?; + let helper = butane_root()?.join("bin/test-vm"); + run_helper(&helper, &[&name, "destroy"]) } -fn valid_test_vm_name(name: &str) -> bool { - name.len() > "clamps-test-".len() - && name.starts_with("clamps-test-") - && name - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +fn vm_name(hostname: &str, name: Option<&str>) -> Result { + let valid = |value: &str| { + !value.is_empty() + && value.starts_with(|character: char| character.is_ascii_lowercase()) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }; + if !valid(hostname) { + return Err(anyhow!("invalid VM host name: {hostname}")); + } + let name = name.map_or_else(|| format!("{hostname}-test-smoke"), str::to_owned); + let suffix = name.strip_prefix(&format!("{hostname}-test-")); + if !suffix.is_some_and(valid) { + return Err(anyhow!("VM name must begin with {hostname}-test-")); + } + Ok(name) } fn run_helper(path: &Path, args: &[&str]) -> Result<()> { @@ -262,9 +267,9 @@ fn butane_root() -> Result { .parent() .ok_or_else(|| anyhow!("Skillet workspace has no parent directory"))? .join("butane"); - if !butane.join("bin/clamps-vm").is_file() || !butane.join("bin/clamps-ready").is_file() { + if !butane.join("bin/test-vm").is_file() || !butane.join("bin/test-vm-ready").is_file() { return Err(anyhow!( - "clamps VM helpers not found under {}; check out the sibling butane directory", + "test VM helpers not found under {}; check out the sibling butane directory", butane.display() )); } From 67852afc29c9f55693e3f51712ca75460578b637 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 22:17:32 +0200 Subject: [PATCH 075/252] Use host and instance arguments for test VMs --- ublue/butane/ACCEPTANCE.md | 14 ++-- ublue/butane/README.md | 56 +++++++++------- ublue/butane/bin/test-vm | 95 ++++++++++++++++++++++++---- ublue/skillet/README.md | 8 ++- ublue/skillet/crates/cli/src/main.rs | 66 +++++++++++++------ 5 files changed, 177 insertions(+), 62 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 2c7967e9..df079f4a 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -15,11 +15,15 @@ recovery remains unverified. reboot and allow an interrupted installation to retry. - The first readiness run failed only because its symlink check compared a resolved `/var/home` path with an unresolved `/home` path. After correcting - the comparison, `test-vm clamps-test-smoke ready` passed on that same VM. -- `test-vm clamps-test-smoke reboot` followed by `test-vm clamps-test-smoke ready` passed; the + the comparison, `test-vm clamps ready smoke` passed on that same VM. +- `test-vm clamps reboot smoke` followed by `test-vm clamps ready smoke` passed; the temporary SSH key and completion markers survived the reboot. -- The VM interface is now `test-vm `. The - Skillet wrapper accepts a host for `test vm create` and `test vm destroy`. +- The VM interface is now `test-vm `, with `list` + also accepting a host or listing all hosts. The Skillet wrapper accepts + host and instance for `test vm create` and `test vm destroy`. + `test-vm clamps status smoke` confirmed the retained domain. `test-vm list` + showed clamps as available, smoke as running, a prior incomplete run as + invalid, and beezelbot as unavailable because its Butane file is absent. Other hosts still need their own Butane configuration and host artifact. - A second fresh VM, `clamps-test-generic` on port 2202, was created with the generalized command and passed signed-boot and full user-environment @@ -104,7 +108,7 @@ recovery remains unverified. | Resolver continuity | Partial | Final boot passed; each intermediate boot not checked | | Artifact delivery | Pass | Guest SHA matched `skillet.sha256` | | Automatic apply | Pass | Boot unit completed with status 0 | -| Repeat apply | Pass | `test-vm clamps-test-smoke ready` reran the base unit with `systemctl --wait` | +| Repeat apply | Pass | `test-vm clamps ready smoke` reran the base unit with `systemctl --wait` | | Reboot | Pass | Smoke reboot preserved data and container state | | Interruption | Not run | Interrupt an isolated bootstrap, then recover | | Repeatability | Not run | Second fresh VM after first passes | diff --git a/ublue/butane/README.md b/ublue/butane/README.md index af8252ba..288c54cf 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -11,35 +11,44 @@ physical-disk install file. The host needs native libvirt on `qemu:///session`, From this directory, create and check the default disposable clamps VM with: ```bash -./bin/test-vm clamps-test-smoke create -./bin/test-vm clamps-test-smoke ready +./bin/test-vm clamps create smoke +./bin/test-vm clamps ready smoke ``` For another disposable run, choose a unique test name and loopback SSH port: ```bash -./bin/test-vm clamps-test-example create 2202 -./bin/test-vm clamps-test-example ready +./bin/test-vm clamps create example --port 2202 +./bin/test-vm clamps ready example ``` -The command form is `./bin/test-vm `. The VM name must be -`HOST-test-SUFFIX`: `HOST` selects `HOST.bu` and `skillet-HOST`, while `SUFFIX` -distinguishes disposable runs. The suffix may contain lowercase letters, -digits, and hyphens. Use these commands after creation: +The command form is `./bin/test-vm `. The host must +have a Skillet host crate; creating a VM also requires `HOST.bu`. The instance +distinguishes disposable runs and may contain lowercase letters, digits, and +hyphens. The helper records `clamps` plus `smoke` as the internal domain +`clamps-test-smoke`. Use these commands after creation: ```bash -./bin/test-vm clamps-test-smoke status # domain and attached disks -./bin/test-vm clamps-test-smoke logs # bootstrap and Skillet journals -./bin/test-vm clamps-test-smoke ssh # interactive guest shell -./bin/test-vm clamps-test-smoke reboot # reboot the guest -./bin/test-vm clamps-test-smoke ready # rerun readiness after a reboot or repair -./bin/test-vm clamps-test-smoke destroy # delete the VM, disk, test key, and run artifacts +./bin/test-vm clamps list # available template and recorded instances +./bin/test-vm list # all known hosts and recorded instances +./bin/test-vm clamps status smoke # domain and attached disks +./bin/test-vm clamps logs smoke # bootstrap and Skillet journals +./bin/test-vm clamps ssh smoke # interactive guest shell +./bin/test-vm clamps reboot smoke # reboot the guest +./bin/test-vm clamps ready smoke # rerun readiness after a reboot or repair +./bin/test-vm clamps destroy smoke # delete the VM, disk, test key, and run artifacts ``` -`cargo run --release -p skillet -- test vm create` runs both `create` and -`ready` for clamps. `cargo run --release -p skillet -- test vm destroy` -removes it. Add a host argument for another host once its `HOST.bu` and -`skillet-HOST` crate exist, for example `test vm create HOST`. +`list` reports each host as `available` when both its Butane config and host +crate exist. Instance rows show the libvirt state; `missing`, `mismatch`, or +`invalid` identify runs needing inspection. The Skillet wrapper runs both +create and ready: + +```bash +cargo run --release -p skillet -- test vm create clamps smoke +cargo run --release -p skillet -- test vm list clamps +cargo run --release -p skillet -- test vm destroy clamps smoke +``` The helper calls `coreos-install`, which builds the current static `skillet-clamps` host binary. The lower-level launcher accepts `--artifact PATH` @@ -81,7 +90,7 @@ A failed rebase stops without rebooting; an already pending deployment gets at most two reboot attempts. The base unit runs `/var/usrlocal/bin/skillet-clamps apply --phase base` with no app credentials. -`./bin/test-vm NAME ready` waits up to 45 minutes, checks the expected +`./bin/test-vm HOST ready INSTANCE` waits up to 45 minutes, checks the expected domain/disk, noninteractive SSH and sudo, signed booted deployment, successful Skillet unit, guest artifact SHA, enforcing SELinux, DNS, masked resolved, Homebrew, dotfiles links, and the complete `core` Brewfile bundle. @@ -89,7 +98,8 @@ It writes `domain.txt`, `disks.txt`, `final-status.json`, `final-boot-id`, `guest-skillet.sha256`, `readiness.log`, and `user-environment.log` into the run directory. On timeout, it writes `failure.log` or `user-environment-failure.log` with relevant journals. -For manual diagnostics, use `test-vm NAME status` and `test-vm NAME logs`. +For manual diagnostics, use `test-vm HOST status INSTANCE` and +`test-vm HOST logs INSTANCE`. The equivalent guest SSH command is: ```bash @@ -97,8 +107,8 @@ ssh -i runs/NAME/ssh/id_ed25519 -p PORT giacomo@127.0.0.1 \ 'sudo rpm-ostree status; sudo journalctl -b -u ucore-bootstrap.service -u skillet-apply.service' ``` -`bin/test-vm` is a constrained source-tree helper for test domains named -`HOST-test-*` under the ignored `butane/runs` directory. `create` infers +`bin/test-vm` is a constrained source-tree helper that stores its runs under +the ignored `butane/runs` directory. `create` infers `HOST.bu`, builds `skillet-HOST`, and uses port 2201 by default. Other commands check the recorded host, domain UUID, and disk before acting. `destroy` removes only the selected test-owned resources. Its source-tree path is @@ -107,7 +117,7 @@ code and executable dependencies installed outside writable roots. The VM and its disk remain available after readiness checks. Cleanup is explicit: inspect the domain and disk, then call -`./bin/test-vm clamps-test-example destroy` for a helper-created run. For a +`./bin/test-vm clamps destroy example` for a helper-created run. For a plain launcher run, shut down and undefine the named test domain with virsh, then remove only its recorded `runs/NAME` directory after checking `domblklist`. diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 6491128f..fba8595a 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -21,21 +21,89 @@ ensure_native_runtime() { fi } usage() { - echo "Usage: $0 HOST-test-NAME create|ready|status|ssh|logs|reboot|destroy [SSH_PORT for create]" >&2 + echo "Usage: $0 [HOST] list | $0 HOST create|ready|status|ssh|logs|reboot|destroy INSTANCE [--port PORT for create]" >&2 exit 2 } +config_value() { sed -n "s/^$1=//p" "$run_dir/run.conf" | head -n 1; } +host_exists() { + [[ -f "$script_dir/../$1.bu" || -f "$script_dir/../../skillet/crates/hosts/$1/Cargo.toml" ]] +} +host_available() { + [[ -f "$script_dir/../$1.bu" && -f "$script_dir/../../skillet/crates/hosts/$1/Cargo.toml" ]] +} +list_host() { + local listed_host="$1" availability=unavailable found=false listed_run listed_name listed_instance listed_state listed_port listed_runtime recorded_uuid actual_uuid + if host_available "$listed_host"; then availability=available; fi + for listed_run in "$root"/"$listed_host"-test-*/run.conf; do + [[ -f "$listed_run" ]] || continue + found=true + run_dir="$(dirname "$listed_run")" + listed_name="$(basename "$run_dir")" + listed_instance="${listed_name#"$listed_host"-test-}" + listed_state=invalid + listed_port=- + if [[ ! -L "$run_dir" && ! -L "$listed_run" && "$(config_value vm)" == "$listed_name" && "$(config_value host)" == "$listed_host" && "$(config_value uri)" == "$uri" ]]; then + listed_port="$(config_value ssh_port)" + listed_runtime="$(config_value runtime_dir)" + if [[ "$listed_port" =~ ^22[0-9][0-9]$ && "$listed_runtime" == /run/user/"$(id -u)"/* && -f "$run_dir/domain.uuid" ]]; then + recorded_uuid="$(cat "$run_dir/domain.uuid")" + if [[ "$recorded_uuid" =~ ^[0-9a-f-]{36}$ ]] && actual_uuid="$(env XDG_RUNTIME_DIR="$listed_runtime" /usr/bin/virsh -c "$uri" domuuid "$listed_name" 2>/dev/null)"; then + if [[ "$actual_uuid" == "$recorded_uuid" ]]; then + listed_state="$(env XDG_RUNTIME_DIR="$listed_runtime" /usr/bin/virsh -c "$uri" domstate "$listed_name" 2>/dev/null)" || listed_state=unknown + else + listed_state=mismatch + fi + else + listed_state=missing + fi + fi + fi + printf '%-12s %-12s %-16s %-12s %s\n' "$listed_host" "$availability" "$listed_instance" "$listed_state" "$listed_port" + done + if [[ "$found" == false ]]; then + printf '%-12s %-12s %-16s %-12s %s\n' "$listed_host" "$availability" - none - + fi +} +list_header() { printf '%-12s %-12s %-16s %-12s %s\n' HOST TEMPLATE INSTANCE STATE PORT; } + +if (($# == 1)) && [[ "$1" == list ]]; then + [[ ! -L "$root" ]] || { echo "Symlinked artifact root refused" >&2; exit 1; } + mapfile -t hosts < <( + { + for candidate in "$script_dir"/../*.bu; do + if [[ -f "$candidate" ]]; then basename "$candidate" .bu; fi + done + for candidate in "$script_dir"/../../skillet/crates/hosts/*/Cargo.toml; do + if [[ -f "$candidate" ]]; then basename "$(dirname "$candidate")"; fi + done + } | sort -u + ) + list_header + for candidate in "${hosts[@]}"; do list_host "$candidate"; done + exit 0 +fi (($# >= 2)) || usage -selector="$1" +host="$1" operation="$2" shift 2 -host="${selector%%-test-*}" -[[ "$host" =~ ^[a-z][a-z0-9-]*$ ]] || usage -name="$selector" -[[ "$name" =~ ^[a-z][a-z0-9-]*-test-[a-z0-9-]+$ ]] || usage +if [[ ! "$host" =~ ^[a-z][a-z0-9-]*$ ]] || ! host_exists "$host"; then + echo "Unknown host: $host" >&2 + exit 2 +fi +if [[ "$operation" == list ]]; then + (($# == 0)) || usage + [[ ! -L "$root" ]] || { echo "Symlinked artifact root refused" >&2; exit 1; } + list_header + list_host "$host" + exit 0 +fi +(($# >= 1)) || usage +instance="$1" +shift +[[ "$instance" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage +name="$host-test-$instance" run_dir="$root/$name" [[ ! -L "$root" && ! -L "$run_dir" ]] || { echo "Symlinked artifact path refused" >&2; exit 1; } - -config_value() { sed -n "s/^$1=//p" "$run_dir/run.conf" | head -n 1; } inspect_owned() { [[ -d "$run_dir" && -f "$run_dir/run.conf" && -f "$run_dir/domain.uuid" ]] || { echo "Missing owned run manifest for $name" >&2; exit 1; @@ -66,11 +134,16 @@ ssh_args() { } case "$operation" in create) - (($# <= 1)) || usage - port="${1:-2201}" + if (($# == 0)); then + port=2201 + elif (($# == 2)) && [[ "$1" == --port ]]; then + port="$2" + else + usage + fi [[ "$port" =~ ^22[0-9][0-9]$ ]] || { echo "Use a port from 2200 to 2299" >&2; exit 2; } [[ ! -e "$run_dir" && ! -L "$run_dir" ]] || { echo "Run path already exists" >&2; exit 1; } - [[ -f "$script_dir/../$host.bu" ]] || { echo "Missing host Butane file: $host.bu" >&2; exit 1; } + host_available "$host" || { echo "Host $host needs both $host.bu and a skillet-$host crate" >&2; exit 1; } ensure_native_runtime if vm_virsh dominfo "$name" &>/dev/null; then echo "Domain already exists" >&2; exit 1 diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 687f964b..34ab77aa 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -53,12 +53,14 @@ Podman operation. `--inspect` opens the container before cleanup. For real systemd/Podman and reboot coverage, create a disposable VM first: ```bash -cargo run --release -p skillet -- test vm create +cargo run --release -p skillet -- test vm create clamps smoke ``` This provisions `clamps-test-smoke` on `giacomo@127.0.0.1:2201`, creates its SSH -key, and waits for the guest to pass `test-vm clamps-test-smoke ready`. The same command +key, and waits for the guest to pass `test-vm clamps ready smoke`. The same command accepts another host when its `HOST.bu` and `skillet-HOST` are available. +Use `cargo run --release -p skillet -- test vm list` to see host availability +and recorded instances, or add a host to list only its instances. Then run the clamps smoke scenario: ```bash @@ -75,7 +77,7 @@ files, so you can inspect a run and rerun it on the same disposable VM. When finished, remove the disposable VM, disk, SSH key, and run artifacts: ```bash -cargo run --release -p skillet -- test vm destroy +cargo run --release -p skillet -- test vm destroy clamps smoke ``` The VM helpers require the host setup documented in `../butane/README.md`. diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 044775cd..b449ee09 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -58,24 +58,27 @@ enum VmCommands { Create(VmCreateArgs), /// Destroy a disposable host VM and remove its temporary key and artifacts Destroy(VmDestroyArgs), + /// List available host templates and their recorded disposable VMs + List(VmListArgs), } #[derive(clap::Args, Debug)] struct VmCreateArgs { - #[arg(default_value = "clamps")] hostname: String, - #[arg(long)] - name: Option, + instance: String, #[arg(long, default_value_t = 2201)] port: u16, } #[derive(clap::Args, Debug)] struct VmDestroyArgs { - #[arg(default_value = "clamps")] hostname: String, - #[arg(long)] - name: Option, + instance: String, +} + +#[derive(clap::Args, Debug)] +struct VmListArgs { + hostname: Option, } #[derive(clap::Args, Debug)] @@ -152,6 +155,12 @@ fn main() -> Result<()> { command: VmCommands::Destroy(args), }, } => run_vm_destroy(&args)?, + Commands::Test { + command: + TestCommands::Vm { + command: VmCommands::List(args), + }, + } => run_vm_list(&args)?, } Ok(()) } @@ -170,7 +179,7 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { }); if !identity.is_file() { return Err(anyhow!( - "SSH key not found at {}; create the default VM with `skillet test vm create` or pass --identity", + "SSH key not found at {}; create the default VM with `skillet test vm create clamps smoke` or pass --identity", identity.display() )); } @@ -200,20 +209,23 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { } fn run_vm_create(args: &VmCreateArgs) -> Result<()> { - let name = vm_name(&args.hostname, args.name.as_deref())?; + let name = vm_name(&args.hostname, &args.instance)?; if !(2200..=2299).contains(&args.port) { return Err(anyhow!("VM SSH port must be between 2200 and 2299")); } let butane = butane_root()?; let helper = butane.join("bin/test-vm"); let port = args.port.to_string(); - run_helper(&helper, &[&name, "create", &port])?; + run_helper( + &helper, + &[&args.hostname, "create", &args.instance, "--port", &port], + )?; let run_dir = butane.join("runs").join(&name); - if let Err(error) = run_helper(&helper, &[&name, "ready"]) { + if let Err(error) = run_helper(&helper, &[&args.hostname, "ready", &args.instance]) { return Err(anyhow!( - "VM created but readiness failed; inspect it with `test-vm {} logs` or destroy it with `skillet test vm destroy {} --name {}`: {error}", - name, args.hostname, name + "VM created but readiness failed; inspect it with `test-vm {} logs {}` or destroy it with `skillet test vm destroy {} {}`: {error}", + args.hostname, args.instance, args.hostname, args.instance )); } info!( @@ -226,12 +238,21 @@ fn run_vm_create(args: &VmCreateArgs) -> Result<()> { } fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { - let name = vm_name(&args.hostname, args.name.as_deref())?; + vm_name(&args.hostname, &args.instance)?; let helper = butane_root()?.join("bin/test-vm"); - run_helper(&helper, &[&name, "destroy"]) + run_helper(&helper, &[&args.hostname, "destroy", &args.instance]) } -fn vm_name(hostname: &str, name: Option<&str>) -> Result { +fn run_vm_list(args: &VmListArgs) -> Result<()> { + let helper = butane_root()?.join("bin/test-vm"); + if let Some(hostname) = &args.hostname { + run_helper(&helper, &[hostname, "list"]) + } else { + run_helper(&helper, &["list"]) + } +} + +fn vm_name(hostname: &str, instance: &str) -> Result { let valid = |value: &str| { !value.is_empty() && value.starts_with(|character: char| character.is_ascii_lowercase()) @@ -242,12 +263,17 @@ fn vm_name(hostname: &str, name: Option<&str>) -> Result { if !valid(hostname) { return Err(anyhow!("invalid VM host name: {hostname}")); } - let name = name.map_or_else(|| format!("{hostname}-test-smoke"), str::to_owned); - let suffix = name.strip_prefix(&format!("{hostname}-test-")); - if !suffix.is_some_and(valid) { - return Err(anyhow!("VM name must begin with {hostname}-test-")); + if !instance + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + || !instance + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(anyhow!("invalid VM instance: {instance}")); } - Ok(name) + Ok(format!("{hostname}-test-{instance}")) } fn run_helper(path: &Path, args: &[&str]) -> Result<()> { From 918c9cb08e845d03a788a1475944db55a11f85b2 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 22:26:22 +0200 Subject: [PATCH 076/252] Repair Skillet container integration runner --- ublue/skillet/crates/cli/src/main.rs | 20 +++++++++++++++---- .../skillet/crates/cli/src/test_entrypoint.sh | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index b449ee09..953d4d91 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -362,7 +362,16 @@ fn run_container_test(args: &ContainerArgs) -> Result<()> { "skillet-test-dummy-secret", )?; let start = Command::new("podman") - .args(["run", "-d", "--rm", "--name", &name, "-v"]) + .args([ + "run", + "-d", + "--rm", + "--security-opt", + "label=disable", + "--name", + &name, + "-v", + ]) .arg(format!("{}:/usr/bin/skillet:ro", binary.display())) .arg("-v") .arg(format!("{}:/run/credentials:ro", creds.display())) @@ -403,10 +412,10 @@ fn run_container_test(args: &ContainerArgs) -> Result<()> { } fn run_twice_and_check(name: &str, inspect: bool) -> Result<()> { - let entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_entrypoint.sh"); + let entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/test_entrypoint.sh"); for round in ["first", "second"] { let status = Command::new("podman") - .args(["exec", name, "/bin/sh", "-s", "--", round]) + .args(["exec", "-i", name, "/bin/sh", "-s", "--", round]) .stdin(fs::File::open(&entry)?) .status() .context("running apply in container failed")?; @@ -449,7 +458,10 @@ fn podman_capture(name: &str, shell: &str) -> Result { .args(["exec", name, "/bin/sh", "-c", shell]) .output()?; if !out.status.success() { - return Err(anyhow!("failed to inspect apply operation log")); + return Err(anyhow!( + "failed to inspect apply operation log: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); } String::from_utf8(out.stdout).context("operation log was not UTF-8") } diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh index b352d4fa..8318ff1c 100644 --- a/ublue/skillet/crates/cli/src/test_entrypoint.sh +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -23,7 +23,7 @@ printf '%s\n' "$*" >> /tmp/skillet-test.podman.log case "$1 $2" in "secret exists") [ -f /tmp/skillet-test-state/secret ] ;; "secret inspect") - case "$*" in *'{{.ID}}'*) printf 'skillet-test-secret-id\n' ;; *) cat /tmp/skillet-test-state/secret-hash ;; esac ;; + case "$*" in *'{{.ID}}'*) printf 'skillet-test-secret-id\n' ;; *) cat /tmp/skillet-test-state/secret-hash ;; esac ;; "secret create") shift 2 From 91ae212c1e1ef7af0bd06512e88c83fb4152764d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 26 Sep 2026 22:29:30 +0200 Subject: [PATCH 077/252] Skip unavailable Cookstyle in pre-push hook --- hooks/pre-push.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/hooks/pre-push.sh b/hooks/pre-push.sh index 8bb205ee..be213596 100755 --- a/hooks/pre-push.sh +++ b/hooks/pre-push.sh @@ -3,10 +3,14 @@ set -eu pushd "$(git rev-parse --show-toplevel)" >/dev/null -cinc exec cookstyle site-cookbooks roles +if command -v cinc >/dev/null 2>&1 && cinc --version >/dev/null 2>&1; then + cinc exec cookstyle site-cookbooks roles +else + echo "Skipping Cookstyle: cinc is not installed" >&2 +fi uv run ruff check --diff . uv run ruff format --diff . uv run mypy --ignore-missing-imports site-cookbooks fabfile.py -find . -name "*.sh" -not -path "./.venv/*" -not -path "./berks-cookbooks/*" -exec shellcheck -x {} + +git ls-files -z -- "*.sh" | xargs -0 -r shellcheck -x shellcheck -x run popd >/dev/null From c3ae98ab26630093c06a1013ce0ee89a91c48603 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 27 Sep 2026 10:38:56 +0200 Subject: [PATCH 078/252] Support Flatpak libvirt for disposable test VMs --- ublue/butane/README.md | 37 ++++++++--- ublue/butane/bin/coreos-install | 16 +++-- ublue/butane/bin/flatpak-virt | 17 +++++ ublue/butane/bin/test-vm | 111 +++++++++++++++++++++++++++----- ublue/butane/bin/test-vm-ready | 6 +- ublue/butane/bin/virsh | 5 ++ ublue/butane/bin/virt-install | 9 +-- 7 files changed, 163 insertions(+), 38 deletions(-) create mode 100755 ublue/butane/bin/flatpak-virt create mode 100755 ublue/butane/bin/virsh diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 288c54cf..0997b8db 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -1,12 +1,24 @@ # Clamps VM bootstrap A clean disposable VM create passed signed boot and readiness on 2026-09-26. +The Flatpak backend on Bazzite passed create, status, and readiness on 2026-09-27. See [ACCEPTANCE.md](ACCEPTANCE.md) for the checks performed and remaining work. `clamps.bu` is a VM configuration. It resizes and reformats partition 4 on `/dev/vda`, the virtio disk attached by this launcher. Do not use it as a -physical-disk install file. The host needs native libvirt on `qemu:///session`, -`virt-install`, `passt`, Podman, `yq`, `rg`, `ssh-keygen`, and KVM. +physical-disk install file. The host needs Podman, `yq`, `rg`, `ssh-keygen`, +and KVM. Native libvirt additionally needs `passt`; the Flatpak backend uses +QEMU user networking. VM commands can come from native libvirt and +`virt-install`, or an existing Flatpak virt-manager with its QEMU extension. + +On Bazzite, the helper uses the existing Flatpak virt-manager installation +when native libvirt is absent. The `bin/virsh` and `bin/virt-install` wrappers +run those tools in the Flatpak sandbox. The helper starts separate Flatpak +libvirt daemons under `/run/user/UID/skvm`, grants them access to `butane/`, +and uses QEMU user networking to forward the selected localhost SSH port. +The helper records the selected backend in `runs/NAME/run.conf` so later +commands use the same VM runtime. Check that `/dev/kvm` is accessible on the +host. The helper does not install host software. From this directory, create and check the default disposable clamps VM with: @@ -65,14 +77,19 @@ Homebrew and the `core` dotfiles profile install after the signed clamps boot. The dotfiles installer links `authorized_keys`; the SSH configuration also reads Ignition's key file, so the generated VM key remains usable. `--image PATH` selects a specific FCOS qcow2; -the default is `images/coreos.qcow2` when present. The VM uses passt with an -inbound forward bound to `127.0.0.1`. - -`bin/test-vm` starts a separate native user libvirt daemon with -`XDG_RUNTIME_DIR=/run/user/UID/skillet-test-libvirt`. This keeps the native -daemon in the same filesystem view as generated artifacts when Flatpak -virt-manager has its own session daemon. For manual virsh inspection of a -helper-created VM, set that environment variable on each virsh invocation. +the default is `images/coreos.qcow2` when present. Both VM backends forward +the SSH port on `127.0.0.1`; native libvirt uses passt and Flatpak uses QEMU +user networking. + +When native libvirt is available, `bin/test-vm` starts a separate user daemon +with `XDG_RUNTIME_DIR=/run/user/UID/skillet-test-libvirt`. For manual virsh +inspection of a native helper-created VM, set that variable on each call. +For Flatpak runs, use the same isolated runtime when invoking `virsh` by hand: + +```bash +TEST_VM_LIBVIRT_RUNTIME_DIR="/run/user/$(id -u)/skvm" \ + ./bin/virsh -c qemu:///session list --all +``` Connect with: diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 10e1d03d..64b254c1 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -21,7 +21,7 @@ artifact= image= libvirt_uri=qemu:///session vm_command() { - if [[ -n "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" ]]; then + if [[ -n "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" && "${TEST_VM_BACKEND:-native}" == native ]]; then env XDG_RUNTIME_DIR="$TEST_VM_LIBVIRT_RUNTIME_DIR" "$@" else "$@" @@ -122,8 +122,8 @@ fi cp --reflink=auto "$image" "$output_dir/$vm_name.qcow2" sha256sum "$output_dir/source/files/skillet-$host" > "$output_dir/skillet.sha256" sha256sum "$image" > "$output_dir/fcos-image.sha256" -printf 'vm=%s\nhost=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir=%s\nsource_commit=%s\n' \ - "$vm_name" "$host" "$libvirt_uri" "$ssh_port" "$image" "$artifact" "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" \ +printf 'vm=%s\nhost=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir=%s\nbackend=%s\nsource_commit=%s\n' \ + "$vm_name" "$host" "$libvirt_uri" "$ssh_port" "$image" "$artifact" "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" "${TEST_VM_BACKEND:-native}" \ "$(git -C "$repo_dir" rev-parse HEAD)" > "$output_dir/run.conf" { virt-install --version @@ -132,7 +132,13 @@ printf 'vm=%s\nhost=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir= yq --version } > "$output_dir/tool-versions.txt" -network="passt,model=virtio,portForward0.proto=tcp,portForward0.address=127.0.0.1,portForward0.range0.start=$ssh_port,portForward0.range0.to=22" +if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then + network=none + qemu_commandline="-fw_cfg name=opt/com.coreos/config,file=$ignition -netdev user,id=skilletnet,hostfwd=tcp:127.0.0.1:$ssh_port-:22 -device virtio-net-pci,netdev=skilletnet,bus=pcie.0,addr=0x1d" +else + network="passt,model=virtio,portForward0.proto=tcp,portForward0.address=127.0.0.1,portForward0.range0.start=$ssh_port,portForward0.range0.to=22" + qemu_commandline="-fw_cfg name=opt/com.coreos/config,file=$ignition" +fi echo "Creating $vm_name on $libvirt_uri, disk $output_dir/$vm_name.qcow2, SSH 127.0.0.1:$ssh_port" >&2 vm_command virt-install --connect "$libvirt_uri" --name "$vm_name" \ --osinfo linux2024 --memory 8192 --vcpus 2 --import \ @@ -140,4 +146,4 @@ vm_command virt-install --connect "$libvirt_uri" --name "$vm_name" \ --disk "path=$ignition,format=raw,bus=virtio,readonly=on" \ --network "$network" --graphics none \ --console pty,target_type=serial --noautoconsole \ - --qemu-commandline="-fw_cfg name=opt/com.coreos/config,file=$ignition" + --qemu-commandline="$qemu_commandline" diff --git a/ublue/butane/bin/flatpak-virt b/ublue/butane/bin/flatpak-virt new file mode 100755 index 00000000..3507e037 --- /dev/null +++ b/ublue/butane/bin/flatpak-virt @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +(($# >= 1)) || { echo "Usage: $0 COMMAND [ARG...]" >&2; exit 2; } +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=../../lib/utils.sh +source "$(realpath "$script_dir/../../lib/utils.sh")" + +flatpak_args=(--filesystem=host --filesystem="$(realpath "$script_dir/..")" + --env=PATH=/app/bin:/usr/bin:/app/lib/extensions/bin:/run/host/usr/bin) +if [[ -n "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" ]]; then + flatpak_args+=(--filesystem=xdg-run/skvm --env="XDG_RUNTIME_DIR=$TEST_VM_LIBVIRT_RUNTIME_DIR") +fi +command_name="$1" +shift +run_on_host flatpak run "${flatpak_args[@]}" --command="$command_name" org.virt_manager.virt-manager "$@" diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index fba8595a..1b9c0901 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -7,7 +7,71 @@ script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" root="$(realpath -m "$script_dir/../runs")" uri=qemu:///session runtime_dir="/run/user/$(id -u)/skillet-test-libvirt" -vm_virsh() { env XDG_RUNTIME_DIR="$runtime_dir" /usr/bin/virsh -c "$uri" "$@"; } +backend= +vm_virsh() { + if [[ "$backend" == flatpak ]]; then + "$script_dir/virsh" -c "$uri" "$@" + else + env XDG_RUNTIME_DIR="$runtime_dir" /usr/bin/virsh -c "$uri" "$@" + fi +} +select_backend() { + if [[ -x /usr/bin/virsh && -x /usr/bin/virtqemud && -x /usr/bin/virtstoraged ]]; then + backend=native + elif flatpak info org.virt_manager.virt-manager >/dev/null 2>&1 && + flatpak info org.virt_manager.virt_manager.Extension.Qemu >/dev/null 2>&1; then + backend=flatpak + runtime_dir="/run/user/$(id -u)/skvm" + export TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" + export PATH="$script_dir:$PATH" + else + echo 'Neither native libvirt nor Flatpak virt-manager is available.' >&2 + exit 1 + fi +} +runtime_from_manifest() { + backend="$(config_value backend)" + runtime_dir="$(config_value runtime_dir)" + if [[ -z "$backend" && "$runtime_dir" == /run/user/"$(id -u)"/* ]]; then + backend=native + fi + [[ "$backend" == native || "$backend" == flatpak ]] || { echo 'Invalid VM backend in manifest' >&2; exit 1; } + if [[ "$backend" == flatpak ]]; then + export TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" + export PATH="$script_dir:$PATH" + fi +} +ensure_runtime() { + if [[ "$backend" == native ]]; then + ensure_native_runtime + else + flatpak info org.virt_manager.virt-manager >/dev/null 2>&1 && + flatpak info org.virt_manager.virt_manager.Extension.Qemu >/dev/null 2>&1 || { + echo 'Flatpak virt-manager or its QEMU extension is unavailable' >&2; exit 1; + } + [[ "$runtime_dir" == "/run/user/$(id -u)/skvm" && ! -L "$runtime_dir" ]] || { + echo 'Invalid Flatpak libvirt runtime path' >&2; exit 1; + } + mkdir -p -m 0700 "$runtime_dir" + if [[ ! -f "$runtime_dir/flatpak.pid" ]] || ! kill -0 "$(cat "$runtime_dir/flatpak.pid")" 2>/dev/null; then + setsid "$script_dir/flatpak-virt" sh -c \ + 'virtlogd --timeout 0 & virtstoraged --timeout 0 & virtqemud --timeout 0 & wait' \ + > "$runtime_dir/flatpak.log" 2>&1 < /dev/null & + echo "$!" > "$runtime_dir/flatpak.pid" + fi + for ((attempt=0; attempt<20; attempt++)); do + if [[ -S "$runtime_dir/libvirt/virtqemud-sock" && -S "$runtime_dir/libvirt/virtstoraged-sock" ]]; then + break + fi + sleep 1 + done + [[ -S "$runtime_dir/libvirt/virtqemud-sock" && -S "$runtime_dir/libvirt/virtstoraged-sock" ]] || { + echo 'Flatpak libvirt daemons did not start' >&2 + tail -30 "$runtime_dir/flatpak.log" >&2 + exit 1 + } + fi +} ensure_native_runtime() { [[ ! -L "$runtime_dir" ]] || { echo "Symlinked libvirt runtime refused" >&2; exit 1; } if [[ ! -d "$runtime_dir" ]]; then @@ -32,7 +96,8 @@ host_available() { [[ -f "$script_dir/../$1.bu" && -f "$script_dir/../../skillet/crates/hosts/$1/Cargo.toml" ]] } list_host() { - local listed_host="$1" availability=unavailable found=false listed_run listed_name listed_instance listed_state listed_port listed_runtime recorded_uuid actual_uuid + local listed_host="$1" availability=unavailable found=false listed_run listed_name listed_instance listed_state listed_port listed_runtime listed_backend recorded_uuid actual_uuid + local -a listed_virsh if host_available "$listed_host"; then availability=available; fi for listed_run in "$root"/"$listed_host"-test-*/run.conf; do [[ -f "$listed_run" ]] || continue @@ -45,11 +110,18 @@ list_host() { if [[ ! -L "$run_dir" && ! -L "$listed_run" && "$(config_value vm)" == "$listed_name" && "$(config_value host)" == "$listed_host" && "$(config_value uri)" == "$uri" ]]; then listed_port="$(config_value ssh_port)" listed_runtime="$(config_value runtime_dir)" - if [[ "$listed_port" =~ ^22[0-9][0-9]$ && "$listed_runtime" == /run/user/"$(id -u)"/* && -f "$run_dir/domain.uuid" ]]; then + listed_backend="$(config_value backend)" + [[ -n "$listed_backend" ]] || listed_backend=native + if [[ "$listed_port" =~ ^22[0-9][0-9]$ && -f "$run_dir/domain.uuid" && ( ( "$listed_backend" == flatpak && "$listed_runtime" == "/run/user/$(id -u)/skvm" ) || ( "$listed_backend" == native && "$listed_runtime" == /run/user/"$(id -u)"/* ) ) ]]; then recorded_uuid="$(cat "$run_dir/domain.uuid")" - if [[ "$recorded_uuid" =~ ^[0-9a-f-]{36}$ ]] && actual_uuid="$(env XDG_RUNTIME_DIR="$listed_runtime" /usr/bin/virsh -c "$uri" domuuid "$listed_name" 2>/dev/null)"; then + if [[ "$listed_backend" == flatpak ]]; then + listed_virsh=(env TEST_VM_LIBVIRT_RUNTIME_DIR="$listed_runtime" "$script_dir/virsh") + else + listed_virsh=(env XDG_RUNTIME_DIR="$listed_runtime" /usr/bin/virsh) + fi + if [[ "$recorded_uuid" =~ ^[0-9a-f-]{36}$ ]] && actual_uuid="$("${listed_virsh[@]}" -c "$uri" domuuid "$listed_name" 2>/dev/null)"; then if [[ "$actual_uuid" == "$recorded_uuid" ]]; then - listed_state="$(env XDG_RUNTIME_DIR="$listed_runtime" /usr/bin/virsh -c "$uri" domstate "$listed_name" 2>/dev/null)" || listed_state=unknown + listed_state="$("${listed_virsh[@]}" -c "$uri" domstate "$listed_name" 2>/dev/null)" || listed_state=unknown else listed_state=mismatch fi @@ -112,8 +184,13 @@ inspect_owned() { [[ "$(config_value vm)" == "$name" && "$(config_value host)" == "$host" && "$(config_value uri)" == "$uri" ]] || { echo "Run manifest does not match requested domain" >&2; exit 1; } - runtime_dir="$(config_value runtime_dir)" - [[ "$runtime_dir" == /run/user/"$(id -u)"/* ]] || { echo "Invalid recorded libvirt runtime" >&2; exit 1; } + runtime_from_manifest + if [[ "$backend" == native ]]; then + [[ "$runtime_dir" == /run/user/"$(id -u)"/* ]] || { echo "Invalid recorded libvirt runtime" >&2; exit 1; } + else + [[ "$runtime_dir" == "/run/user/$(id -u)/skvm" ]] || { echo "Invalid Flatpak runtime" >&2; exit 1; } + fi + ensure_runtime actual_uuid="$(vm_virsh domuuid "$name")" [[ "$actual_uuid" == "$(cat "$run_dir/domain.uuid")" ]] || { echo "Domain UUID mismatch" >&2; exit 1; } disk="$run_dir/$name.qcow2" @@ -144,47 +221,51 @@ case "$operation" in [[ "$port" =~ ^22[0-9][0-9]$ ]] || { echo "Use a port from 2200 to 2299" >&2; exit 2; } [[ ! -e "$run_dir" && ! -L "$run_dir" ]] || { echo "Run path already exists" >&2; exit 1; } host_available "$host" || { echo "Host $host needs both $host.bu and a skillet-$host crate" >&2; exit 1; } - ensure_native_runtime + select_backend + if [[ "$backend" == native ]]; then + for executable in virt-install qemu-system-x86_64 passt; do + command -v "$executable" >/dev/null || { echo "Missing native VM tool: $executable" >&2; exit 1; } + done + fi + if [[ ! -e /dev/kvm ]]; then + echo '/dev/kvm is unavailable; enable hardware virtualization and KVM before creating a VM.' >&2 + exit 1 + fi + ensure_runtime if vm_virsh dominfo "$name" &>/dev/null; then echo "Domain already exists" >&2; exit 1 fi mkdir -p "$root" - TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" "$script_dir/coreos-install" --name "$name" --output-dir "$run_dir" \ + TEST_VM_BACKEND="$backend" TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" "$script_dir/coreos-install" --name "$name" --output-dir "$run_dir" \ --ssh-port "$port" --libvirt-uri "$uri" "$script_dir/../$host.bu" vm_virsh domuuid "$name" > "$run_dir/domain.uuid" echo "Run: $run_dir" ;; ready) (($# == 0)) || usage - ensure_native_runtime inspect_owned "$script_dir/test-vm-ready" "$run_dir" ;; status) (($# == 0)) || usage - ensure_native_runtime inspect_owned vm_virsh dominfo "$name" vm_virsh domblklist "$name" ;; ssh) (($# == 0)) || usage - ensure_native_runtime inspect_owned ssh_args exec ssh "${args[@]}" giacomo@127.0.0.1 ;; logs) (($# == 0)) || usage - ensure_native_runtime inspect_owned ssh_args ssh "${args[@]}" giacomo@127.0.0.1 \ 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' ;; reboot) (($# == 0)) || usage - ensure_native_runtime inspect_owned vm_virsh reboot "$name" ;; destroy) (($# == 0)) || usage - ensure_native_runtime inspect_owned if [[ "$(vm_virsh domstate "$name")" != 'shut off' ]]; then vm_virsh destroy "$name" diff --git a/ublue/butane/bin/test-vm-ready b/ublue/butane/bin/test-vm-ready index 962ef68c..debcd78c 100755 --- a/ublue/butane/bin/test-vm-ready +++ b/ublue/butane/bin/test-vm-ready @@ -12,9 +12,13 @@ host="$(config_value host)" uri="$(config_value uri)" ssh_port="$(config_value ssh_port)" runtime_dir="$(config_value runtime_dir)" +backend="$(config_value backend)" +[[ -n "$backend" ]] || backend=native [[ "$host" =~ ^[a-z][a-z0-9-]*$ && "$vm" == "$host"-test-* ]] || { echo "Invalid host VM manifest" >&2; exit 1; } vm_virsh() { - if [[ -n "$runtime_dir" ]]; then + if [[ "$backend" == flatpak ]]; then + TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" virsh -c "$uri" "$@" + elif [[ -n "$runtime_dir" ]]; then env XDG_RUNTIME_DIR="$runtime_dir" virsh -c "$uri" "$@" else virsh -c "$uri" "$@" diff --git a/ublue/butane/bin/virsh b/ublue/butane/bin/virsh new file mode 100755 index 00000000..78872fa3 --- /dev/null +++ b/ublue/butane/bin/virsh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +exec "$script_dir/flatpak-virt" virsh "$@" diff --git a/ublue/butane/bin/virt-install b/ublue/butane/bin/virt-install index 61b7e93a..105f44a6 100755 --- a/ublue/butane/bin/virt-install +++ b/ublue/butane/bin/virt-install @@ -2,10 +2,5 @@ set -euo pipefail -script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" -# shellcheck source-path=SCRIPTDIR -# shellcheck source=../../lib/utils.sh -source "$(realpath "$script_dir/../../lib/utils.sh")" - -run_on_host flatpak run --filesystem=host --command=virt-install org.virt_manager.virt-manager "$@" -exit $? +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +exec "$script_dir/flatpak-virt" virt-install "$@" From 426930c2fe8bf552ad7f023e4319ccc36229b5ff Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 27 Sep 2026 12:02:18 +0200 Subject: [PATCH 079/252] Document secret delivery and VM lifecycle; organize migration plans --- ublue/AGENTS.md | 24 ++++++ ublue/CLAMPS-AGENT-1.md | 57 -------------- ublue/CLAMPS-AGENT-2.md | 64 --------------- ublue/butane/README.md | 28 +++---- ublue/design/secrets.md | 78 +++++++++++++++++++ ublue/design/smoke-vms.md | 63 +++++++++++++++ ublue/{ => plan}/CLAMPS-MIGRATION.md | 70 +++++++---------- .../{ => plan}/CLAMPS-STORAGE-CREDENTIALS.md | 50 +++++++----- ublue/skillet/README.md | 46 +++++++---- 9 files changed, 263 insertions(+), 217 deletions(-) create mode 100644 ublue/AGENTS.md delete mode 100644 ublue/CLAMPS-AGENT-1.md delete mode 100644 ublue/CLAMPS-AGENT-2.md create mode 100644 ublue/design/secrets.md create mode 100644 ublue/design/smoke-vms.md rename ublue/{ => plan}/CLAMPS-MIGRATION.md (53%) rename ublue/{ => plan}/CLAMPS-STORAGE-CREDENTIALS.md (68%) diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md new file mode 100644 index 00000000..e33f0b61 --- /dev/null +++ b/ublue/AGENTS.md @@ -0,0 +1,24 @@ +# uBlue agent instructions + +## Design documentation + +- Read the relevant documents in [design/](design/) before changing architecture + or behavior they describe, including [secrets](design/secrets.md) and + [smoke VM lifecycle](design/smoke-vms.md). +- Keep those documents updated in the same change as the implementation or + decision they describe. Clearly distinguish planned from implemented behavior. +- Record new architectural decisions in `ublue/design/`, updating an existing + document when appropriate or adding a short document for a new topic. +- Keep design documents brief: describe the decision, why it was chosen, and + material consequences. Keep setup and usage instructions in READMEs, with + links to the relevant designs instead of duplicated rationale. + +## Implementation plans + +- Keep implementation plans in [plan/](plan/), not in the `ublue/` root. +- Read relevant plans before starting work and update their remaining steps, + status, and links as work progresses. Plans describe tasks and acceptance + criteria; architectural decisions and rationale belong in `design/`. +- Remove completed or superseded plans after preserving any unfinished work + in an active plan. Retain implementation evidence in the relevant acceptance + record; do not label unverified checks as passed. diff --git a/ublue/CLAMPS-AGENT-1.md b/ublue/CLAMPS-AGENT-1.md deleted file mode 100644 index b467182d..00000000 --- a/ublue/CLAMPS-AGENT-1.md +++ /dev/null @@ -1,57 +0,0 @@ -# Agent assignment 1: reliable Skillet convergence - -Draft assignment; writing this file does not launch an agent. Suggested model: `gpt-6-sol`, medium reasoning, fresh context with this assignment and the migration draft. The parent reviews the implementation and evidence. - -## Objective - -Make Skillet's existing resources reliably converge under real systemd and Podman. Deliver a repeatable smoke check for agent 2's disposable uCore VM. Do not add production services or implement the later Pi-hole/storage/TPM migration. - -Read `ublue/CLAMPS-MIGRATION.md`, `ublue/skillet/AGENTS.md`, and the relevant Rust implementations before editing. Resolve applicable instructions from the actual checkout. Preserve unrelated changes. - -## Ownership and interface - -- Own `ublue/skillet/`, including tests, documentation, and its integration-test instructions. Coordinate any edit outside that directory with the parent. -- Agent 2 owns `ublue/butane/`. Do not change its launcher or bootstrap units. -- Preserve normal full-apply behavior. Add an explicit `apply --phase base` option to both the generic and clamps binaries. The base phase applies only the host baseline and must not read application credentials or start Pi-hole. Default `apply` continues to mean the full configured host; missing required credentials must remain an error there. -- Both entry points must call the same baseline implementation. Keep this a small explicit phase selection; no general dependency scheduler or plugin framework. -- Produce the static `skillet-clamps` artifact and document its exact build command, path and SHA256. Agent 2 supplies it to the guest and invokes `skillet-clamps apply --phase base`. -- Provide a documented smoke command against an explicit SSH target. It must fail closed without a disposable-target opt-in and never default to the workstation or production clamps. Agent 2 owns VM creation; this check owns guest assertions. - -## Implementation plan - -1. Fix service start/restart calls to wait for startup completion and report failures. Remove the blanket `--wait` use, which waits for service termination. Bound smoke-test waits and retain failure output. -2. Correct generated Quadlet lifecycle: use `[Install]` plus generator reload for persistence, not `systemctl enable` on generated units. Ensure inactive desired services start even when files are unchanged. Preserve ordinary unit enablement where appropriate. -3. Track and recover unfinished convergence. A configuration write followed by failed reload/restart must be retried on the next apply. Avoid a change flag that disappears across runs before the service consumes the change. -4. Propagate changes to consumed secrets into container recreation. Recover from interrupted secret updates; a failed inspect must not automatically be interpreted as 'missing'. Do not expose secret values in logs, arguments, recordings or errors. -5. Fix ownership/mode churn on paths managed by both application and container resources. Add only the primitives needed for these cases. -6. Implement the base-phase interface above. Cover both CLI entry points and retain normal full-apply errors. -7. Replace recording equality as the acceptance oracle. Keep recordings only if diagnostically useful; remove obsolete test commands/docs if scrapping them. Update `AGENTS.md` explicitly to replace the old container-recording requirement with unit checks plus the real-runtime scenario. - -## Smoke scenario and exit criteria - -Use a harmless fixture container configured through the same Rust resources used by hosts. Do not start the partial production Pi-hole implementation for this milestone. Use dummy secrets and a persistent test volume. Mock systemctl/Podman is forbidden for runtime acceptance; unit tests may use mocks. - -| Case | Required observation | -| --- | --- | -| First apply | Returns within a documented deadline; service is running; fixture reports expected configuration; volume is usable | -| Repeat apply | Managed bytes, permissions and ownership are unchanged; container identity/start timestamp is unchanged | -| Stopped service | Apply starts it despite unchanged configuration | -| Configuration change | Running fixture consumes the new configuration; subsequent apply causes no further recreation | -| Secret rotation | Running fixture consumes the replacement dummy secret; verification reports pass/fail without printing it | -| Startup failure | Apply returns nonzero and useful diagnostics; fixing the cause and reapplying identical desired configuration succeeds | -| Interrupted convergence | Inject failure after writing desired state but before successful activation; a new apply completes pending work | -| Reboot | Fixture starts automatically; persistent test data remains; another apply succeeds without unnecessary restart | -| Base/full separation | Base succeeds without credentials; full clamps apply still rejects missing required credentials | - -Capture hashes, ownership/modes, systemd result and container identity before/after. An unchanged operation count is not enough. Check content and runtime state. Fixture namespaces, files and services must be distinctive and removable without touching other guest workloads. - -Run formatting, workspace tests, and pedantic Clippy with warnings denied, using the workspace's musl configuration. Tests belong in separate modules. Preserve `thiserror` in libraries, no `unsafe`, no library `unwrap`/`expect`, and crate-first system interactions. - -## Handoff and completion - -Two statuses are deliberately distinct: - -- **Ready for VM integration:** implementation, focused unit tests, lint/build checks, artifact and smoke command are ready. Include any runtime checks already performed. Agent 2 may start from this handoff. -- **Accepted:** all smoke cases have passed with real systemd/Podman, including on agent 2's final uCore VM, and the parent has reviewed the changes. If a guest is unavailable, report pending runtime validation; do not claim acceptance. - -Return changed files, interface/build commands, artifact hash, test results, evidence paths, and unresolved issues. Agent 2 reports Skillet defects to this agent/parent instead of editing owned files concurrently. No nested delegation, production changes, publishing or physical disk operations. diff --git a/ublue/CLAMPS-AGENT-2.md b/ublue/CLAMPS-AGENT-2.md deleted file mode 100644 index 81160731..00000000 --- a/ublue/CLAMPS-AGENT-2.md +++ /dev/null @@ -1,64 +0,0 @@ -# Agent assignment 2: reproducible clamps VM boot - -Draft assignment; writing this file does not launch an agent. Suggested model: `gpt-6-sol`, medium reasoning, fresh context with this assignment and the migration draft. Start implementation after agent 1 provides the base-phase interface and artifact. The parent reviews changes and acceptance evidence. - -## Objective - -From `ublue/butane/`, one documented invocation of `./bin/coreos-install clamps.bu` provisions a disposable VM which reaches the signed uCore clamps deployment and automatically completes the Skillet base phase. The agent may use virsh, SSH, serial logs, guest commands, reboot, and repeated Skillet applies to diagnose and validate it. - -Final acceptance must come from a fresh install with fixes encoded in the source. Manual guest repairs alone do not count. This assignment proves VM boot, not physical hardware/Secure Boot, TPM credentials, Pi-hole parity, production storage or cutover. - -## Ownership and preflight - -- Own `ublue/butane/` and its documentation/tests. Shared `ublue/lib/` changes require parent coordination. Agent 1 owns `ublue/skillet/`; request Rust fixes through that agent/parent. -- Read the migration draft, launcher, Butane wrapper, all clamps includes, host-execution helpers, and agent 1's handoff. -- Existing scripts use a Flatpak virt-install wrapper, user networking without an explicit inbound SSH path, a domain named `clamps`, and unconditional deletion of `images/clamps.img`. Fix collision handling before executing them against existing artifacts. -- Identify the actual libvirt URI and execution environment. Use the same URI for creation, inspection and cleanup; account for host commands when inside distrobox. Check permissions, KVM, disk space, RAM, Podman, Butane and SSH access. Host tools existing on PATH is not proof of working virtualization. -- Use only newly created disposable VM disks. Refuse to overwrite an existing domain or disk unless it is identified as belonging to this test and replacement was explicitly requested. Preserve any pre-existing `clamps` VM/image. Support a unique VM-name/output option while retaining the simple invocation on a clean environment. -- Report permission/environment blocks with the failing command. Use normal escalation when required; do not silently change host networking, security settings, or unrelated VMs. - -## Agreed handoff from agent 1 - -- A static host binary `skillet-clamps` supporting `apply --phase base`, with a build command and SHA256. -- The default full apply is unchanged and requires application credentials where configured. -- A smoke check accepting an explicit disposable SSH target, using real systemd/Podman and dummy fixtures. - -## Implementation plan - -1. Make the launcher safe to repeat. Parse inputs reliably, isolate generated artifacts, name the VM explicitly, and reject collisions before replacing anything. Provide intentional cleanup/recreate for test-owned resources. Show the exact VM identity and output paths. -2. Establish unattended SSH access. Prefer loopback-bound forwarding on the existing user-network setup if supported; otherwise use a documented isolated libvirt network. Use test-specific SSH configuration and known_hosts. Permit existing authorized credentials or generate an ignored ephemeral test key; embed only its public key. Avoid interactive password-change prompts in test access. -3. Keep `/dev/vda` specific to the VM layout. Separate or clearly guard physical-install disk selection. Do not choose the final `/srv` design here. -4. Build/stage the supplied Skillet artifact automatically as part of provisioning, or accept a documented artifact override. For this milestone, prefer an Ignition-delivered artifact under `/var/lib/skillet/` with executable permissions and a persistent unit invoking it. Verify execution with SELinux enforcing. Do not rely on modifying immutable `/usr`, publishing a new image, or manually copying the binary after first boot. -5. Provision bootstrap DNS before the first rebase into the resolved-masked image. Use a single owner for resolver state, obtain suitable upstreams from the test network, and avoid dependence on the future Pi-hole service or a nonexistent resolved runtime file. Confirm NetworkManager behavior on the actual guest. Do not hardcode production LAN values as a convenience. -6. Make rebase stages recoverable and sequenced. Distinguish requested/pending deployments from the actually booted deployment using rpm-ostree state. Verify the expected signed origin and signature policy, not just marker files or an image name. Prevent Skillet starting before the intended booted deployment. Bound failure/retry behavior so a failed rebase cannot create a reboot loop. -7. Configure `skillet-apply.service` to invoke the explicit base phase without application credentials. Arrange reruns/recovery after failure. Keep all required baseline files, access and binary delivery valid across both rebase boots. -8. Add an automated readiness/verification command using virsh plus SSH/serial diagnostics. Poll with deadlines; report the failing stage and journals. A successful virt-install exit or SSH response alone is not readiness. - -The local image checkout is at `759f195` as of drafting. Preserve its common-before-host build dependency. If the published image cannot support the task, report the exact mismatch and proposed image change to the parent; do not silently publish or weaken verification. - -## Exit criteria - -| Case | Evidence required | -| --- | --- | -| Clean install | The documented launcher invocation completes provisioning from a fresh VM disk with no manual guest repair; exact input revisions and artifact SHA recorded | -| Access | virsh identifies the expected domain and disk; noninteractive SSH and needed sudo work using the documented connection method | -| Rebase sequence | Boot IDs and deployment/journal evidence show the intended transitions and the final booted signed clamps deployment; no pending bootstrap transition or repeating rebase | -| Resolver continuity | External resolution and registry access work at the stages that need them and after final reboot; resolved remains masked on final uCore | -| Artifact delivery | Guest binary SHA matches the staged artifact and it executes under enforcing SELinux after rebases | -| Automatic apply | Skillet unit finishes successfully on the intended deployment without application credentials or an SSH-triggered first apply | -| Repeat apply | Two further base applies succeed without unnecessary file changes or service restarts; use actual state evidence | -| Reboot | Another ordinary reboot retains SSH, DNS, binary and successful baseline state; bootstrap does not restart the rebase cycle | -| Interruption | Interrupt a disposable bootstrap at a recorded stage; the subsequent boot recovers to the expected final state without hand-editing markers | -| Repeatability | A second fresh VM creation from the final source passes the same checks; existing unrelated VM/disk collision is refused safely | -| Real Skillet scenario | Run agent 1's fixture checks on final uCore, including repeat apply, failure recovery, secret rotation and reboot | - -Run strict Butane validation, shell syntax checks and ShellCheck for changed scripts when available. Rerun the end-to-end installation after the last bootstrap-affecting edit. Capture unexpected failed units and resolve those caused by the change; disclose unrelated failures. - -## Deliverables - -- Source changes and concise instructions: clean provision, connection details, readiness check, base apply, log collection and explicit test cleanup. -- A result for every exit criterion: pass, fail, or not run, with reproducible commands and local evidence paths. Include actual image digests, tool versions, guest boot IDs and final deployment status. -- Diagnostics for failed runs without credential material. Track generated keys, Ignition, VM disks and logs outside committed source. -- Leave the successful VM inspectable and report its domain/URI/connection command. Clean up only test-owned resources when explicitly requested or according to documented test lifecycle. - -Return to the parent for review when criteria pass. Report environment or unavailable-image blockers accurately; never label an unexecuted acceptance check as passed. No nested delegation, production changes, physical disk operations or image publishing. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 0997b8db..2bbd5f42 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -4,6 +4,9 @@ A clean disposable VM create passed signed boot and readiness on 2026-09-26. The Flatpak backend on Bazzite passed create, status, and readiness on 2026-09-27. See [ACCEPTANCE.md](ACCEPTANCE.md) for the checks performed and remaining work. +Design decisions: [disposable VM lifecycle](../design/smoke-vms.md) and +[secret storage and delivery](../design/secrets.md). + `clamps.bu` is a VM configuration. It resizes and reformats partition 4 on `/dev/vda`, the virtio disk attached by this launcher. Do not use it as a physical-disk install file. The host needs Podman, `yq`, `rg`, `ssh-keygen`, @@ -16,9 +19,8 @@ when native libvirt is absent. The `bin/virsh` and `bin/virt-install` wrappers run those tools in the Flatpak sandbox. The helper starts separate Flatpak libvirt daemons under `/run/user/UID/skvm`, grants them access to `butane/`, and uses QEMU user networking to forward the selected localhost SSH port. -The helper records the selected backend in `runs/NAME/run.conf` so later -commands use the same VM runtime. Check that `/dev/kvm` is accessible on the -host. The helper does not install host software. +The selected backend is recorded in `runs/NAME/run.conf`. Check that `/dev/kvm` +is accessible on the host. The helper does not install host software. From this directory, create and check the default disposable clamps VM with: @@ -65,17 +67,13 @@ cargo run --release -p skillet -- test vm destroy clamps smoke The helper calls `coreos-install`, which builds the current static `skillet-clamps` host binary. The lower-level launcher accepts `--artifact PATH` to use a specific binary. It records its SHA256 in -`runs/NAME/skillet.sha256`. To keep the QEMU `fw_cfg` Ignition payload small, -the VM config omits this multi-megabyte binary; `test-vm ready` transfers it +`runs/NAME/skillet.sha256`. `test-vm ready` transfers the binary over SSH after first boot and installs it at `/var/usrlocal/bin/skillet-clamps`. -The shared uCore bootstrap script also lives in `/var/usrlocal/bin`: Fedora CoreOS labels -that persistent directory `bin_t`, allowing systemd to execute these files -with SELinux enforcing. +The shared uCore bootstrap script also lives in `/var/usrlocal/bin`. Generated Ignition, SSH key, VM disk and logs stay under `runs/NAME`, which Git ignores. The test key is private and only its public half enters Ignition. Homebrew and the `core` dotfiles profile install after the signed clamps boot. -The dotfiles installer links `authorized_keys`; the SSH configuration also -reads Ignition's key file, so the generated VM key remains usable. +The dotfiles installer links `authorized_keys`; SSH also reads Ignition's key file. `--image PATH` selects a specific FCOS qcow2; the default is `images/coreos.qcow2` when present. Both VM backends forward the SSH port on `127.0.0.1`; native libvirt uses passt and Flatpak uses QEMU @@ -124,13 +122,9 @@ ssh -i runs/NAME/ssh/id_ed25519 -p PORT giacomo@127.0.0.1 \ 'sudo rpm-ostree status; sudo journalctl -b -u ucore-bootstrap.service -u skillet-apply.service' ``` -`bin/test-vm` is a constrained source-tree helper that stores its runs under -the ignored `butane/runs` directory. `create` infers -`HOST.bu`, builds `skillet-HOST`, and uses port 2201 by default. Other commands check the -recorded host, domain UUID, and disk before acting. `destroy` removes only -the selected test-owned resources. Its source-tree path is -mutable, so an enduring elevated approval requires reviewed, frozen helper -code and executable dependencies installed outside writable roots. +`create` selects `HOST.bu`, builds `skillet-HOST`, and uses port 2201 by default. +Other commands check the recorded host, domain UUID, and disk before acting. +`destroy` removes only the selected test-owned resources. The VM and its disk remain available after readiness checks. Cleanup is explicit: inspect the domain and disk, then call diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md new file mode 100644 index 00000000..1ed65a56 --- /dev/null +++ b/ublue/design/secrets.md @@ -0,0 +1,78 @@ +# Secret storage and delivery + +Decision: use KeePassXC for durable secrets and read its KDBX database directly +from workstation Skillet through a Rust library. Delivery is planned; the host +credential reader and Podman secret convergence already exist. + +## Portable storage + +KeePassXC is already the user's password manager, and Syncthing already moves +the encrypted database between machines. Reusing that database avoids a custom +vault format, export mechanism, or second authoritative store. Its location is +a workstation setting pointing to the existing synced `.kdbx` file. +KeePassXC owns edits and conflict resolution; Skillet reads it in memory. +Backups include the encrypted database, with recovery passwords and any key +files retained separately. Syncthing replication alone is not versioned backup. + +The desktop keyring may remember the database unlock password, accessed through +Rust's Secret Service support. A new workstation can unlock the copied database +without the old workstation's keyring. The existing `cloudflare-token-creator` +keyring item needs a one-time migration into KeePassXC. + +Vault paths are group paths plus an entry title; values use the Password field. +The prefix is consistently singular, `skillet`: + +| Vault entry | Host credential and Podman secret name | +| --- | --- | +| `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | +| `skillet/hosts/clamps/cloudflare/acme-token` | `cloudflare_acme_token` | +| `skillet/cloudflare/token-creator` | Workstation only | + +Host entries follow `skillet/hosts///`; shared credentials +follow `skillet//`. Exact lookups reject missing or ambiguous +entries. Production values persist across workstation changes; absence never +implicitly generates a replacement. Rotation is explicit. Normal host re-apply +can reuse credentials already installed on that host. + +## Delivery and consumption + +After signed boot and base readiness, the workstation sends only the target's +selected secret values through SSH stdin using its recorded/verified host key. +The host encrypts stdin with `systemd-creds` and atomically installs root-owned +mode `0600` files at `/etc/credstore.encrypted/skillet/.cred`. +Keeping delivery after bootstrap avoids plaintext in Ignition and run artifacts. +Secret values never enter command arguments or diagnostics. + +Use host-key encryption for disposable VMs; production can use TPM2 binding +after confirming hardware support and rebase behavior. Host-key encryption is +recoverable by someone with the guest disk and its key; these files are runtime +copies, while KeePassXC supplies portable recovery. + +The full-apply systemd unit loads each file with `LoadCredentialEncrypted=`. +Skillet reads `$CREDENTIALS_DIRECTORY/` and sends the value through +stdin to `podman secret create`. Quadlets reference secret names with `Secret=`: +Pi-hole receives a file under `/run/secrets/` through `WEBPASSWORD_FILE`; Caddy +receives `cloudflare_acme_token` through `type=env,target=CF_API_TOKEN`. +Rotation recreates the affected container to consume the new secret. +Podman's default secret storage persists a copy on the guest disk; systemd's +credential encryption does not encrypt that copy. + +## Disposable credentials + +Smoke VMs get generated test passwords, never production host entries. Live +ACME testing reads `skillet/cloudflare/token-creator` locally and creates one +short-lived Cloudflare token per VM, with Zone Read and DNS Edit restricted to +the separately managed smoke-test zone. That permission covers the whole zone; +unique test names prevent accidental record collisions, not API access to +other records. Keep the literal zone outside this public repository. The +creator remains on the workstation. + +VM secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, +expiry, and owned record IDs in run metadata, without token values. Existing +VM credentials survive re-apply and reboot. Expired tokens require explicit +renewal; cleanup and recovery belong to the [VM lifecycle](smoke-vms.md). + +References: [KeePassXC](https://keepassxc.org/docs/KeePassXC_GettingStarted), +[Rust KDBX reader](https://github.com/sseemayer/keepass-rs), +[Cloudflare token creation](https://developers.cloudflare.com/fundamentals/api/how-to/create-via-api/), +[systemd credentials](https://github.com/systemd/systemd/blob/main/docs/CREDENTIALS.md). diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md new file mode 100644 index 00000000..6c6af0d9 --- /dev/null +++ b/ublue/design/smoke-vms.md @@ -0,0 +1,63 @@ +# Disposable smoke VM lifecycle + +Decision: keep VM creation, readiness, repeatable smoke checks, and destruction +separate. A retained VM supports inspection and repeated convergence checks; +a fresh VM establishes that bootstrap is reproducible. The existing fixture +tests real systemd/Podman convergence. Application and live ACME scenarios, +secret delivery, and external token cleanup are planned. + +## Identity and ownership + +The interface is `test-vm `; `list` may omit the host. +Host selects the Butane template and Skillet crate; instance identifies a run. +`clamps` and `smoke` become the internal domain `clamps-test-smoke`. +The ignored `butane/runs/-test-/` directory records domain UUID, +disk, backend, artifact hash, SSH port, temporary SSH key, and known host key. +Commands validate ownership before acting, so an arbitrary libvirt domain name +does not authorize its deletion. + +The libvirt runtime is shared across instances for a user/backend and recorded +in the manifest. Each concurrent VM has a distinct localhost SSH port. Native +libvirt and the existing virt-manager Flatpak support different workstation +OSes without installing host packages. The Flatpak runtime grants access to +the test artifacts and uses QEMU user networking. + +## States and recovery + +1. **Create:** build the host binary, allocate the run directory and SSH key, + and provision a fresh VM disk. Ignition contains the public SSH key and + base configuration. Large binaries travel over SSH because embedding them + in QEMU `fw_cfg` previously caused long boot delays. +2. **Ready:** wait for SSH, deliver the binary, complete unsigned then signed + rebase, and check the booted deployment, base apply, resolver, SELinux, and + user environment. Persistent bootstrap state tolerates reboots. Executable + staging uses `/var/usrlocal/bin`, whose FCOS labeling permits execution. + Ignition's authorized-key file remains configured alongside dotfiles keys. +3. **Provision applications (planned):** deliver disposable credentials using + the [shared secret mechanism](secrets.md), then perform full apply. VM + passwords and tokens remain independent of production credentials. +4. **Smoke:** run assertions against the retained VM. Check repeat apply, + configuration/secret changes, interruption recovery, and reboot persistence. + The container sandbox uses mocked systemctl/Podman; only VM checks establish + real runtime behavior. Failures retain the VM and diagnostics for repair. +5. **Destroy:** validate ownership, stop the VM, revoke its external tokens and + remove only its recorded test DNS records, then remove the domain, disk, + SSH key, and artifacts. External cleanup is planned; current destroy only + handles local resources. Failed cleanup retains metadata for a retry, even + if the domain is already gone. Token expiry limits abandoned credentials; + it does not remove abandoned DNS records. + +## Live ACME boundary + +Routine smoke checks need no Cloudflare access. The optional live scenario +uses Let's Encrypt staging and a unique hostname under the separately managed +smoke-test DNS zone, preserving its other records. Keep the literal zone +outside this public repository. DNS-01 needs no publicly reachable VM. Staging certificates are untrusted; +the test verifies their expected identity using explicit staging trust. +Normal reruns reuse credentials and certificate state; fresh issuance is an +explicit scenario. Production DNS cutover is separate from VM acceptance. + +Acceptance requires a fresh create, readiness, application checks, unchanged +second apply, and reboot persistence. Destruction must also demonstrate that +external credentials and test-owned resources are cleaned up. An interrupted +run must either resume or leave enough ownership metadata for cleanup. diff --git a/ublue/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md similarity index 53% rename from ublue/CLAMPS-MIGRATION.md rename to ublue/plan/CLAMPS-MIGRATION.md index df1b6f3b..b51b7234 100644 --- a/ublue/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -1,59 +1,42 @@ # Clamps migration draft -Status: implementation checkpoint, 2026-09-26. Milestones 1 and 2 were delegated and implemented; final VM acceptance remains outstanding. Later milestones remain a planning draft. - -Detailed delegation drafts: [agent 1 — Skillet convergence](CLAMPS-AGENT-1.md) and [agent 2 — reproducible VM boot](CLAMPS-AGENT-2.md). Agent 1 hands off a tested base-phase interface and smoke check; agent 2 supplies final uCore runtime evidence. Both require parent review before acceptance. +Status: remaining work, 2026-09-27. Skillet convergence and disposable VM +bootstrap are implemented; their completed agent assignments have been removed. +Runtime evidence and outstanding bootstrap checks are recorded in +[ACCEPTANCE.md](../butane/ACCEPTANCE.md). ## Goal and starting point Provision clamps with uCore + Skillet and migrate the required behavior and persistent state of the Chef-managed rupik. Complete one milestone at a time; add reusable Skillet resources when a service needs them. -Repositories refreshed with `git pull --ff-only`: - -- devmachine, branch `skillet`, commit `1fa4fd4`: already current. -- ucore-images, branch `main`, commit `759f195`: now includes serialized common/host builds, `wol` and `et`, and removal of the `/usr/local/bin/wol` wrapper. CI status and hardware operation were not checked. - -Skillet currently has hardening, container/secret primitives, and partial Pi-hole configuration. Its broader OS-hardening function is a placeholder. Butane defines installation and autorebase services, but binary delivery, credential provisioning, resolver continuity, and boot sequencing are incomplete. +Implemented: base/full apply separation, activation recovery, fixture smoke +checks, temporary VM SSH keys, binary delivery, resolver configuration, signed +bootstrap, and user environment. Pi-hole remains partial. KeePassXC reading, +secret delivery, live ACME, and production storage are not implemented. -The starting-point description above refers to the pre-migration commits. The current checkpoint adds explicit base-phase apply, service activation recovery, a real-runtime smoke runner, staged binary delivery, resolver configuration and deployment-aware bootstrap. Static/unit validation does not establish successful installation: see [VM acceptance evidence](butane/ACCEPTANCE.md) for the remaining runtime checks. - -The next acceptance gate is a fresh disposable VM reaching the signed deployment and completing base apply, followed by the smoke runner and reboot/repeatability checks. Resolve that before implementing storage. The selected storage direction is boxy's single Btrfs filesystem with separate OS/data subvolumes; the mount path and final physical disk remain open in the [milestone 3 discussion](CLAMPS-STORAGE-CREDENTIALS.md). +Next: generic secret delivery and Pi-hole, following the +[secret design](../design/secrets.md), [VM lifecycle](../design/smoke-vms.md), and +[storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The selected storage +direction is boxy's shared Btrfs filesystem with OS/data subvolumes; mount path +and final physical disk remain open. ## Boundaries - Image: packages, static OS files, baseline unit enablement/masking. Preserve common-before-host image builds. -- Ignition: installation layout, initial access, bootstrap configuration and credential delivery. +- Ignition: installation layout, initial access and bootstrap configuration. +- Workstation Skillet: KeePassXC reads and secret delivery over SSH after base readiness. - Skillet: continuing convergence of host values, application configuration, secrets, storage resources and containers. - Public repositories and images must exclude secret values, including secrets compiled into host binaries. Decide which topology values also require private delivery. - Keep the production Samsung SSD and functioning DNS on rupik until cutover. Test on the stock clamps SSD or disposable VM disks. - Leave desktop `ublue/tailscale/` and `ublue/llama/` outside this work. - Preserve Rust safety/error-handling rules and idempotency. Replace the blanket `systemctl --wait` rule with waiting for startup job completion: `--wait` waits for service termination and can hang on persistent services. -## Milestone 1: make Skillet convergence reliable - -Scope: repair the existing execution path before adding another application. - -- Correct service start/restart waiting and propagate failures. -- Let Quadlet's generator process `[Install]`; remove explicit enabling of generated services. -- Ensure an unchanged desired service is started if inactive. Restart/recreate it when its configuration or consumed secret changes. -- Handle interrupted applies: a file written before a failed reload/start must not prevent recovery on the next apply. -- Avoid configuration churn, including competing ownership updates to the same directories. -- Add focused unit tests for these failure and convergence cases, using the existing separate test modules. -- Replace the recording-comparison acceptance check with a small disposable VM smoke scenario using real systemd, Podman and a harmless container. Check first apply, second apply, stopped-service recovery, changed configuration, and reboot persistence. -- Keep recording only if useful for diagnostics. Update `skillet/AGENTS.md` and documentation to reflect the replacement; do not maintain a second harness merely to preserve old recordings. - -Done when: apply completes, failures are visible, a second apply causes no unnecessary changes/restarts, and interrupted work can recover. Formatting, workspace tests and pedantic Clippy pass; the real-runtime smoke scenario passes. - -## Milestone 2: boot a reproducible clamps base - -- Separate VM disk selection from physical installation; remove the physical install's dependence on `/dev/vda`. -- Establish SSH access, users/sudo requirements and hardware/Secure Boot preparation before unattended provisioning. -- Supply working bootstrap DNS in Ignition. The image masks resolved, so DNS must work before Skillet can run or pull containers. Decide the resolver owner and upstreams, then have Skillet maintain that policy. -- Deliver a versioned Skillet artifact to a location compatible with ostree and rebases. Decide image delivery versus a persistent runtime location; do not assume an Ignition-written `/usr/bin` binary survives rebases. -- Sequence unsigned rebase, signed rebase and apply using the actually booted deployment. Existing markers record requested rebases, not proof that the target deployment has booted. -- Decouple baseline provisioning from application credentials so the base can converge before Pi-hole is introduced. +## Remaining bootstrap verification -Done when: a fresh VM reaches the signed clamps deployment, retains SSH and DNS through reboots, and runs the baseline apply successfully. Repeat on the stock SSD after hardware preparation. +- Exercise interrupted bootstrap and capture resolver continuity across intermediate boots. +- Reconcile the acceptance log's repeatability status with its recorded second fresh VM; do not infer unrecorded checks passed. +- Confirm automatic base apply before readiness explicitly restarts the unit, and collision refusal for unrelated domains/disks. +- Prepare physical disk selection and hardware/Secure Boot/MOK enrollment separately from the VM-only `/dev/vda` template; validate on the stock SSD. ## Milestone 3: storage and credentials for the first application @@ -63,16 +46,16 @@ Resolve only the decisions needed for Pi-hole first. - Put formatting/partition creation in the installation path. Skillet must not format existing data during convergence. - Make applications depend on their real data mount so a missing disk cannot silently produce an empty replacement data directory on the OS filesystem. - Add directory/subvolume/ownership support as required. Defer container graphroot relocation unless chosen explicitly. -- Implement SOPS/age delivery for one credential end to end. Choose individual credentials versus a bundle, safe serialization, and handling of generated Ignition artifacts. -- Define TPM requirements, encryption timing, recovery and rotation. Make encryption and cleanup recoverable after interruption; verify a usable encrypted credential before deleting the input. -- Decide Podman secret storage protection explicitly. Its default file driver does not inherit systemd credential encryption. +- Implement direct KeePassXC reads and SSH delivery of named systemd credentials according to the secret design. Use generated disposable passwords in VM tests. +- Verify host-key encryption in VMs, atomic delivery, interruption recovery and rotation. Confirm production TPM binding against actual hardware and rebase behavior. +- Account for Podman's on-disk secret copy in the production disk-protection decision. Done when: Pi-hole's storage and password can be provisioned, recovered after interruption, and rotated without leaking values into repository files, command arguments or logs. Reboot/rebase recovery works. The persistent-data mount failure case is exercised. ## Milestone 4: working Pi-hole on an isolated test address - Add the dual-stack network resource, explicit container attachment and auto-update configuration. -- Decide whether to retain the Chef web pod or publish Pi-hole ports independently. Preserve the eventual nginx integration contract. +- Decide whether to retain the Chef web pod or publish Pi-hole ports independently. Keep its admin endpoint compatible with the planned Caddy proxy. - Correct Pi-hole password-file consumption, container identity/permissions, SELinux labels, DNS publication, upstreams, web port and custom-record format for the selected image version. - Replace placeholder DNS records with supplied configuration. Support multiple names per address. - Keep host bootstrap DNS independent of Pi-hole availability. Verify IPv4/IPv6 TCP and UDP DNS, web authentication, persistence, rotation, repeated apply and reboot. @@ -90,7 +73,7 @@ Each row is a separate implementation and validation step. Adjust order for depe | 2 | btrbk | Real Btrfs subvolumes, hourly snapshots, Chef retention policy, restore exercise; local snapshots are not an independent backup | | 3 | UniFi | Controller data or supported backup restore, version compatibility, adoption and ownership | | 4 | Tailscale | Authentication/identity policy, exit-node approval, forwarding, persistent state, DNS policy and hardware interface settings | -| 5 | nginx + OAuth2 proxy + ACME | Domains/routes, access rules, Cloudflare real-IP handling, certificates, renewal and reload | +| 5 | Caddy evaluation + OAuth2 proxy + ACME | Port required nginx domains/routes and access rules; validate Cloudflare DNS-01 with staging, per-VM tokens, certificate persistence and cleanup | | 6 | Cloudflare DDNS | Required records and token delivery; reconcile the legacy updater before enabling competing writers | | 7 | Monitoring and remaining host baseline | Explicit keep/drop decision for Datadog; required hardening, users, SSH/sudo, ET and WOL behavior | @@ -111,9 +94,8 @@ Done when: all retained capabilities and data work on clamps, routine convergenc ## Decisions deferred until needed -- Base bootstrap: artifact delivery and resolver ownership/upstreams. - Storage: stock-SSD test layout, final disk arrangement, Pi-hole path, container graphroot. -- Credentials: artifact handling, TPM/recovery policy, Podman storage and rotation. +- Credentials: production TPM/rebase policy and protection of Podman's on-disk copy; portable storage and delivery are decided in the secret design. - Services: actual DNS records, domains/routes, Tailscale enrollment, Nebula Sync, Datadog and legacy networking. - Cutover: address/identity transitions, outage window and rollback duration. diff --git a/ublue/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md similarity index 68% rename from ublue/CLAMPS-STORAGE-CREDENTIALS.md rename to ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md index 4fc2a1e8..994bdf1b 100644 --- a/ublue/CLAMPS-STORAGE-CREDENTIALS.md +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -1,10 +1,14 @@ # Milestone 3 discussion: storage and credentials -Status: draft, 2026-09-25. Parent-owned planning document while agents implement milestones 1 and 2. User prefers boxy's single Btrfs filesystem with separate OS/data subvolumes. Mount path, final disk and credential policy remain open. +Status: remaining work, 2026-09-27. Base provisioning is implemented. Storage +mount path and final disk remain open; credential decisions are recorded in +[the secret design](../design/secrets.md). ## Split into two small steps -3A establishes a persistent application-data mount and proves it behaves correctly. 3B delivers one dummy Pi-hole password end to end and proves recovery and rotation. Pi-hole itself is introduced in milestone 4. +3A establishes the application-data mount. 3B delivers a disposable Pi-hole +password and proves recovery and rotation. Start with 3B on the existing VM; +settle storage before migrating production Pi-hole state. ## 3A: application storage @@ -49,22 +53,27 @@ Snapshots, Syncthing data transfer and final disk cutover are later milestones. ## 3B: one credential end to end -Retain the intended SOPS/age -> bootstrap delivery -> encrypted systemd credential -> Skillet -> Podman -> container sequence, with explicit decisions at its boundaries. - -Decisions to take after storage: - -1. Is a restricted, temporary plaintext-bearing Ignition artifact acceptable during provisioning, or must plaintext never be persisted on the workstation? This changes the transport/build workflow. -2. Must production credentials require the TPM? How will reinstall, TPM failure and recovery work? VM testing must use a documented test policy or vTPM and must not silently weaken the production policy. -3. Is Podman's protected default file store sufficient with the chosen disk protection, or is encrypted secret storage required there too? Systemd credential encryption alone does not protect a second stored copy. - -Proposed implementation size: - -- Start with the existing named `pihole_web_password` credential; defer a general JSON secret bundle unless multiple credentials justify it. -- Use a dummy value in VM acceptance. Add only one encrypted source entry, one safe transport path, one bootstrap encryption service and one consuming fixture. -- Preserve secret bytes according to an explicit input format; do not trim meaningful whitespace implicitly. -- Encrypt to a temporary output, validate it, then atomically install it. Make cleanup/retry safe across interruption at every stage. Deleting a temporary file is cleanup, not a claim of guaranteed physical erasure. -- Keep the encrypted source recoverable independently of the target TPM. Document the age-key backup/recovery process without storing private keys in the repository. -- Rotation must deliver the replacement credential and recreate its consumers. Repeated application of an unchanged credential must not restart them. +Implement the [KeePassXC and delivery decision](../design/secrets.md): + +- Read exact KDBX entries in workstation Rust code. Use a disposable test database + to verify lookup, unlock failure and recovery on another workstation. +- Migrate the existing Cloudflare token creator from the desktop keyring to + `skillet/cloudflare/token-creator` through KeePassXC; never print its value. +- Start delivery with the existing `pihole_web_password` credential. Production + reads `skillet/hosts/clamps/pihole/web-password`; smoke uses a generated dummy + value, never the production entry. +- Deliver through SSH stdin after base readiness. Encrypt on the guest and + atomically install `/etc/credstore.encrypted/skillet/pihole_web_password.cred`. + Wire the full-apply service's `LoadCredentialEncrypted=` and Podman consumer. +- Preserve secret bytes according to an explicit input format; do not trim + meaningful whitespace implicitly. Validate encrypted output before replacement. +- Use host-key encryption for VM acceptance. Confirm production TPM support, + rebase compatibility, and disk protection for Podman's separate secret copy. +- Rotation must recreate consumers; unchanged credentials must not restart them. + A new workstation or missing vault entry must never regenerate production values. + +Cloudflare per-VM issuance and cleanup follow in the live ACME milestone, using +the [VM lifecycle decision](../design/smoke-vms.md). Exit criteria for 3B: @@ -75,4 +84,7 @@ Exit criteria for 3B: 5. A rotation reaches the running fixture; a subsequent apply causes no further recreation. 6. Missing, corrupt or undecryptable credentials produce a clear failure and follow a documented recovery procedure. They never fall back to an empty/default production password. -Advance to real Pi-hole only after both steps pass. The shared-filesystem/subvolume direction is selected; its remaining details and credential choices remain pending discussion. +The first Pi-hole VM apply follows credential delivery; storage acceptance is +required before production-state migration. Shared Btrfs subvolumes and KeePassXC +are selected; physical disk, mount path, graphroot and production TPM details +still need resolution. diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 34ab77aa..4d43f617 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -1,12 +1,14 @@ # Skillet -Skillet is a Rust-based tool for idempotent host configuration management. It is designed to be highly modular, with core primitives in `skillet_core`, hardening modules in `skillet_hardening`, and host-specific binaries built on top of these. +Skillet is a Rust tool for idempotent host configuration management. + +Design decisions: [secret storage and delivery](../design/secrets.md) and +[disposable VM lifecycle](../design/smoke-vms.md). ## Building -Skillet builds fully static musl binaries by default (see `.cargo/config.toml`), -so each host binary runs on any x86_64 Linux target with no glibc or system -library dependencies — ideal for dropping onto an immutable host. +Skillet builds fully static x86_64 musl binaries by default +(see `.cargo/config.toml`). One-time setup: ```bash @@ -34,6 +36,7 @@ The per-host binaries land at The tool provides an `apply` command to execute configuration. `apply` defaults to the full host and requires its application credentials. Use `--phase base` to converge only the shared host baseline before credentials are available. + - **Agent Mode**: Run generically on a host: ```bash ./target/x86_64-unknown-linux-musl/debug/skillet apply --host clamps --phase base @@ -46,9 +49,8 @@ The legacy CI command `skillet test run beezelbot --image fedora:latest` runs the host apply twice in a uniquely named, temporary Podman container. It builds the selected host binary, checks both applies succeed, and checks the second apply does not start or restart a service. `clamps` is also supported. -This fast sandbox uses mock `systemctl` and `podman` executables, so it checks -apply behavior and repeat convergence but does not establish real systemd or -Podman operation. `--inspect` opens the container before cleanup. +This sandbox uses mock `systemctl` and `podman` executables. +`--inspect` opens the container before cleanup. For real systemd/Podman and reboot coverage, create a disposable VM first: @@ -72,7 +74,7 @@ them with `--target`, `--port`, and `--identity` when using a separately provisioned VM. It takes the generic binary from the running executable and uses the `skillet-clamps` binary installed by VM creation. Repeated smoke runs reset only the namespaced `/var/lib/skillet-smoke` fixture and its managed -files, so you can inspect a run and rerun it on the same disposable VM. +files. The VM remains available for inspection and reruns. When finished, remove the disposable VM, disk, SSH key, and run artifacts: @@ -80,10 +82,9 @@ When finished, remove the disposable VM, disk, SSH key, and run artifacts: cargo run --release -p skillet -- test vm destroy clamps smoke ``` -The VM helpers require the host setup documented in `../butane/README.md`. -The VM fixture controls its own image. Smoke still requires the Skillet -workspace and Cargo so it can build the host binary alongside the running -executable. +The VM helpers require the host setup documented in the +[Butane README](../butane/README.md). Smoke requires the Skillet workspace and +Cargo. The VM fixture controls its own image. Build both static binaries from this directory if you want to install the host binary separately: @@ -99,13 +100,26 @@ Supply that artifact to the guest, then invoke `skillet-clamps apply --phase bas The smoke scenario checks baseline/full separation, container startup and idempotency, configuration and dummy secret rotation, failed startup recovery, interrupted activation, and reboot persistence. It writes state snapshots and -failure output under `/var/lib/skillet-smoke/` in the guest. It resets only its -own fixture files before each run, so repeated runs on the same disposable VM -are supported. The guest needs passwordless sudo for the SSH user and access to +failure output under `/var/lib/skillet-smoke/` in the guest. +The guest needs passwordless sudo for the SSH user and access to pull `docker.io/library/alpine:3.20`. When `--identity` is supplied, SSH stores that VM's host key beside the private key. -## Architectural Mandates +## Secret setup (delivery planned) + +Manage durable secrets in your Syncthing-synced KeePassXC database. Use group +paths and entry titles from the [secret design](../design/secrets.md), storing +each value in its Password field. Direct KDBX reading and host delivery are not +implemented yet. + +For Cloudflare token issuance, select **Create additional tokens** in the +Cloudflare dashboard, with **User > API Tokens > Edit** and no extra +permissions. Save it as `skillet/cloudflare/token-creator`. If already stored +in the desktop keyring, move that value into KeePassXC and verify the saved +entry before removing the old copy. The current smoke command does not use it. + +## Development checks + - **Error Handling**: Use `thiserror` in library crates; `anyhow` is reserved for CLI binaries. No `unwrap()` or `expect()` in library code. - **Idempotency**: All modules must ensure system state idempotently. - **System Interactions**: Prioritize Rust crates (e.g., `zbus`, `users`) over shelling out to system commands. From a4b6c370807718e83a2720ee3d08a25a02539039 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 27 Sep 2026 18:02:54 +0200 Subject: [PATCH 080/252] Provision clamps Pi-hole credentials from XDG vault --- ublue/butane/ACCEPTANCE.md | 29 + ublue/butane/README.md | 7 + ublue/butane/bin/test-vm | 18 +- ublue/butane/includes/skillet.bu | 16 + ublue/design/secrets.md | 23 +- ublue/design/smoke-vms.md | 12 +- ublue/plan/CLAMPS-MIGRATION.md | 13 +- ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md | 8 + ublue/skillet/Cargo.lock | 686 +++++++++++++++++- ublue/skillet/Cargo.toml | 2 + ublue/skillet/README.md | 57 +- ublue/skillet/crates/cli-common/src/hosts.rs | 8 +- ublue/skillet/crates/cli/Cargo.toml | 6 + ublue/skillet/crates/cli/src/main.rs | 79 ++ .../skillet/crates/cli/src/secret_delivery.rs | 277 +++++++ .../crates/cli/src/secret_delivery/tests.rs | 88 +++ ublue/skillet/crates/core/src/credentials.rs | 3 - .../crates/core/src/credentials/tests.rs | 8 +- ublue/skillet/crates/core/src/system.rs | 6 +- ublue/skillet/crates/pihole/src/lib.rs | 16 +- 20 files changed, 1287 insertions(+), 75 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/secret_delivery.rs create mode 100644 ublue/skillet/crates/cli/src/secret_delivery/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index df079f4a..e810ddfa 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -117,3 +117,32 @@ recovery remains unverified. The passing VM is retained for further inspection. The mutable source-tree helper has not been installed as a frozen, persistently approved host tool; it still uses normal command approvals. + +## Pi-hole credential slice, 2026-09-27 + +- Fresh `clamps-test-pihole` VM on port 2202 passed `test vm create clamps + pihole`, including unsigned/signed image rebases and base readiness. Its + ignored `runs/clamps-test-pihole/` directory retains build and VM evidence. +- `test vm provision clamps pihole` delivered a generated dummy password via + SSH stdin. The guest installed mode 0600, root-owned encrypted credential + and loaded it through `skillet-full-apply.service`. A comparison of decrypted + credential bytes with the live container's `/run/secrets/pihole_web_password` + passed without printing the value. +- First full apply exposed a pre-existing `useradd` same-name group bug and a + non-root Pi-hole startup failure. Both were fixed. `test vm update clamps + pihole` deployed the current host binary while retaining the creation + snapshot. Pi-hole then stayed active. +- Repeat provision kept container ID + `de3716fc0a698771f792a1c909cec4f5486bbbe15f5fb74292da5a4b94c05e8c`. + Explicit `--rotate` changed it to + `dc4a8970644a44684b0d00b0f7dbf0d9bd242848b3a3ab2ff7b46bff94c55e87`, + and the mounted secret matched the new credential. A reboot brought Pi-hole + back active with the encrypted file intact. +- `skillet test smoke clamps --port 2202 --identity + ../butane/runs/clamps-test-pihole/ssh/id_ed25519` passed its real-systemd + fixture, including its own reboot. Evidence is under + `/var/lib/skillet-smoke/` on the retained VM. +- Final workspace checks passed: 32 Rust tests, pedantic Clippy with warnings + denied, `shellcheck` and `bash -n` for `bin/test-vm`, and `git diff --check`. +- Post-delivery OS rebase, interruption recovery, production TPM binding, + Cloudflare token migration, and Pi-hole DNS/web behavior were not checked. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 2bbd5f42..d5136c12 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -50,6 +50,7 @@ hyphens. The helper records `clamps` plus `smoke` as the internal domain ./bin/test-vm clamps ssh smoke # interactive guest shell ./bin/test-vm clamps reboot smoke # reboot the guest ./bin/test-vm clamps ready smoke # rerun readiness after a reboot or repair +./bin/test-vm clamps update smoke # install the latest built host binary ./bin/test-vm clamps destroy smoke # delete the VM, disk, test key, and run artifacts ``` @@ -61,6 +62,7 @@ create and ready: ```bash cargo run --release -p skillet -- test vm create clamps smoke cargo run --release -p skillet -- test vm list clamps +cargo run --release -p skillet -- test vm provision clamps smoke cargo run --release -p skillet -- test vm destroy clamps smoke ``` @@ -69,6 +71,11 @@ The helper calls `coreos-install`, which builds the current static to use a specific binary. It records its SHA256 in `runs/NAME/skillet.sha256`. `test-vm ready` transfers the binary over SSH after first boot and installs it at `/var/usrlocal/bin/skillet-clamps`. +`ready` uses that captured binary on every run. After editing Skillet, use +`cargo run --release -p skillet -- test vm update clamps smoke` to rebuild and +install the current binary on a retained VM. `provision` delivers a disposable +Pi-hole password and runs the credential-loaded full apply; `--rotate` replaces +that disposable password. The shared uCore bootstrap script also lives in `/var/usrlocal/bin`. Generated Ignition, SSH key, VM disk and logs stay under `runs/NAME`, which Git ignores. The test key is private and only its public half enters Ignition. diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 1b9c0901..831913ff 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -45,6 +45,7 @@ ensure_runtime() { if [[ "$backend" == native ]]; then ensure_native_runtime else + # shellcheck disable=SC2015 flatpak info org.virt_manager.virt-manager >/dev/null 2>&1 && flatpak info org.virt_manager.virt_manager.Extension.Qemu >/dev/null 2>&1 || { echo 'Flatpak virt-manager or its QEMU extension is unavailable' >&2; exit 1; @@ -52,6 +53,7 @@ ensure_runtime() { [[ "$runtime_dir" == "/run/user/$(id -u)/skvm" && ! -L "$runtime_dir" ]] || { echo 'Invalid Flatpak libvirt runtime path' >&2; exit 1; } + # shellcheck disable=SC2174 mkdir -p -m 0700 "$runtime_dir" if [[ ! -f "$runtime_dir/flatpak.pid" ]] || ! kill -0 "$(cat "$runtime_dir/flatpak.pid")" 2>/dev/null; then setsid "$script_dir/flatpak-virt" sh -c \ @@ -85,7 +87,7 @@ ensure_native_runtime() { fi } usage() { - echo "Usage: $0 [HOST] list | $0 HOST create|ready|status|ssh|logs|reboot|destroy INSTANCE [--port PORT for create]" >&2 + echo "Usage: $0 [HOST] list | $0 HOST create|ready|update|status|ssh|logs|reboot|destroy INSTANCE [--port PORT for create]" >&2 exit 2 } config_value() { sed -n "s/^$1=//p" "$run_dir/run.conf" | head -n 1; } @@ -244,6 +246,20 @@ case "$operation" in (($# == 0)) || usage inspect_owned "$script_dir/test-vm-ready" "$run_dir" ;; + update) + (($# == 0)) || usage + inspect_owned + artifact="$(realpath -e "$script_dir/../../skillet/target/x86_64-unknown-linux-musl/release/skillet-$host")" + [[ -f "$artifact" && ! -L "$artifact" ]] || { echo "Build skillet-$host first" >&2; exit 1; } + ssh_args + scp -O -i "$run_dir/ssh/id_ed25519" -P "$port" -o BatchMode=yes \ + -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$run_dir/ssh/known_hosts" \ + "$artifact" giacomo@127.0.0.1:/var/tmp/skillet-update + # shellcheck disable=SC2029 + ssh "${args[@]}" giacomo@127.0.0.1 \ + "sudo -n install -m 0755 /var/tmp/skillet-update /var/usrlocal/bin/skillet-$host && rm -f /var/tmp/skillet-update" + sha256sum "$artifact" > "$run_dir/deployed-skillet.sha256" + echo "Updated skillet-$host in $name" ;; status) (($# == 0)) || usage inspect_owned diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index a71bbb49..bfb2b268 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -21,5 +21,21 @@ systemd: Type=oneshot ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase base + [Install] + WantedBy=multi-user.target + - name: skillet-full-apply.service + enabled: true + contents: | + [Unit] + Description=Apply Skillet clamps application configuration + Requires=skillet-apply.service + After=skillet-apply.service + ConditionPathExists=/etc/credstore.encrypted/skillet/pihole_web_password.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred + ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase full + [Install] WantedBy=multi-user.target diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 1ed65a56..bd80c6cd 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -1,23 +1,25 @@ # Secret storage and delivery Decision: use KeePassXC for durable secrets and read its KDBX database directly -from workstation Skillet through a Rust library. Delivery is planned; the host -credential reader and Podman secret convergence already exist. +from workstation Skillet through a Rust library. Exact lookup and Pi-hole +delivery are implemented; Cloudflare token issuance remains planned. ## Portable storage KeePassXC is already the user's password manager, and Syncthing already moves the encrypted database between machines. Reusing that database avoids a custom -vault format, export mechanism, or second authoritative store. Its location is -a workstation setting pointing to the existing synced `.kdbx` file. +vault format, export mechanism, or second authoritative store. Skillet defaults +to `$XDG_DATA_HOME/skillet/secrets.kdbx` (or +`$HOME/.local/share/skillet/secrets.kdbx`); a symlink may point at the existing +synced vault. Vault-dependent commands fail if that path is absent, with an +explicit `--database` override. Disposable VM provisioning needs no vault. KeePassXC owns edits and conflict resolution; Skillet reads it in memory. Backups include the encrypted database, with recovery passwords and any key files retained separately. Syncthing replication alone is not versioned backup. -The desktop keyring may remember the database unlock password, accessed through -Rust's Secret Service support. A new workstation can unlock the copied database -without the old workstation's keyring. The existing `cloudflare-token-creator` -keyring item needs a one-time migration into KeePassXC. +The workstation currently prompts for the database password on its terminal. +A new workstation can unlock the copied database without the old one's keyring. +The existing `cloudflare-token-creator` keyring item still needs migration. Vault paths are group paths plus an entry title; values use the Password field. The prefix is consistently singular, `skillet`: @@ -43,7 +45,8 @@ mode `0600` files at `/etc/credstore.encrypted/skillet/.cred`. Keeping delivery after bootstrap avoids plaintext in Ignition and run artifacts. Secret values never enter command arguments or diagnostics. -Use host-key encryption for disposable VMs; production can use TPM2 binding +Use host-key encryption for disposable VMs; current production delivery also +uses host-key encryption. Production can use TPM2 binding after confirming hardware support and rebase behavior. Host-key encryption is recoverable by someone with the guest disk and its key; these files are runtime copies, while KeePassXC supplies portable recovery. @@ -67,7 +70,7 @@ unique test names prevent accidental record collisions, not API access to other records. Keep the literal zone outside this public repository. The creator remains on the workstation. -VM secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, +VM Pi-hole secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, expiry, and owned record IDs in run metadata, without token values. Existing VM credentials survive re-apply and reboot. Expired tokens require explicit renewal; cleanup and recovery belong to the [VM lifecycle](smoke-vms.md). diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 6c6af0d9..68954c03 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -4,7 +4,7 @@ Decision: keep VM creation, readiness, repeatable smoke checks, and destruction separate. A retained VM supports inspection and repeated convergence checks; a fresh VM establishes that bootstrap is reproducible. The existing fixture tests real systemd/Podman convergence. Application and live ACME scenarios, -secret delivery, and external token cleanup are planned. +external token cleanup are planned. Pi-hole credential delivery is implemented. ## Identity and ownership @@ -33,9 +33,13 @@ the test artifacts and uses QEMU user networking. user environment. Persistent bootstrap state tolerates reboots. Executable staging uses `/var/usrlocal/bin`, whose FCOS labeling permits execution. Ignition's authorized-key file remains configured alongside dotfiles keys. -3. **Provision applications (planned):** deliver disposable credentials using - the [shared secret mechanism](secrets.md), then perform full apply. VM - passwords and tokens remain independent of production credentials. + `ready` uses the artifact captured at creation. Explicit `update` builds + and installs current host code on a retained VM, recording the deployed hash + separately while preserving the original creation snapshot. +3. **Provision applications:** `test vm provision` delivers a generated Pi-hole + credential using the [shared secret mechanism](secrets.md), then performs + full apply. Repeated provisioning reuses the installed credential; `--rotate` + replaces it. Live ACME token delivery remains planned. 4. **Smoke:** run assertions against the retained VM. Check repeat apply, configuration/secret changes, interruption recovery, and reboot persistence. The container sandbox uses mocked systemctl/Podman; only VM checks establish diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index b51b7234..08b817ca 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -11,10 +11,11 @@ Provision clamps with uCore + Skillet and migrate the required behavior and pers Implemented: base/full apply separation, activation recovery, fixture smoke checks, temporary VM SSH keys, binary delivery, resolver configuration, signed -bootstrap, and user environment. Pi-hole remains partial. KeePassXC reading, -secret delivery, live ACME, and production storage are not implemented. +bootstrap, user environment, exact KeePassXC reads, and encrypted Pi-hole +credential delivery. A dummy-credential Pi-hole container runs in the VM; +service networking, live ACME, and production storage remain open. -Next: generic secret delivery and Pi-hole, following the +Next: Pi-hole storage and isolated service networking, following the [secret design](../design/secrets.md), [VM lifecycle](../design/smoke-vms.md), and [storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The selected storage direction is boxy's shared Btrfs filesystem with OS/data subvolumes; mount path @@ -46,8 +47,8 @@ Resolve only the decisions needed for Pi-hole first. - Put formatting/partition creation in the installation path. Skillet must not format existing data during convergence. - Make applications depend on their real data mount so a missing disk cannot silently produce an empty replacement data directory on the OS filesystem. - Add directory/subvolume/ownership support as required. Defer container graphroot relocation unless chosen explicitly. -- Implement direct KeePassXC reads and SSH delivery of named systemd credentials according to the secret design. Use generated disposable passwords in VM tests. -- Verify host-key encryption in VMs, atomic delivery, interruption recovery and rotation. Confirm production TPM binding against actual hardware and rebase behavior. +- Extend the implemented direct KeePassXC read and SSH delivery to other named credentials as services need them. Disposable Pi-hole passwords are generated in VM tests. +- Verify interruption recovery after credential delivery and credential survival across an additional OS rebase. Host-key encryption, atomic replacement, rotation, repeat apply and reboot passed in the VM. Confirm production TPM binding against actual hardware. - Account for Podman's on-disk secret copy in the production disk-protection decision. Done when: Pi-hole's storage and password can be provisioned, recovered after interruption, and rotated without leaking values into repository files, command arguments or logs. Reboot/rebase recovery works. The persistent-data mount failure case is exercised. @@ -56,7 +57,7 @@ Done when: Pi-hole's storage and password can be provisioned, recovered after in - Add the dual-stack network resource, explicit container attachment and auto-update configuration. - Decide whether to retain the Chef web pod or publish Pi-hole ports independently. Keep its admin endpoint compatible with the planned Caddy proxy. -- Correct Pi-hole password-file consumption, container identity/permissions, SELinux labels, DNS publication, upstreams, web port and custom-record format for the selected image version. +- Pi-hole password-file consumption and startup identity/permissions passed on the VM. Verify SELinux labels, DNS publication, upstreams, web port and custom-record format for the selected image version. - Replace placeholder DNS records with supplied configuration. Support multiple names per address. - Keep host bootstrap DNS independent of Pi-hole availability. Verify IPv4/IPv6 TCP and UDP DNS, web authentication, persistence, rotation, repeated apply and reboot. - Decide whether Nebula Sync is required and which instance is authoritative before enabling it. diff --git a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md index 994bdf1b..f13b543e 100644 --- a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -4,6 +4,14 @@ Status: remaining work, 2026-09-27. Base provisioning is implemented. Storage mount path and final disk remain open; credential decisions are recorded in [the secret design](../design/secrets.md). +3B progress: exact KDBX lookup, an XDG data-home vault default, encrypted SSH +delivery, a full-apply unit, and Pi-hole secret file wiring are implemented. +Workspace checks, dummy credential delivery, repeated apply, rotation, reboot, +and VM smoke passed; +see [the acceptance record](../butane/ACCEPTANCE.md). Cloudflare creator +migration, TPM assessment, interrupted delivery recovery, and an OS rebase +after delivery remain open. + ## Split into two small steps 3A establishes the application-data mount. 3B delivers a disposable Pi-hole diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index a5e08b41..260aef3f 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2,6 +2,32 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -58,6 +84,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "askama" version = "0.12.1" @@ -84,7 +116,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", ] [[package]] @@ -206,7 +238,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -241,7 +273,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -256,6 +288,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "basic-toml" version = "0.1.10" @@ -271,6 +315,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec", + "constant_time_eq", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -280,6 +334,30 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -293,6 +371,37 @@ dependencies = [ "piper", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -305,6 +414,40 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -336,7 +479,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -345,6 +488,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -360,6 +509,30 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -369,6 +542,24 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -385,14 +576,44 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -419,7 +640,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -465,6 +686,23 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -551,10 +789,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -590,6 +830,21 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "humansize" version = "2.1.3" @@ -599,6 +854,39 @@ dependencies = [ "libm", ] +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -617,6 +905,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -629,6 +927,51 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keepass" +version = "0.13.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8161ba5159f649a4899e46aa084130263a7af722116ead3faec24e5ee92a7b9" +dependencies = [ + "aes", + "base64 0.23.1", + "block-modes", + "byteorder", + "cbc", + "chacha20", + "chrono", + "cipher", + "flate2", + "getrandom 0.4.2", + "hex", + "hex-literal", + "hmac", + "hybrid-array", + "indexmap", + "js-sys", + "quick-xml", + "rust-argon2", + "salsa20", + "secrecy", + "serde", + "sha2 0.11.0", + "thiserror 2.0.21", + "twofish", + "uuid", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -712,6 +1055,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "nix" version = "0.29.0" @@ -834,7 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -855,6 +1208,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.45" @@ -900,6 +1263,39 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "rust-argon2" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae76b7506744d254fd0eb2c0ff5c5d108201ccbb083111ac04a44eeda105680" +dependencies = [ + "base64 0.22.1", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + [[package]] name = "rustix" version = "1.1.4" @@ -913,12 +1309,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -952,7 +1373,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -976,7 +1397,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1001,8 +1422,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -1012,8 +1433,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1025,6 +1457,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1035,13 +1473,23 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "skillet" version = "0.1.0" dependencies = [ "anyhow", "clap", + "hex", + "keepass", + "rpassword", "skillet_cli_common", + "tempfile", "tracing", "tracing-subscriber", ] @@ -1079,7 +1527,7 @@ dependencies = [ "skillet_pihole", "skillet_podman", "tempfile", - "thiserror", + "thiserror 1.0.69", "tracing", "tracing-subscriber", "users", @@ -1093,9 +1541,9 @@ dependencies = [ "hex", "nix", "serde", - "sha2", + "sha2 0.10.9", "tempfile", - "thiserror", + "thiserror 1.0.69", "tracing", "users", "zbus", @@ -1106,10 +1554,10 @@ name = "skillet_hardening" version = "0.1.0" dependencies = [ "hex", - "sha2", + "sha2 0.10.9", "skillet_core", "tempfile", - "thiserror", + "thiserror 1.0.69", "tracing", ] @@ -1120,7 +1568,7 @@ dependencies = [ "askama", "skillet_core", "skillet_podman", - "thiserror", + "thiserror 1.0.69", "tracing", "users", ] @@ -1132,9 +1580,9 @@ dependencies = [ "askama", "hex", "serde", - "sha2", + "sha2 0.10.9", "skillet_core", - "thiserror", + "thiserror 1.0.69", "tracing", "users", ] @@ -1174,6 +1622,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1193,7 +1652,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", ] [[package]] @@ -1204,7 +1672,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -1265,7 +1744,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1303,6 +1782,15 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "twofish" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0218ef702a91e18ba84dd516edf8df33a9f9fb4431d6ada13e6f9502c3bcb6fc" +dependencies = [ + "cipher", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1354,6 +1842,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1390,6 +1890,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1424,12 +1969,65 @@ dependencies = [ "semver", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1560,7 +2158,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1576,7 +2174,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1675,7 +2273,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -1707,9 +2305,35 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" @@ -1738,7 +2362,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -1750,5 +2374,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 125d09e5..0a8a6e9f 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -31,6 +31,8 @@ serde_yml = "0.0.12" hex = "0.4" zbus = { version = "4.4", features = ["blocking"] } askama = "0.12" +keepass = { version = "0.13.25", features = ["save_kdbx4"] } +rpassword = "7.5" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 4d43f617..66e4cfc0 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -75,6 +75,10 @@ provisioned VM. It takes the generic binary from the running executable and uses the `skillet-clamps` binary installed by VM creation. Repeated smoke runs reset only the namespaced `/var/lib/skillet-smoke` fixture and its managed files. The VM remains available for inspection and reruns. +After editing host code, use +`cargo run --release -p skillet -- test vm update clamps smoke` to rebuild and +install the current binary. `ready` reinstalls the artifact captured when the +VM was created. When finished, remove the disposable VM, disk, SSH key, and run artifacts: @@ -105,12 +109,59 @@ The guest needs passwordless sudo for the SSH user and access to pull `docker.io/library/alpine:3.20`. When `--identity` is supplied, SSH stores that VM's host key beside the private key. -## Secret setup (delivery planned) +## Secret setup and delivery Manage durable secrets in your Syncthing-synced KeePassXC database. Use group paths and entry titles from the [secret design](../design/secrets.md), storing -each value in its Password field. Direct KDBX reading and host delivery are not -implemented yet. +each value in its Password field. The workstation prompts for the database +password on its terminal; it does not write decrypted values to a file. +Skillet looks for `$XDG_DATA_HOME/skillet/secrets.kdbx`, falling back to +`$HOME/.local/share/skillet/secrets.kdbx`. Point that location at your +Syncthing-synced database, for example: + +```bash +mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/skillet" +ln -s /path/to/your/synced-vault.kdbx \ + "${XDG_DATA_HOME:-$HOME/.local/share}/skillet/secrets.kdbx" +``` + +The vault-dependent command reports the missing path before asking for its +password. `--database PATH` overrides the default when needed. Disposable VM +provisioning generates its own test value and does not require a vault. + +After creating and preparing a disposable clamps VM, deliver its generated +test password and run the full host apply: + +```bash +cargo run --release -p skillet -- test vm provision clamps smoke +``` + +The password stays encrypted on the VM. Repeat this command after a reboot to +reuse it; use `--rotate` to replace it explicitly. A fresh VM starts with a +new password. The command requires the `skillet-full-apply.service` supplied +by current Butane, so recreate older test VMs made before that unit existed. +If an existing VM credential is corrupt, use `--rotate` to replace only that +disposable value. + +For a production clamps host, create the exact KeePassXC entry +`skillet/hosts/clamps/pihole/web-password`, then deliver it over an SSH +connection whose host key you have already recorded: + +```bash +cargo run --release -p skillet -- secret deliver clamps pihole \ + --target giacomo@clamps --identity /path/to/ssh-key \ + --known-hosts /path/to/known_hosts +``` + +Add `--key-file /path/to/keyfile` if the database uses one. The value is +encrypted on the host and loaded by `skillet-full-apply.service`; later +`systemctl start --wait skillet-full-apply.service` reuses it. If full apply +reports a missing or undecryptable credential, check the host's encrypted +credential file and redeliver the KeePassXC entry. Do not create a new +production password merely because a workstation lacks the vault. Redeliver +the same entry after moving to a new workstation; use a deliberate vault edit +and redelivery for rotation. The production TPM binding decision remains in +the [secret design](../design/secrets.md). For Cloudflare token issuance, select **Create additional tokens** in the Cloudflare dashboard, with **User > API Tokens > Edit** and no extra diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index aff973d3..58746828 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -106,10 +106,10 @@ pub fn apply_clamps( let secrets = vec![QuadletSecret { secret_name: PIHOLE_WEB_PASSWORD_CREDENTIAL.to_string(), target: SecretTarget::File { - target_path: "/etc/pihole/webpassword".to_string(), + target_path: "/run/secrets/pihole_web_password".to_string(), mode: Some("0400".to_string()), - uid: pihole_uid, - gid: pihole_gid, + uid: None, + gid: None, }, }]; @@ -121,7 +121,7 @@ pub fn apply_clamps( skillet_pihole::apply( system, files, - skillet_pihole::PiholeUser { + &skillet_pihole::PiholeUser { uid: pihole_uid, gid: pihole_gid, name: "pihole".to_string(), diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index d42f4783..4bbaca8b 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -12,3 +12,9 @@ clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true anyhow = "1.0" +keepass.workspace = true +rpassword.workspace = true +hex.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 953d4d91..6b8b673c 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -9,6 +9,8 @@ use std::{ use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; +mod secret_delivery; + #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { @@ -20,6 +22,11 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { + /// Deliver an existing `KeePassXC` secret to a provisioned host + Secret { + #[command(subcommand)] + command: SecretCommands, + }, /// Apply host configuration Apply { #[arg(long, value_enum, default_value_t = ApplyPhase::Full)] @@ -39,6 +46,32 @@ enum Commands { }, } +#[derive(clap::Subcommand, Debug)] +enum SecretCommands { + /// Deliver the clamps Pi-hole web password + Deliver(SecretDeliverArgs), +} + +#[derive(clap::Args, Debug)] +struct SecretDeliverArgs { + #[arg(value_parser = ["clamps"])] + hostname: String, + #[arg(value_parser = ["pihole"])] + service: String, + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, + #[arg(long)] + target: String, + #[arg(long, default_value_t = 22)] + port: u16, + #[arg(long)] + identity: PathBuf, + #[arg(long)] + known_hosts: PathBuf, +} + #[derive(clap::Subcommand, Debug)] enum TestCommands { /// Apply configuration twice in a fresh disposable Podman container @@ -60,6 +93,10 @@ enum VmCommands { Destroy(VmDestroyArgs), /// List available host templates and their recorded disposable VMs List(VmListArgs), + /// Deliver a generated dummy Pi-hole password and run full apply + Provision(VmProvisionArgs), + /// Install the current host binary on a retained disposable VM + Update(VmDestroyArgs), } #[derive(clap::Args, Debug)] @@ -76,6 +113,15 @@ struct VmDestroyArgs { instance: String, } +#[derive(clap::Args, Debug)] +struct VmProvisionArgs { + hostname: String, + instance: String, + /// Replace the disposable password and restart its consumer + #[arg(long)] + rotate: bool, +} + #[derive(clap::Args, Debug)] struct VmListArgs { hostname: Option, @@ -118,6 +164,11 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { + Commands::Secret { + command: SecretCommands::Deliver(args), + } => { + secret_delivery::deliver_from_vault(&args)?; + } Commands::Apply { phase, host, @@ -161,6 +212,18 @@ fn main() -> Result<()> { command: VmCommands::List(args), }, } => run_vm_list(&args)?, + Commands::Test { + command: + TestCommands::Vm { + command: VmCommands::Provision(args), + }, + } => secret_delivery::provision_vm(&args)?, + Commands::Test { + command: + TestCommands::Vm { + command: VmCommands::Update(args), + }, + } => run_vm_update(&args)?, } Ok(()) } @@ -243,6 +306,22 @@ fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { run_helper(&helper, &[&args.hostname, "destroy", &args.instance]) } +fn run_vm_update(args: &VmDestroyArgs) -> Result<()> { + vm_name(&args.hostname, &args.instance)?; + let root = workspace_root()?; + let package = format!("skillet-{}", args.hostname); + let status = Command::new("cargo") + .current_dir(&root) + .args(["build", "--release", "-p", &package]) + .status() + .context("building host binary for VM update")?; + if !status.success() { + return Err(anyhow!("building {package} failed")); + } + let helper = butane_root()?.join("bin/test-vm"); + run_helper(&helper, &[&args.hostname, "update", &args.instance]) +} + fn run_vm_list(args: &VmListArgs) -> Result<()> { let helper = butane_root()?.join("bin/test-vm"); if let Some(hostname) = &args.hostname { diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs new file mode 100644 index 00000000..c2a4265f --- /dev/null +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -0,0 +1,277 @@ +use super::{butane_root, vm_name, SecretDeliverArgs, VmProvisionArgs}; +use anyhow::{anyhow, Context, Result}; +use keepass::{Database, DatabaseKey}; +use std::{ + fs, + io::{Read as _, Write as _}, + path::Path, + process::{Command, Stdio}, +}; + +const INSTALL: &str = r#"set -euo pipefail +umask 077 +dir=/etc/credstore.encrypted/skillet +install -d -m 0700 "$dir" +tmp=$(mktemp "$dir/.pihole_web_password.XXXXXX") +trap "rm -f -- $tmp" EXIT +systemd-creds encrypt --with-key=host --name=pihole_web_password - - > "$tmp" +systemd-creds decrypt --name=pihole_web_password "$tmp" - >/dev/null +chmod 0600 "$tmp" +mv -f -- "$tmp" "$dir/pihole_web_password.cred" +systemctl start --wait skillet-full-apply.service"#; + +pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { + let path = format!("skillet/hosts/{}/pihole/web-password", args.hostname); + let database = match &args.database { + Some(path) => path.clone(), + None => default_database_path()?, + }; + let secret = read_vault(&database, args.key_file.as_deref(), &path)?; + if !args.identity.is_file() || !args.known_hosts.is_file() { + return Err(anyhow!( + "SSH identity and recorded known-hosts file must exist" + )); + } + let mut command = Command::new("ssh"); + command + .args([ + "-T", + "-o", + "BatchMode=yes", + "-o", + "IdentitiesOnly=yes", + "-o", + "StrictHostKeyChecking=yes", + "-o", + "ConnectTimeout=10", + "-o", + ]) + .arg(format!("UserKnownHostsFile={}", args.known_hosts.display())) + .arg("-i") + .arg(&args.identity) + .arg("-p") + .arg(args.port.to_string()) + .arg(&args.target); + install(&mut command, &secret) +} + +fn default_database_path() -> Result { + let xdg = std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from); + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + database_path_from(xdg.as_deref(), home.as_deref()) +} + +fn database_path_from( + xdg_data_home: Option<&Path>, + home: Option<&Path>, +) -> Result { + let data_home = if let Some(path) = xdg_data_home.filter(|path| path.is_absolute()) { + path.to_path_buf() + } else { + let home = home + .filter(|path| path.is_absolute()) + .ok_or_else(|| anyhow!("set an absolute XDG_DATA_HOME or HOME, or pass --database"))?; + home.join(".local/share") + }; + Ok(data_home.join("skillet/secrets.kdbx")) +} + +pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { + if args.hostname != "clamps" { + return Err(anyhow!( + "application provisioning currently supports clamps only" + )); + } + let name = vm_name(&args.hostname, &args.instance)?; + let butane = butane_root()?; + let helper = butane.join("bin/test-vm"); + let status = Command::new(&helper) + .args([&args.hostname, "status", &args.instance]) + .stdout(Stdio::null()) + .status() + .context("validating owned VM")?; + if !status.success() { + return Err(anyhow!("VM ownership or status validation failed")); + } + let run_dir = butane.join("runs").join(name); + let identity = run_dir.join("ssh/id_ed25519"); + let known_hosts = run_dir.join("ssh/known_hosts"); + let port = read_vm_port(&run_dir.join("run.conf"))?; + let mut command = Command::new("ssh"); + command + .args([ + "-T", + "-o", + "BatchMode=yes", + "-o", + "IdentitiesOnly=yes", + "-o", + "StrictHostKeyChecking=yes", + "-o", + ]) + .arg(format!("UserKnownHostsFile={}", known_hosts.display())) + .arg("-i") + .arg(&identity) + .arg("-p") + .arg(port.to_string()) + .arg("giacomo@127.0.0.1"); + if !args.rotate { + // An existing encrypted credential is reusable after reboot or on a + // different workstation. Missing or corrupt files fail in full apply. + let check = Command::new("ssh") + .args([ + "-T", + "-o", + "BatchMode=yes", + "-o", + "IdentitiesOnly=yes", + "-o", + "StrictHostKeyChecking=yes", + "-o", + ]) + .arg(format!("UserKnownHostsFile={}", known_hosts.display())) + .arg("-i") + .arg(&identity) + .arg("-p") + .arg(port.to_string()) + .args([ + "giacomo@127.0.0.1", + "sudo -n test -s /etc/credstore.encrypted/skillet/pihole_web_password.cred", + ]) + .status() + .context("checking VM credential")?; + if check.success() { + let apply = Command::new("ssh") + .args([ + "-T", + "-o", + "BatchMode=yes", + "-o", + "IdentitiesOnly=yes", + "-o", + "StrictHostKeyChecking=yes", + "-o", + ]) + .arg(format!("UserKnownHostsFile={}", known_hosts.display())) + .arg("-i") + .arg(&identity) + .arg("-p") + .arg(port.to_string()) + .args([ + "giacomo@127.0.0.1", + "sudo -n systemctl start --wait skillet-full-apply.service", + ]) + .status() + .context("running full apply")?; + if !apply.success() { + return Err(anyhow!("full apply failed")); + } + return Ok(()); + } + } + let mut bytes = [0_u8; 32]; + fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; + let secret = hex::encode(bytes); + install(&mut command, &secret) +} + +fn read_vm_port(manifest: &Path) -> Result { + let contents = fs::read_to_string(manifest)?; + let value = contents + .lines() + .find_map(|line| line.strip_prefix("ssh_port=")) + .ok_or_else(|| anyhow!("VM manifest has no SSH port"))?; + let port: u16 = value.parse().context("invalid VM SSH port")?; + if !(2200..=2299).contains(&port) { + return Err(anyhow!("VM SSH port is outside the test range")); + } + Ok(port) +} + +fn read_vault(path: &Path, key_file: Option<&Path>, entry_path: &str) -> Result { + // Check file availability before prompting for the unlock password. + let mut database_file = fs::File::open(path).with_context(|| format!( + "KeePassXC database is missing or unreadable at {}; place the synced vault there or pass --database", + path.display() + ))?; + let mut key_file = key_file + .map(fs::File::open) + .transpose() + .context("KeePassXC key file is missing or unreadable")?; + let password = rpassword::prompt_password("KeePassXC database password: ") + .context("reading database password from terminal")?; + let mut key = DatabaseKey::new().with_password(&password); + if let Some(file) = key_file.as_mut() { + key = key.with_keyfile(file)?; + } + let database = Database::open(&mut database_file, key) + .context("opening KeePassXC database; check password and key file")?; + lookup(&database, entry_path) +} + +fn lookup(database: &Database, path: &str) -> Result { + let parts: Vec<_> = path.split('/').collect(); + if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { + return Err(anyhow!("invalid KeePassXC entry path")); + } + let mut groups = vec![database.root().id()]; + for part in &parts[..parts.len() - 1] { + groups = groups + .into_iter() + .filter_map(|id| database.group(id)) + .flat_map(|group| group.group_ids().collect::>()) + .filter(|id| database.group(*id).is_some_and(|group| group.name == *part)) + .collect(); + } + let mut matches = groups + .iter() + .filter_map(|id| database.group(*id)) + .flat_map(|group| group.entry_ids().collect::>()) + .filter_map(|id| database.entry(id)) + .filter(|entry| entry.get_title() == parts.last().copied()) + .collect::>(); + if matches.len() != 1 { + return Err(anyhow!("KeePassXC entry {path} must exist exactly once")); + } + let entry = matches.remove(0); + let secret = entry + .get_password() + .ok_or_else(|| anyhow!("KeePassXC entry {path} has no Password field"))?; + if secret.is_empty() { + return Err(anyhow!( + "KeePassXC entry {path} has an empty Password field" + )); + } + Ok(secret.to_owned()) +} + +fn install(command: &mut Command, secret: &str) -> Result<()> { + if secret.is_empty() { + return Err(anyhow!("refusing to deliver an empty credential")); + } + // The script is constant and contains no user input. Secret bytes travel + // only over stdin, never through command arguments or diagnostics. + let remote = format!("sudo -n bash -c '{INSTALL}'"); + let mut child = command + .arg(remote) + .stdin(Stdio::piped()) + .spawn() + .context("opening SSH credential delivery")?; + child + .stdin + .take() + .ok_or_else(|| anyhow!("SSH stdin unavailable"))? + .write_all(secret.as_bytes()) + .context("sending credential to SSH")?; + let status = child.wait().context("waiting for credential delivery")?; + if !status.success() { + return Err(anyhow!( + "credential delivery or full apply failed: {status}" + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "secret_delivery/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs new file mode 100644 index 00000000..027924f4 --- /dev/null +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -0,0 +1,88 @@ +use super::{database_path_from, lookup, read_vault, read_vm_port}; +use keepass::Database; +use keepass::DatabaseKey; + +#[test] +fn exact_vault_path_preserves_whitespace() { + let mut db = Database::new(); + let mut root = db.root_mut(); + let mut skillet = root.add_group(); + skillet.name = "skillet".to_string(); + let mut hosts = skillet.add_group(); + hosts.name = "hosts".to_string(); + let mut clamps = hosts.add_group(); + clamps.name = "clamps".to_string(); + let mut pihole = clamps.add_group(); + pihole.name = "pihole".to_string(); + let mut entry = pihole.add_entry(); + entry.set_unprotected("Title", "web-password"); + entry.set_protected("Password", " value\n"); + assert_eq!( + lookup(&db, "skillet/hosts/clamps/pihole/web-password").unwrap(), + " value\n" + ); + assert!(lookup(&db, "skillet/hosts/clamps/pihole/other").is_err()); + assert!(lookup(&db, "skillet/hosts/other/pihole/web-password").is_err()); +} + +#[test] +fn vm_port_requires_manifest_range() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("run.conf"); + std::fs::write(&file, "ssh_port=2201\n").unwrap(); + assert_eq!(read_vm_port(&file).unwrap(), 2201); + std::fs::write(&file, "ssh_port=22\n").unwrap(); + assert!(read_vm_port(&file).is_err()); +} + +#[test] +fn disposable_database_opens_with_correct_password_only() { + let mut db = Database::new(); + let mut root = db.root_mut(); + let mut skillet = root.add_group(); + skillet.name = "skillet".to_string(); + let mut entry = skillet.add_entry(); + entry.set_unprotected("Title", "fixture"); + entry.set_protected("Password", "dummy only"); + let mut encrypted = Vec::new(); + db.save( + &mut encrypted, + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + assert!(Database::open( + &mut encrypted.as_slice(), + DatabaseKey::new().with_password("wrong") + ) + .is_err()); + let reopened = Database::open( + &mut encrypted.as_slice(), + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + assert_eq!(lookup(&reopened, "skillet/fixture").unwrap(), "dummy only"); +} + +#[test] +fn vault_path_uses_xdg_data_home_with_home_fallback() { + let xdg = std::path::Path::new("/tmp/xdg-data"); + let home = std::path::Path::new("/tmp/user-home"); + assert_eq!( + database_path_from(Some(xdg), Some(home)).unwrap(), + xdg.join("skillet/secrets.kdbx") + ); + assert_eq!( + database_path_from(Some(std::path::Path::new("relative")), Some(home)).unwrap(), + home.join(".local/share/skillet/secrets.kdbx") + ); + assert!(database_path_from(None, None).is_err()); +} + +#[test] +fn missing_vault_fails_before_password_prompt() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.kdbx"); + let error = read_vault(&path, None, "skillet/fixture").unwrap_err(); + assert!(error.to_string().contains("missing or unreadable")); + assert!(error.to_string().contains("secrets.kdbx")); +} diff --git a/ublue/skillet/crates/core/src/credentials.rs b/ublue/skillet/crates/core/src/credentials.rs index e8134610..30bca01b 100644 --- a/ublue/skillet/crates/core/src/credentials.rs +++ b/ublue/skillet/crates/core/src/credentials.rs @@ -42,9 +42,6 @@ impl CredentialManager { let mut content = String::new(); file.read_to_string(&mut content) .map_err(|e| CredentialError::ReadError(name.to_string(), e))?; - // Remove only trailing whitespace, preserving other characters - let new_len = content.trim_end().len(); - content.truncate(new_len); Ok(content) } } diff --git a/ublue/skillet/crates/core/src/credentials/tests.rs b/ublue/skillet/crates/core/src/credentials/tests.rs index 4a01aeba..9d22fb5f 100644 --- a/ublue/skillet/crates/core/src/credentials/tests.rs +++ b/ublue/skillet/crates/core/src/credentials/tests.rs @@ -1,11 +1,11 @@ use super::*; -use std::env; fn manager_with_secret(name: &str, payload: &str) -> (CredentialManager, tempfile::TempDir) { let dir = tempfile::TempDir::new().expect("tempdir"); std::fs::write(dir.path().join(name), payload).expect("write secret"); - env::set_var("CREDENTIALS_DIRECTORY", dir.path()); - let manager = CredentialManager::new().expect("manager"); + let manager = CredentialManager { + base_path: dir.path().to_path_buf(), + }; (manager, dir) } @@ -14,7 +14,7 @@ fn read_secret_happy_path() { let (manager, _dir) = manager_with_secret("pihole_web_password", "s3cret\n"); assert_eq!( manager.read_secret("pihole_web_password").unwrap(), - "s3cret" + "s3cret\n" ); } diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index cc6b315a..200156da 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -258,7 +258,7 @@ impl SystemResource for LinuxSystemResource { if let Some(u) = uid { cmd.arg("-u").arg(u.to_string()); } - if let Some(g) = gid { + if let Some(g) = gid.or_else(|| get_group_by_name(name).map(|group| group.gid())) { cmd.arg("-g").arg(g.to_string()); } cmd.arg(name); @@ -266,7 +266,9 @@ impl SystemResource for LinuxSystemResource { let output = cmd.output()?; if !output.status.success() { - if output.status.code() == Some(EXIT_CODE_USER_EXISTS) { + if output.status.code() == Some(EXIT_CODE_USER_EXISTS) + && get_user_by_name(name).is_some() + { debug!("User {name} was created by another process"); return Ok(false); } diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index c53aaa5b..fe362a79 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -1,9 +1,7 @@ use askama::Template; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; -use skillet_podman::{ - self, ContainerUser, HostUser, PodmanConfig, PodmanError, QuadletSecret, Volume, -}; +use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, QuadletSecret, Volume}; use std::collections::BTreeMap; use std::path::Path; use thiserror::Error; @@ -38,7 +36,7 @@ pub struct PiholeUser { pub fn apply( system: &S, files: &F, - user_config: PiholeUser, + user_config: &PiholeUser, secrets: Vec, custom_records: BTreeMap, ) -> Result<(), PiholeError> @@ -89,9 +87,9 @@ where // 4. Define container let user = ContainerUser { - container_uid: 1000, - container_gid: 1000, - host_user: Some(HostUser::Name(user_config.name)), + container_uid: 0, + container_gid: 0, + host_user: None, }; // SELinux relabeling (:z, shared) so the container can access these @@ -116,6 +114,10 @@ where let mut extra_config = BTreeMap::new(); extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); + extra_config.insert( + "Container".to_string(), + vec!["Environment=WEBPASSWORD_FILE=/run/secrets/pihole_web_password".to_string()], + ); extra_config.insert( "Unit".to_string(), vec![ From c4f3b77bceb0b8f998a58cd6bc1c59284c00fac4 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 27 Sep 2026 19:31:22 +0200 Subject: [PATCH 081/252] Document clamps Btrfs data layout and migration steps --- ublue/design/storage.md | 39 ++++++++++++++++++++++++ ublue/plan/CLAMPS-MIGRATION.md | 16 ++++++---- ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md | 37 +++++++++++++--------- 3 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 ublue/design/storage.md diff --git a/ublue/design/storage.md b/ublue/design/storage.md new file mode 100644 index 00000000..f4502e44 --- /dev/null +++ b/ublue/design/storage.md @@ -0,0 +1,39 @@ +# Clamps persistent storage + +Decision: use the Btrfs filesystem backing uCore's writable `/var` and +`/sysroot`, with a `data` subvolume mounted at `/var/lib/data`. This follows +boxy's shared-space OS/data layout while preserving uCore's deployment layout. +On the disposable clamps VM, `/` is a composefs overlay and `/var` is on the +Btrfs root partition; an `/os` root subvolume must not be copied from boxy. +The physical disk for clamps is still undecided. + +`/var/lib/data` is the host's canonical service-state root. Create child +subvolumes only for independently managed data: initially `pihole` and +`containers`. Pi-hole's container `/etc/pihole` maps to +`/var/lib/data/pihole/etc`; any retained dnsmasq configuration and logs live +under that Pi-hole tree. Rootful Podman's graphroot will be +`/var/lib/data/containers/system`, configured before its first application +container on a fresh install. Keep the working overlay storage driver unless +VM evidence supports a change. Rootless storage is added only for a host that +needs rootless containers. + +XDG base directories describe user-specific files, so they do not improve the +location of system service state. `/srv` suits site data served directly to +clients; Pi-hole's database and Podman's image layers are internal state. +`/var/lib/data` also matches boxy's and calculon's existing convention. + +The data mount and its identity are installation concerns. Skillet may create +and converge directories and subvolumes but must not format a populated disk. +Application startup and Podman storage use must require the mounted data +subvolume and fail when it is absent, preventing writes to a fallback directory. +Btrfs snapshots do not include nested subvolume contents, so backup policy +must list service subvolumes explicitly. The `containers` subvolume can then +be omitted from service-data snapshots. A shared filesystem shares free space +and physical failure; snapshots are not independent backups. + +Implementation and mount-failure acceptance are planned in +[the storage plan](../plan/CLAMPS-STORAGE-CREDENTIALS.md). + +References: [XDG base directories](https://specifications.freedesktop.org/basedir/), +[FHS `/srv`](https://specifications.freedesktop.org/fhs/latest/srv.html), +[Btrfs subvolumes](https://btrfs.readthedocs.io/en/stable/btrfs-subvolume.html). diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 08b817ca..aa00d776 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -15,11 +15,12 @@ bootstrap, user environment, exact KeePassXC reads, and encrypted Pi-hole credential delivery. A dummy-credential Pi-hole container runs in the VM; service networking, live ACME, and production storage remain open. -Next: Pi-hole storage and isolated service networking, following the +Next: implement Pi-hole storage and isolated service networking, following the [secret design](../design/secrets.md), [VM lifecycle](../design/smoke-vms.md), and +[storage design](../design/storage.md) and [storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The selected storage -direction is boxy's shared Btrfs filesystem with OS/data subvolumes; mount path -and final physical disk remain open. +direction is a shared Btrfs filesystem with `/var/lib/data` as the data mount; +the final physical disk remains open. ## Boundaries @@ -43,10 +44,12 @@ and final physical disk remain open. Resolve only the decisions needed for Pi-hole first. -- Decide the test and final `/srv` layout, mount identity, and Pi-hole persistence path. Chef currently uses `/srv/pihole`; Skillet currently uses `/etc/pihole`. +- Implement the decided `/var/lib/data` mount and Pi-hole persistence path. + Chef currently uses `/srv/pihole`; Skillet currently uses `/etc/pihole`. - Put formatting/partition creation in the installation path. Skillet must not format existing data during convergence. - Make applications depend on their real data mount so a missing disk cannot silently produce an empty replacement data directory on the OS filesystem. -- Add directory/subvolume/ownership support as required. Defer container graphroot relocation unless chosen explicitly. +- Add directory/subvolume/ownership support and place system Podman's graphroot + under `/var/lib/data` on fresh installs, as decided in the storage design. - Extend the implemented direct KeePassXC read and SSH delivery to other named credentials as services need them. Disposable Pi-hole passwords are generated in VM tests. - Verify interruption recovery after credential delivery and credential survival across an additional OS rebase. Host-key encryption, atomic replacement, rotation, repeat apply and reboot passed in the VM. Confirm production TPM binding against actual hardware. - Account for Podman's on-disk secret copy in the production disk-protection decision. @@ -95,7 +98,8 @@ Done when: all retained capabilities and data work on clamps, routine convergenc ## Decisions deferred until needed -- Storage: stock-SSD test layout, final disk arrangement, Pi-hole path, container graphroot. +- Storage: stock-SSD test layout, final disk arrangement, mount-failure + acceptance, and graphroot relocation. - Credentials: production TPM/rebase policy and protection of Podman's on-disk copy; portable storage and delivery are decided in the secret design. - Services: actual DNS records, domains/routes, Tailscale enrollment, Nebula Sync, Datadog and legacy networking. - Cutover: address/identity transitions, outage window and rollback duration. diff --git a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md index f13b543e..6c77d146 100644 --- a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -1,8 +1,9 @@ # Milestone 3 discussion: storage and credentials -Status: remaining work, 2026-09-27. Base provisioning is implemented. Storage -mount path and final disk remain open; credential decisions are recorded in -[the secret design](../design/secrets.md). +Status: remaining work, 2026-09-27. Base provisioning is implemented. The +storage mount path and subvolume layout are decided in +[the storage design](../design/storage.md); final physical disk remains open. +Credential decisions are recorded in [the secret design](../design/secrets.md). 3B progress: exact KDBX lookup, an XDG data-home vault default, encrypted SSH delivery, a full-apply unit, and Pi-hole secret file wiring are implemented. @@ -20,7 +21,10 @@ settle storage before migrating production Pi-hole state. ## 3A: application storage -Agreed direction: one Btrfs filesystem sharing space between OS and data, with separate subvolumes, following boxy's approach. The particular data mount path is not important to the user. Final disk selection remains open; the Samsung remains in rupik until cutover. +Decided: one Btrfs filesystem sharing space between OS and data, with a `data` +subvolume mounted at `/var/lib/data` and child `pihole` and `containers` +subvolumes. See [storage design](../design/storage.md). Final disk selection +remains open; the Samsung remains in rupik until cutover. Read-only SSH inspection of boxy confirmed: @@ -39,12 +43,18 @@ User-reported existing fleet differences: calculon mounts a separate Btrfs files After that choice, establish whether the Samsung's existing filesystem/subvolume structure should be preserved or its data restored into a new layout. Use a read-only inventory of disk identifiers, filesystem types, sizes, subvolumes and used space when access is available. Do not infer these from Chef's `/dev/sda3` default. -Proposed defaults for discussion: - -- Keep OS/data subvolumes on the shared filesystem while respecting uCore's deployment layout. Do not assume boxy's ordinary `/os` root mount can be copied literally into an ostree installation; prove the installation/rebase mechanism in the VM. -- Proposed canonical data mount: `/var/lib/data`, matching boxy and calculon. An alternative remains `/srv`; settle the path before implementation. -- Place Pi-hole under `/pihole`, preserving its configuration layout during migration rather than requiring its old absolute host path. -- Proposed system Podman graphroot: `/containers/system`, following boxy. Confirm the storage driver and SELinux labeling on uCore before committing it. Configure before the first application container; never switch the driver of an existing populated store implicitly. Add rootless storage only if clamps actually needs it. +Implementation decisions and checks: + +- Keep uCore's composefs root and Btrfs `/sysroot` and `/var` layout. The + existing VM uses `/dev/vda4` for the writable backing filesystem; prove the + new `data` mount across reboot and rebase. +- Mount the `data` subvolume at `/var/lib/data`. Place Pi-hole under + `/var/lib/data/pihole`, with its container `/etc/pihole` at `pihole/etc`. +- Put system Podman's graphroot at `/var/lib/data/containers/system` on a fresh + installation. The VM currently uses overlay at `/var/lib/containers/storage`; + keep that driver and verify SELinux labeling after relocation. Existing + populated stores require an explicit migration or fresh VM, never an + implicit path or driver switch. - Use stable disk/filesystem identifiers. Ignition handles installation-time formatting only for explicitly selected new/test storage. Skillet converges mounts, directories, permissions and eventual subvolumes without formatting populated disks. - Make the data subvolume mount a prerequisite for directory creation, container storage use and application startup. A failed data mount should leave SSH available where the OS can still boot, with dependent applications stopped and the failure visible. Loss of the shared physical disk also loses the OS; subvolumes do not provide device independence. - Model the selected layout on one disposable VM disk. Test a failed/unavailable data subvolume mount rather than removing the shared OS disk. @@ -92,7 +102,6 @@ Exit criteria for 3B: 5. A rotation reaches the running fixture; a subsequent apply causes no further recreation. 6. Missing, corrupt or undecryptable credentials produce a clear failure and follow a documented recovery procedure. They never fall back to an empty/default production password. -The first Pi-hole VM apply follows credential delivery; storage acceptance is -required before production-state migration. Shared Btrfs subvolumes and KeePassXC -are selected; physical disk, mount path, graphroot and production TPM details -still need resolution. +The first Pi-hole VM apply followed credential delivery. Storage acceptance is +required before production-state migration. Physical disk and production TPM +details still need resolution. From cbe8e3895fed993cde4d350cba20c436764a91a2 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 27 Sep 2026 19:37:52 +0200 Subject: [PATCH 082/252] Record fresh clamps VM smoke acceptance --- ublue/butane/ACCEPTANCE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index e810ddfa..87572882 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -146,3 +146,18 @@ host tool; it still uses normal command approvals. denied, `shellcheck` and `bash -n` for `bin/test-vm`, and `git diff --check`. - Post-delivery OS rebase, interruption recovery, production TPM binding, Cloudflare token migration, and Pi-hole DNS/web behavior were not checked. + +## Fresh default smoke VM, 2026-09-27 + +- The retained `clamps-test-smoke` VM predated `skillet-full-apply.service`: + readiness passed, but `test vm provision clamps smoke` failed because that + unit was absent. The owned disposable domain and run directory were removed. +- `test vm create clamps smoke` then built a fresh VM from current Butane and + passed signed-image readiness and the user-environment checks. The run + artifacts remain in the ignored `runs/clamps-test-smoke/` directory. +- `test vm provision clamps smoke` delivered a generated dummy credential and + completed full apply. `test smoke clamps` passed all real-systemd/Podman + fixture cases, including repeat apply, recovery, secret rotation, and reboot + persistence. Guest evidence remains under `/var/lib/skillet-smoke/`. +- This run used no KeePassXC vault or Cloudflare token. It did not check + storage mounts, Pi-hole DNS/web behavior, or post-delivery OS rebase. From 970f8466682b81fc8311b64c3671226892d3f0b1 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 27 Sep 2026 22:44:32 +0200 Subject: [PATCH 083/252] Share Btrfs data and Podman storage across uCore hosts --- ublue/butane/ACCEPTANCE.md | 27 ++++ ublue/butane/README.md | 4 + ublue/butane/clamps.bu | 1 + ublue/butane/includes/data-storage.bu | 133 +++++++++++++++++++ ublue/butane/includes/skillet.bu | 3 + ublue/design/storage.md | 41 ++++-- ublue/plan/CLAMPS-MIGRATION.md | 29 ++-- ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md | 32 ++++- ublue/skillet/crates/cli-common/src/hosts.rs | 20 ++- ublue/skillet/crates/core/src/files.rs | 98 ++++++++++++++ ublue/skillet/crates/core/src/files/tests.rs | 33 +++++ ublue/skillet/crates/core/src/recorder.rs | 17 +++ ublue/skillet/crates/core/src/resource_op.rs | 3 + ublue/skillet/crates/core/src/test_utils.rs | 13 ++ ublue/skillet/crates/pihole/src/lib.rs | 37 +++--- 15 files changed, 437 insertions(+), 54 deletions(-) create mode 100644 ublue/butane/includes/data-storage.bu diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 87572882..e3073b00 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -161,3 +161,30 @@ host tool; it still uses normal command approvals. persistence. Guest evidence remains under `/var/lib/skillet-smoke/`. - This run used no KeePassXC vault or Cloudflare token. It did not check storage mounts, Pi-hole DNS/web behavior, or post-delivery OS rebase. + +## Shared Btrfs data storage, 2026-09-27 + +- Fresh `clamps-test-storage` VM on port 2203 booted from Butane with the + shared `data-storage.bu` include. After the signed uCore bootstrap, + `/var/lib/data` mounted as Btrfs `/data` on the same filesystem as `/var`. + `skillet-data-prepare.service` created the `containers` subvolume and + restored the rootful graphroot SELinux label. Podman reported graphroot + `/var/lib/data/containers/system`, runroot `/run/containers/storage`, and + driver `overlay`. +- Dummy-credential provisioning created the `pihole` child subvolume and + started Pi-hole with bind paths under `/var/lib/data/pihole/{etc,dnsmasq.d,log}`. + Repeated full apply retained the subvolume and running container. After + Pi-hole changed ownership of its own files, two further applies made no + metadata changes or container replacement. +- A sentinel in the Pi-hole data tree survived VM reboot. Temporarily masking + `var-lib-data.mount` caused full apply to fail without creating fallback + Pi-hole or container paths. A wrong tmpfs mount was rejected by both the + shared preparation script and Skillet before either wrote to it. Restoring + the real mount and running normal convergence recovered Pi-hole and retained + the sentinel. The temporary mount override was removed. +- The real Podman/systemd `test smoke clamps` scenario passed against this VM, + including its reboot and recovery cases, after deployment of the final + shared full-apply guard. Workspace tests, pedantic Clippy, ShellCheck for + both storage scripts, and `git diff --check` passed. These checks did not include a + post-provision OS rebase or the physical stock SSD. Other host templates do + not yet merge the shared include because they do not yet exist. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index d5136c12..ef01459d 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -13,6 +13,10 @@ physical-disk install file. The host needs Podman, `yq`, `rg`, `ssh-keygen`, and KVM. Native libvirt additionally needs `passt`; the Flatpak backend uses QEMU user networking. VM commands can come from native libvirt and `virt-install`, or an existing Flatpak virt-manager with its QEMU extension. +The shared [data storage design](../design/storage.md) is implemented by +`includes/data-storage.bu`. On a fresh VM it mounts the Btrfs `data` subvolume +at `/var/lib/data` and sets rootful Podman's graphroot there. Recreate a +disposable VM made before this include to check the install-time layout. On Bazzite, the helper uses the existing Flatpak virt-manager installation when native libvirt is absent. The `bin/virsh` and `bin/virt-install` wrappers diff --git a/ublue/butane/clamps.bu b/ublue/butane/clamps.bu index 78c1ee49..ffed8692 100644 --- a/ublue/butane/clamps.bu +++ b/ublue/butane/clamps.bu @@ -8,6 +8,7 @@ ignition: - local: includes/passwd.bu - local: includes/zram.bu - local: includes/resolver.bu + - local: includes/data-storage.bu - local: includes/brew.bu - local: includes/skillet.bu - local: includes/ucore-bootstrap.bu diff --git a/ublue/butane/includes/data-storage.bu b/ublue/butane/includes/data-storage.bu new file mode 100644 index 00000000..70afd23f --- /dev/null +++ b/ublue/butane/includes/data-storage.bu @@ -0,0 +1,133 @@ +variant: fcos +version: 1.6.0 +storage: + directories: + - path: /var/lib/data + mode: 0755 + files: + - path: /etc/containers/storage.conf + mode: 0644 + contents: + inline: | + [storage] + driver = "overlay" + runroot = "/run/containers/storage" + graphroot = "/var/lib/data/containers/system" + - path: /var/usrlocal/bin/skillet-data-top + mode: 0755 + contents: + inline: | + #!/usr/bin/env bash + set -euo pipefail + [[ "$(findmnt -T /var -n -o FSTYPE)" == btrfs ]] || { + echo '/var must be backed by Btrfs' >&2 + exit 1 + } + var_uuid="$(findmnt -T /var -n -o UUID)" + root_device=/dev/disk/by-partlabel/root + [[ -n "$var_uuid" && "$(blkid -s UUID -o value "$root_device")" == "$var_uuid" ]] || { + echo 'The root partition does not match the Btrfs filesystem backing /var' >&2 + exit 1 + } + top="$(mktemp -d /run/skillet-data-top.XXXXXX)" + cleanup() { + mountpoint -q "$top" && umount "$top" + rmdir "$top" + } + trap cleanup EXIT + mount -t btrfs -o subvolid=5 "$root_device" "$top" + if btrfs subvolume show "$top/data" >/dev/null 2>&1; then + exit 0 + fi + [[ ! -e "$top/data" ]] || { + echo 'Top-level data path exists but is not a Btrfs subvolume' >&2 + exit 1 + } + btrfs subvolume create "$top/data" + - path: /var/usrlocal/bin/skillet-data-prepare + mode: 0755 + contents: + inline: | + #!/usr/bin/env bash + set -euo pipefail + mountpoint -q /var/lib/data || { + echo '/var/lib/data is not mounted' >&2 + exit 1 + } + [[ "$(findmnt -M /var/lib/data -n -o FSTYPE)" == btrfs && + "$(findmnt -M /var/lib/data -n -o FSROOT)" == /data && + "$(findmnt -M /var/lib/data -n -o UUID)" == "$(findmnt -M /var -n -o UUID)" ]] || { + echo '/var/lib/data is not the data subvolume of the /var Btrfs filesystem' >&2 + exit 1 + } + if ! btrfs subvolume show /var/lib/data/containers >/dev/null 2>&1; then + [[ ! -e /var/lib/data/containers ]] || { + echo 'Container path exists but is not a Btrfs subvolume' >&2 + exit 1 + } + btrfs subvolume create /var/lib/data/containers + fi + mkdir -p /var/lib/data/containers/system + if ! semanage fcontext -l -C | grep -Fq '/var/lib/data/containers/system'; then + semanage fcontext -a -e /var/lib/containers/storage /var/lib/data/containers/system + restorecon -R /var/lib/data/containers/system + fi + restorecon /var/lib/data/containers/system +systemd: + units: + - name: skillet-data-top.service + contents: | + [Unit] + Description=Create the shared Btrfs data subvolume + DefaultDependencies=no + Requires=var.mount + After=var.mount + Before=var-lib-data.mount + + [Service] + Type=oneshot + ExecStart=/var/usrlocal/bin/skillet-data-top + RemainAfterExit=yes + - name: var-lib-data.mount + enabled: true + contents: | + [Unit] + Description=Mount the shared Btrfs service data subvolume + Requires=skillet-data-top.service + After=skillet-data-top.service + + [Mount] + What=/dev/disk/by-partlabel/root + Where=/var/lib/data + Type=btrfs + Options=subvol=data,noatime,compress=zstd:3 + + [Install] + WantedBy=multi-user.target + - name: skillet-data-prepare.service + enabled: true + contents: | + [Unit] + Description=Prepare shared Podman storage on the data mount + Requires=var-lib-data.mount + After=var-lib-data.mount + Requires=ucore-bootstrap.service + After=ucore-bootstrap.service + ConditionPathExists=/run/ucore-bootstrap-ready + AssertPathIsMountPoint=/var/lib/data + + [Service] + Type=oneshot + ExecStart=/var/usrlocal/bin/skillet-data-prepare + RemainAfterExit=yes + + [Install] + WantedBy=multi-user.target + - name: podman-auto-update.service + dropins: + - name: 10-skillet-data.conf + contents: | + [Unit] + Requires=skillet-data-prepare.service + After=skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index bfb2b268..ce06d385 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -30,6 +30,9 @@ systemd: Description=Apply Skillet clamps application configuration Requires=skillet-apply.service After=skillet-apply.service + Requires=skillet-data-prepare.service + After=skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data ConditionPathExists=/etc/credstore.encrypted/skillet/pihole_web_password.cred [Service] diff --git a/ublue/design/storage.md b/ublue/design/storage.md index f4502e44..b5e7da4d 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -1,37 +1,50 @@ -# Clamps persistent storage +# Shared uCore persistent storage Decision: use the Btrfs filesystem backing uCore's writable `/var` and `/sysroot`, with a `data` subvolume mounted at `/var/lib/data`. This follows boxy's shared-space OS/data layout while preserving uCore's deployment layout. -On the disposable clamps VM, `/` is a composefs overlay and `/var` is on the -Btrfs root partition; an `/os` root subvolume must not be copied from boxy. -The physical disk for clamps is still undecided. +This layout is shared by all uCore hosts. On the disposable clamps VM, `/` is a +composefs overlay and `/var` is on the Btrfs root partition; an `/os` root +subvolume must not be copied from boxy. The physical disk for clamps is still +undecided. `/var/lib/data` is the host's canonical service-state root. Create child subvolumes only for independently managed data: initially `pihole` and `containers`. Pi-hole's container `/etc/pihole` maps to `/var/lib/data/pihole/etc`; any retained dnsmasq configuration and logs live -under that Pi-hole tree. Rootful Podman's graphroot will be -`/var/lib/data/containers/system`, configured before its first application -container on a fresh install. Keep the working overlay storage driver unless -VM evidence supports a change. Rootless storage is added only for a host that -needs rootless containers. +under that Pi-hole tree. Rootful Podman's graphroot is +`/var/lib/data/containers/system` on fresh installs, with runroot under +`/run/containers/storage`. Chef set `driver=btrfs` for rupik and boxy; uCore +currently uses overlay, so the shared config keeps overlay while moving the +graphroot. Its SELinux file-context equivalence matches the former graphroot. +Rootless storage is added only for a host that needs rootless containers. XDG base directories describe user-specific files, so they do not improve the location of system service state. `/srv` suits site data served directly to clients; Pi-hole's database and Podman's image layers are internal state. `/var/lib/data` also matches boxy's and calculon's existing convention. -The data mount and its identity are installation concerns. Skillet may create -and converge directories and subvolumes but must not format a populated disk. -Application startup and Podman storage use must require the mounted data -subvolume and fail when it is absent, preventing writes to a fallback directory. +The shared `butane/includes/data-storage.bu` include creates the top-level +`data` subvolume on the Btrfs filesystem backing `/var`, mounts it, prepares +the `containers` subvolume, and configures rootful Podman. It checks that the +standard `root` partition +label points to that same filesystem. Each host's Butane template must merge +the include; clamps currently does. Host Skillet creates application +subvolumes and directories. Existing populated Podman stores require an +explicit migration; changing an installed host's graphroot is not automatic. +Skillet must not format a populated disk. +Skillet, managed application services, and Podman auto-update require the +mounted data subvolume and fail when it is absent, preventing their writes to +a fallback directory. A manually invoked Podman command does not have this +unit dependency and must only run after validating the mount. Btrfs snapshots do not include nested subvolume contents, so backup policy must list service subvolumes explicitly. The `containers` subvolume can then be omitted from service-data snapshots. A shared filesystem shares free space and physical failure; snapshots are not independent backups. -Implementation and mount-failure acceptance are planned in +The clamps VM passed fresh provisioning, mount, SELinux, reboot, repeat apply, +and unavailable/wrong-mount recovery. A post-provision OS rebase and the +physical disk choice remain open in [the storage plan](../plan/CLAMPS-STORAGE-CREDENTIALS.md). References: [XDG base directories](https://specifications.freedesktop.org/basedir/), diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index aa00d776..6349fa81 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -12,14 +12,16 @@ Provision clamps with uCore + Skillet and migrate the required behavior and pers Implemented: base/full apply separation, activation recovery, fixture smoke checks, temporary VM SSH keys, binary delivery, resolver configuration, signed bootstrap, user environment, exact KeePassXC reads, and encrypted Pi-hole -credential delivery. A dummy-credential Pi-hole container runs in the VM; -service networking, live ACME, and production storage remain open. +credential delivery, and the shared Btrfs data layout. A dummy-credential +Pi-hole container runs in the VM with data and Podman storage under +`/var/lib/data`; service networking, live ACME, final disk selection, and +post-provision rebase checks remain open. -Next: implement Pi-hole storage and isolated service networking, following the +Next: implement isolated Pi-hole service networking, following the [secret design](../design/secrets.md), [VM lifecycle](../design/smoke-vms.md), and [storage design](../design/storage.md) and -[storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The selected storage -direction is a shared Btrfs filesystem with `/var/lib/data` as the data mount; +[storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The implemented VM +layout uses a shared Btrfs filesystem with `/var/lib/data` as the data mount; the final physical disk remains open. ## Boundaries @@ -44,12 +46,14 @@ the final physical disk remains open. Resolve only the decisions needed for Pi-hole first. -- Implement the decided `/var/lib/data` mount and Pi-hole persistence path. - Chef currently uses `/srv/pihole`; Skillet currently uses `/etc/pihole`. +- The shared `/var/lib/data` mount and Pi-hole persistence path are implemented + and accepted on a disposable VM. Chef used `/srv/pihole` on rupik. - Put formatting/partition creation in the installation path. Skillet must not format existing data during convergence. -- Make applications depend on their real data mount so a missing disk cannot silently produce an empty replacement data directory on the OS filesystem. -- Add directory/subvolume/ownership support and place system Podman's graphroot - under `/var/lib/data` on fresh installs, as decided in the storage design. +- Skillet and the managed Pi-hole service now depend on the real data mount; + the missing and wrong-mount VM checks passed. +- Directory/subvolume/mount checks and system Podman's graphroot under + `/var/lib/data` passed fresh-VM acceptance. Merge the shared Butane storage + include when adding each future host template. - Extend the implemented direct KeePassXC read and SSH delivery to other named credentials as services need them. Disposable Pi-hole passwords are generated in VM tests. - Verify interruption recovery after credential delivery and credential survival across an additional OS rebase. Host-key encryption, atomic replacement, rotation, repeat apply and reboot passed in the VM. Confirm production TPM binding against actual hardware. - Account for Podman's on-disk secret copy in the production disk-protection decision. @@ -98,8 +102,9 @@ Done when: all retained capabilities and data work on clamps, routine convergenc ## Decisions deferred until needed -- Storage: stock-SSD test layout, final disk arrangement, mount-failure - acceptance, and graphroot relocation. +- Storage: stock-SSD test layout, final disk arrangement, and post-provision + OS rebase acceptance. The disposable VM mount-failure and graphroot checks + passed. - Credentials: production TPM/rebase policy and protection of Podman's on-disk copy; portable storage and delivery are decided in the secret design. - Services: actual DNS records, domains/routes, Tailscale enrollment, Nebula Sync, Datadog and legacy networking. - Cutover: address/identity transitions, outage window and rollback duration. diff --git a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md index 6c77d146..1def10c4 100644 --- a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -4,6 +4,11 @@ Status: remaining work, 2026-09-27. Base provisioning is implemented. The storage mount path and subvolume layout are decided in [the storage design](../design/storage.md); final physical disk remains open. Credential decisions are recorded in [the secret design](../design/secrets.md). +Shared Butane storage setup, rootful Podman graphroot configuration, Skillet +mount guards and Pi-hole path changes passed fresh-VM, reboot, repeat-apply, +and mount-failure checks; see [the acceptance record](../butane/ACCEPTANCE.md). +The include is host independent and must be merged by each future host's +Butane template. 3B progress: exact KDBX lookup, an XDG data-home vault default, encrypted SSH delivery, a full-apply unit, and Pi-hole secret file wiring are implemented. @@ -50,13 +55,20 @@ Implementation decisions and checks: new `data` mount across reboot and rebase. - Mount the `data` subvolume at `/var/lib/data`. Place Pi-hole under `/var/lib/data/pihole`, with its container `/etc/pihole` at `pihole/etc`. -- Put system Podman's graphroot at `/var/lib/data/containers/system` on a fresh - installation. The VM currently uses overlay at `/var/lib/containers/storage`; - keep that driver and verify SELinux labeling after relocation. Existing - populated stores require an explicit migration or fresh VM, never an - implicit path or driver switch. -- Use stable disk/filesystem identifiers. Ignition handles installation-time formatting only for explicitly selected new/test storage. Skillet converges mounts, directories, permissions and eventual subvolumes without formatting populated disks. -- Make the data subvolume mount a prerequisite for directory creation, container storage use and application startup. A failed data mount should leave SSH available where the OS can still boot, with dependent applications stopped and the failure visible. Loss of the shared physical disk also loses the OS; subvolumes do not provide device independence. +- The shared Butane include sets system Podman's graphroot to + `/var/lib/data/containers/system` on a fresh installation and keeps overlay. + Verify SELinux labeling and the effective graphroot on a fresh VM. Existing + populated stores require an explicit migration or fresh VM. +- Use stable disk/filesystem identifiers. Ignition handles installation-time + formatting only for selected new/test storage; the shared Butane units create + and mount `data`. Skillet verifies the mount and converges application + directories, permissions and subvolumes without formatting populated disks. +- Make the data subvolume mount a prerequisite for Skillet, managed container + services and auto-update. A failed data mount should leave SSH available + where the OS can still boot, with dependent applications stopped and the + failure visible. Manually invoked Podman is outside these unit dependencies. + Loss of the shared physical disk also loses the OS; subvolumes do not provide + device independence. - Model the selected layout on one disposable VM disk. Test a failed/unavailable data subvolume mount rather than removing the shared OS disk. Exit criteria for 3A: @@ -67,6 +79,12 @@ Exit criteria for 3A: 4. Restoring the correct subvolume mount permits recovery through normal convergence without formatting or data loss. 5. Repeated apply changes neither mount configuration nor directory metadata unnecessarily. +Fresh-VM graphroot, Pi-hole bind paths, SELinux label, reboot, repeated apply, +unavailable mount, wrong mount and recovery passed on `clamps-test-storage`. +The sentinel survived reboot and both mount-failure recoveries. Criterion 2 +remains partial until an OS rebase preserves the sentinel. Final disk selection +and a stock-SSD install are also open. + Snapshots, Syncthing data transfer and final disk cutover are later milestones. Do not introduce all service subvolumes before their requirements are known. ## 3B: one credential end to end diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 58746828..fc68f5bb 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -57,7 +57,7 @@ pub fn apply_beezelbot( system: &dyn SystemResource, files: &dyn FileResource, ) -> Result<(), ApplyError> { - skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) + apply_full_base(system, files) } /// Shared host baseline used by both CLI entry points. @@ -65,6 +65,21 @@ pub fn apply_base(system: &dyn SystemResource, files: &dyn FileResource) -> Resu skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) } +/// Shared full-apply baseline. Base apply runs before the signed image is +/// ready, while full apply requires the persistent data mount. +pub fn apply_full_base( + system: &dyn SystemResource, + files: &dyn FileResource, +) -> Result<(), ApplyError> { + apply_base(system, files)?; + files.require_btrfs_subvolume_mount( + std::path::Path::new("/var/lib/data"), + std::path::Path::new("/var"), + "/data", + )?; + Ok(()) +} + /// Name of the systemd credential (and podman secret) holding the Pi-hole /// web UI password. Full `clamps` apply reads it from the systemd credential /// directory supplied to that invocation. @@ -85,7 +100,8 @@ pub fn apply_clamps( system: &dyn SystemResource, files: &dyn FileResource, ) -> Result<(), ApplyError> { - apply_base(system, files)?; + // Check before Podman can create a graphroot on the fallback /var tree. + apply_full_base(system, files)?; // 1. Ingest secret from systemd (lazy: only this host needs secrets) let credentials = CredentialManager::new()?; diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index ab1d818f..9727d4c4 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -4,6 +4,7 @@ use std::fs::{self, File}; use std::io::{self, BufReader, Write}; use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; use std::path::Path; +use std::process::Command; use tempfile::NamedTempFile; use thiserror::Error; use tracing::info; @@ -35,6 +36,14 @@ pub enum FileError { NotAFile(String), #[error("Metadata mismatch for {0}")] Metadata(String), + #[error("Required data mount is absent: {0}")] + MountMissing(String), + #[error("Unexpected filesystem or subvolume at {0}")] + WrongMount(String), + #[error("Path {0} exists but is not a Btrfs subvolume")] + NotASubvolume(String), + #[error("Btrfs operation failed for {0}: {1}")] + Btrfs(String, String), } pub trait FileResource { @@ -55,6 +64,13 @@ pub trait FileResource { group: Option<&str>, ) -> Result; fn delete_file(&self, path: &Path) -> Result; + fn require_btrfs_subvolume_mount( + &self, + path: &Path, + backing_mount: &Path, + subvolume_root: &str, + ) -> Result<(), FileError>; + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result; } pub struct LocalFileResource; @@ -185,6 +201,49 @@ impl Default for LocalFileResource { } impl FileResource for LocalFileResource { + fn require_btrfs_subvolume_mount( + &self, + path: &Path, + backing_mount: &Path, + subvolume_root: &str, + ) -> Result<(), FileError> { + let mountinfo = fs::read_to_string("/proc/self/mountinfo")?; + require_btrfs_mount_in(&mountinfo, path, backing_mount, subvolume_root) + } + + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { + let path_text = path.display().to_string(); + if Command::new("btrfs") + .args(["subvolume", "show"]) + .arg(path) + .output()? + .status + .success() + { + return Ok(false); + } + if path.exists() { + return Err(FileError::NotASubvolume(path_text)); + } + let parent = path + .parent() + .ok_or_else(|| FileError::InvalidPath(path_text.clone()))?; + if !parent.is_dir() { + return Err(FileError::ParentMissing(path_text)); + } + let output = Command::new("btrfs") + .args(["subvolume", "create"]) + .arg(path) + .output()?; + if !output.status.success() { + return Err(FileError::Btrfs( + path_text, + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + Ok(true) + } + fn read_file(&self, path: &Path) -> Result>, FileError> { match fs::read(path) { Ok(bytes) => Ok(Some(bytes)), @@ -328,6 +387,45 @@ impl FileResource for LocalFileResource { } } +fn mount_entry<'a>(mountinfo: &'a str, path: &str) -> Option<(&'a str, &'a str, &'a str)> { + mountinfo.lines().find_map(|line| { + let (left, right) = line.split_once(" - ")?; + let mut fields = left.split_whitespace(); + let device = fields.nth(2)?; + let root = fields.next()?; + let mountpoint = fields.next()?; + let filesystem = right.split_whitespace().next()?; + (mountpoint == path).then_some((device, root, filesystem)) + }) +} + +fn require_btrfs_mount_in( + mountinfo: &str, + path: &Path, + backing_mount: &Path, + subvolume_root: &str, +) -> Result<(), FileError> { + let path_text = path + .to_str() + .ok_or_else(|| FileError::InvalidPath(path.display().to_string()))?; + let backing_text = backing_mount + .to_str() + .ok_or_else(|| FileError::InvalidPath(backing_mount.display().to_string()))?; + let (device, root, filesystem) = mount_entry(mountinfo, path_text) + .ok_or_else(|| FileError::MountMissing(path_text.to_string()))?; + let backing = mount_entry(mountinfo, backing_text); + if filesystem == "btrfs" + && root == subvolume_root + && backing.is_some_and(|(backing_device, _, backing_fs)| { + backing_device == device && backing_fs == "btrfs" + }) + { + Ok(()) + } else { + Err(FileError::WrongMount(path_text.to_string())) + } +} + #[cfg(test)] #[path = "files/tests.rs"] mod tests; diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs index c267ad7e..d9821872 100644 --- a/ublue/skillet/crates/core/src/files/tests.rs +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -3,6 +3,39 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; +#[test] +fn data_mount_requires_the_expected_btrfs_subvolume() { + let mountinfo = "1 0 0:31 /ostree/deploy/fedora-coreos/var /var rw - btrfs /dev/vda4 rw\n\ + 2 1 0:31 /data /var/lib/data rw - btrfs /dev/vda4 rw\n"; + let data = Path::new("/var/lib/data"); + let backing = Path::new("/var"); + + assert!(require_btrfs_mount_in(mountinfo, data, backing, "/data").is_ok()); + assert!(matches!( + require_btrfs_mount_in(mountinfo, data, backing, "/other"), + Err(FileError::WrongMount(_)) + )); + assert!(matches!( + require_btrfs_mount_in(mountinfo, Path::new("/missing"), backing, "/data"), + Err(FileError::MountMissing(_)) + )); +} + +#[test] +fn data_mount_rejects_an_unrelated_filesystem() { + let mountinfo = "1 0 0:31 /ostree/deploy/fedora-coreos/var /var rw - btrfs /dev/vda4 rw\n\ + 2 1 0:44 /data /var/lib/data rw - btrfs /dev/vdb1 rw\n"; + assert!(matches!( + require_btrfs_mount_in( + mountinfo, + Path::new("/var/lib/data"), + Path::new("/var"), + "/data" + ), + Err(FileError::WrongMount(_)) + )); +} + #[test] fn test_ensure_file_creates_file() { let dir = tempdir().unwrap(); diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index c2a50867..b32f3984 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -42,6 +42,23 @@ impl Recorder { } impl FileResource for Recorder { + fn require_btrfs_subvolume_mount( + &self, + path: &Path, + backing_mount: &Path, + subvolume_root: &str, + ) -> Result<(), FileError> { + self.inner + .require_btrfs_subvolume_mount(path, backing_mount, subvolume_root) + } + + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { + self.record(ResourceOp::EnsureBtrfsSubvolume { + path: path.display().to_string(), + }); + self.inner.ensure_btrfs_subvolume(path) + } + fn read_file(&self, path: &Path) -> Result>, FileError> { self.inner.read_file(path) } diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index d4666e6f..c995fa74 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -18,6 +18,9 @@ pub enum ResourceOp { owner: Option, group: Option, }, + EnsureBtrfsSubvolume { + path: String, + }, EnsureGroup { name: String, }, diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 5cb57db1..152d8124 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -192,6 +192,19 @@ impl Default for MockFiles { } impl FileResource for MockFiles { + fn require_btrfs_subvolume_mount( + &self, + _path: &Path, + _backing_mount: &Path, + _subvolume_root: &str, + ) -> Result<(), FileError> { + Ok(()) + } + + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { + self.ensure_directory(path, None, None, None) + } + fn read_file(&self, path: &Path) -> Result>, FileError> { Ok(self .files diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index fe362a79..33837688 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -45,8 +45,12 @@ where F: FileResource + ?Sized, { info!("Applying pihole configuration..."); - let root = "/etc/pihole"; - let logs = "/var/log/pihole"; + let root = "/var/lib/data/pihole"; + let etc = "/var/lib/data/pihole/etc"; + let logs = "/var/lib/data/pihole/log"; + + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + files.ensure_btrfs_subvolume(Path::new(root))?; // 1. Ensure user and group system.ensure_group(&user_config.group_name, user_config.gid)?; @@ -54,19 +58,9 @@ where // 2. Ensure directories files.ensure_directory(Path::new(root), Some(0o755), Some("root"), Some("root"))?; - files.ensure_directory( - &Path::new(root).join("conf"), - Some(0o755), - Some("root"), - Some("root"), - )?; - files.ensure_directory( - &Path::new(root).join("dnsmasq.d"), - Some(0o755), - Some("root"), - Some("root"), - )?; - files.ensure_directory(Path::new(logs), Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory(Path::new(etc), Some(0o755), None, None)?; + files.ensure_directory(&Path::new(root).join("dnsmasq.d"), Some(0o755), None, None)?; + files.ensure_directory(Path::new(logs), Some(0o755), None, None)?; // 3. Custom list template (records supplied by the host) let template = CustomListTemplate { @@ -78,11 +72,11 @@ where ))) })?; files.ensure_file( - &Path::new(root).join("conf/custom.list"), + &Path::new(etc).join("custom.list"), custom_list.as_bytes(), Some(0o640), - Some("root"), - Some("root"), + None, + None, )?; // 4. Define container @@ -96,7 +90,7 @@ where // host paths on enforcing systems such as uCore. let volumes = vec![ Volume { - host_path: format!("{root}/conf"), + host_path: etc.to_string(), container_path: "/etc/pihole".to_string(), options: Some("z".to_string()), }, @@ -123,6 +117,11 @@ where vec![ "Description=Pi. Hole".to_string(), "After=network-online.target".to_string(), + "Requires=skillet-data-prepare.service".to_string(), + "After=skillet-data-prepare.service".to_string(), + "BindsTo=var-lib-data.mount".to_string(), + "After=var-lib-data.mount".to_string(), + "AssertPathIsMountPoint=/var/lib/data".to_string(), ], ); extra_config.insert( From c7a356c3d7a3682024dd55127d588ae0ac5ebbb0 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 28 Sep 2026 12:04:07 +0200 Subject: [PATCH 084/252] fix(butane): support native libvirt on Rocky Linux --- ublue/butane/README.md | 18 ++++++-- ublue/butane/bin/coreos-install | 80 ++++++++++++++++++++++++++++----- ublue/butane/bin/test-vm | 35 +++++++++------ 3 files changed, 104 insertions(+), 29 deletions(-) diff --git a/ublue/butane/README.md b/ublue/butane/README.md index ef01459d..95dc8753 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -10,9 +10,10 @@ Design decisions: [disposable VM lifecycle](../design/smoke-vms.md) and `clamps.bu` is a VM configuration. It resizes and reformats partition 4 on `/dev/vda`, the virtio disk attached by this launcher. Do not use it as a physical-disk install file. The host needs Podman, `yq`, `rg`, `ssh-keygen`, -and KVM. Native libvirt additionally needs `passt`; the Flatpak backend uses -QEMU user networking. VM commands can come from native libvirt and -`virt-install`, or an existing Flatpak virt-manager with its QEMU extension. +and KVM. Native libvirt additionally needs `virsh`, `virtqemud`, +`virtstoraged`, `passt`, Python 3, `xmllint`, and `virt-xml-validate`; the helper +defines native VMs with `virsh`. The Flatpak backend uses QEMU user networking +and an existing virt-manager with its QEMU extension. The shared [data storage design](../design/storage.md) is implemented by `includes/data-storage.bu`. On a fresh VM it mounts the Btrfs `data` subvolume at `/var/lib/data` and sets rootful Podman's graphroot there. Recreate a @@ -24,7 +25,16 @@ run those tools in the Flatpak sandbox. The helper starts separate Flatpak libvirt daemons under `/run/user/UID/skvm`, grants them access to `butane/`, and uses QEMU user networking to forward the selected localhost SSH port. The selected backend is recorded in `runs/NAME/run.conf`. Check that `/dev/kvm` -is accessible on the host. The helper does not install host software. +is accessible on the host. Native libvirt uses the existing `qemu:///session` +connection when available, so those VMs appear in virt-manager's user session. +The helper does not install host software. + +On Rocky Linux, if QEMU startup reports `cannot limit core file size ... to +18446744073709551615`, set `max_core = 0` in +`${XDG_CONFIG_HOME:-$HOME/.config}/libvirt/qemu.conf` and restart the idle user +`virtqemud` daemon. This account had a zero core-dump hard limit; the user +session's `qemu.conf` now contains that setting. `virt-install` is optional for +the native backend. From this directory, create and check the default disposable clamps VM with: diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 64b254c1..33e88e26 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -74,17 +74,17 @@ fi [[ -f "$artifact" ]] || { echo "Missing Skillet artifact: $artifact" >&2; exit 1; } artifact="$(realpath "$artifact")" if [[ -z "$image" ]]; then - image="$butane_dir/images/coreos.qcow2" - if [[ ! -f "$image" ]]; then + mapfile -t images < <(find "$butane_dir/images" -maxdepth 1 -name 'fedora-coreos-*-qemu.x86_64.qcow2' -type f 2>/dev/null | sort) + if ((${#images[@]} == 0)); then mkdir -p "$butane_dir/images" podman run --pull=always --rm --security-opt label=disable \ -v "$butane_dir/images:/data" -w /data \ quay.io/coreos/coreos-installer:release \ download -s stable -p qemu -f qcow2.xz --decompress mapfile -t images < <(find "$butane_dir/images" -maxdepth 1 -name 'fedora-coreos-*-qemu.x86_64.qcow2' -type f | sort) - ((${#images[@]} == 1)) || { echo "Specify --image: found ${#images[@]} candidate FCOS images" >&2; exit 1; } - image="${images[0]}" fi + ((${#images[@]} == 1)) || { echo "Specify --image: found ${#images[@]} candidate FCOS images" >&2; exit 1; } + image="${images[0]}" fi [[ -f "$image" ]] || { echo "Missing FCOS image: $image" >&2; exit 1; } image="$(realpath "$image")" @@ -126,7 +126,11 @@ printf 'vm=%s\nhost=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir= "$vm_name" "$host" "$libvirt_uri" "$ssh_port" "$image" "$artifact" "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" "${TEST_VM_BACKEND:-native}" \ "$(git -C "$repo_dir" rev-parse HEAD)" > "$output_dir/run.conf" { - virt-install --version + if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then + virt-install --version + else + echo 'Native VM launcher: virsh XML' + fi vm_command virsh --version podman --version yq --version @@ -140,10 +144,62 @@ else qemu_commandline="-fw_cfg name=opt/com.coreos/config,file=$ignition" fi echo "Creating $vm_name on $libvirt_uri, disk $output_dir/$vm_name.qcow2, SSH 127.0.0.1:$ssh_port" >&2 -vm_command virt-install --connect "$libvirt_uri" --name "$vm_name" \ - --osinfo linux2024 --memory 8192 --vcpus 2 --import \ - --disk "path=$output_dir/$vm_name.qcow2,format=qcow2,bus=virtio" \ - --disk "path=$ignition,format=raw,bus=virtio,readonly=on" \ - --network "$network" --graphics none \ - --console pty,target_type=serial --noautoconsole \ - --qemu-commandline="$qemu_commandline" +if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then + vm_command virt-install --connect "$libvirt_uri" --name "$vm_name" \ + --osinfo linux2024 --memory 8192 --vcpus 2 --import \ + --disk "path=$output_dir/$vm_name.qcow2,format=qcow2,bus=virtio" \ + --disk "path=$ignition,format=raw,bus=virtio,readonly=on" \ + --network "$network" --graphics none \ + --console pty,target_type=serial --noautoconsole \ + --qemu-commandline="$qemu_commandline" +else + [[ "${TEST_VM_BACKEND:-native}" == native ]] || { echo 'Flatpak backend needs virt-install' >&2; exit 1; } + emulator="$(vm_command virsh -c "$libvirt_uri" capabilities | + xmllint --xpath "string((//guest[os_type='hvm']/arch[@name='x86_64']/emulator)[1])" -)" + [[ -n "$emulator" && -x "$emulator" ]] || { echo 'No usable x86_64 libvirt emulator' >&2; exit 1; } + python3 - "$vm_name" "$emulator" "$output_dir/$vm_name.qcow2" "$ignition" "$ssh_port" > "$output_dir/domain.xml" <<'PY' +import sys +import xml.etree.ElementTree as ET + +name, emulator, disk, ignition, port = sys.argv[1:] +domain = ET.Element('domain', type='kvm') +ET.SubElement(domain, 'name').text = name +ET.SubElement(domain, 'memory', unit='MiB').text = '8192' +ET.SubElement(domain, 'vcpu').text = '2' +os = ET.SubElement(domain, 'os') +ET.SubElement(os, 'type', arch='x86_64', machine='q35').text = 'hvm' +ET.SubElement(os, 'boot', dev='hd') +features = ET.SubElement(domain, 'features') +ET.SubElement(features, 'acpi') +ET.SubElement(features, 'apic') +ET.SubElement(domain, 'cpu', mode='host-passthrough') +sysinfo = ET.SubElement(domain, 'sysinfo', type='fwcfg') +ET.SubElement(sysinfo, 'entry', name='opt/com.coreos/config', file=ignition) +devices = ET.SubElement(domain, 'devices') +ET.SubElement(devices, 'emulator').text = emulator +for source, fmt, target, readonly in ((disk, 'qcow2', 'vda', False), (ignition, 'raw', 'vdb', True)): + device = ET.SubElement(devices, 'disk', type='file', device='disk') + ET.SubElement(device, 'driver', name='qemu', type=fmt) + ET.SubElement(device, 'source', file=source) + ET.SubElement(device, 'target', dev=target, bus='virtio') + if readonly: + ET.SubElement(device, 'readonly') +interface = ET.SubElement(devices, 'interface', type='user') +ET.SubElement(interface, 'backend', type='passt') +forward = ET.SubElement(interface, 'portForward', proto='tcp', address='127.0.0.1') +ET.SubElement(forward, 'range', start=port, to='22') +ET.SubElement(interface, 'model', type='virtio') +serial = ET.SubElement(devices, 'serial', type='pty') +ET.SubElement(serial, 'target', port='0') +console = ET.SubElement(devices, 'console', type='pty') +ET.SubElement(console, 'target', type='serial', port='0') +ET.indent(domain) +ET.ElementTree(domain).write(sys.stdout, encoding='unicode', xml_declaration=True) +PY + virt-xml-validate "$output_dir/domain.xml" + vm_command virsh -c "$libvirt_uri" define "$output_dir/domain.xml" + if ! vm_command virsh -c "$libvirt_uri" start "$vm_name"; then + vm_command virsh -c "$libvirt_uri" undefine "$vm_name" + exit 1 + fi +fi diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 831913ff..d97217eb 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -16,8 +16,11 @@ vm_virsh() { fi } select_backend() { - if [[ -x /usr/bin/virsh && -x /usr/bin/virtqemud && -x /usr/bin/virtstoraged ]]; then + if command -v virsh >/dev/null && command -v virtqemud >/dev/null && command -v virtstoraged >/dev/null; then backend=native + if /usr/bin/virsh -c "$uri" uri >/dev/null 2>&1; then + runtime_dir="/run/user/$(id -u)" + fi elif flatpak info org.virt_manager.virt-manager >/dev/null 2>&1 && flatpak info org.virt_manager.virt_manager.Extension.Qemu >/dev/null 2>&1; then backend=flatpak @@ -45,16 +48,14 @@ ensure_runtime() { if [[ "$backend" == native ]]; then ensure_native_runtime else - # shellcheck disable=SC2015 - flatpak info org.virt_manager.virt-manager >/dev/null 2>&1 && - flatpak info org.virt_manager.virt_manager.Extension.Qemu >/dev/null 2>&1 || { - echo 'Flatpak virt-manager or its QEMU extension is unavailable' >&2; exit 1; - } + if ! flatpak info org.virt_manager.virt-manager >/dev/null 2>&1 || + ! flatpak info org.virt_manager.virt_manager.Extension.Qemu >/dev/null 2>&1; then + echo 'Flatpak virt-manager or its QEMU extension is unavailable' >&2; exit 1 + fi [[ "$runtime_dir" == "/run/user/$(id -u)/skvm" && ! -L "$runtime_dir" ]] || { echo 'Invalid Flatpak libvirt runtime path' >&2; exit 1; } - # shellcheck disable=SC2174 - mkdir -p -m 0700 "$runtime_dir" + install -d -m 0700 "$runtime_dir" if [[ ! -f "$runtime_dir/flatpak.pid" ]] || ! kill -0 "$(cat "$runtime_dir/flatpak.pid")" 2>/dev/null; then setsid "$script_dir/flatpak-virt" sh -c \ 'virtlogd --timeout 0 & virtstoraged --timeout 0 & virtqemud --timeout 0 & wait' \ @@ -75,6 +76,12 @@ ensure_runtime() { fi } ensure_native_runtime() { + if [[ "$runtime_dir" == "/run/user/$(id -u)" ]]; then + /usr/bin/virsh -c "$uri" uri >/dev/null || { + echo "Cannot access the native user libvirt session at $uri" >&2; exit 1; + } + return + fi [[ ! -L "$runtime_dir" ]] || { echo "Symlinked libvirt runtime refused" >&2; exit 1; } if [[ ! -d "$runtime_dir" ]]; then mkdir -m 0700 "$runtime_dir" @@ -114,7 +121,7 @@ list_host() { listed_runtime="$(config_value runtime_dir)" listed_backend="$(config_value backend)" [[ -n "$listed_backend" ]] || listed_backend=native - if [[ "$listed_port" =~ ^22[0-9][0-9]$ && -f "$run_dir/domain.uuid" && ( ( "$listed_backend" == flatpak && "$listed_runtime" == "/run/user/$(id -u)/skvm" ) || ( "$listed_backend" == native && "$listed_runtime" == /run/user/"$(id -u)"/* ) ) ]]; then + if [[ "$listed_port" =~ ^22[0-9][0-9]$ && -f "$run_dir/domain.uuid" && ( ( "$listed_backend" == flatpak && "$listed_runtime" == "/run/user/$(id -u)/skvm" ) || ( "$listed_backend" == native && ( "$listed_runtime" == "/run/user/$(id -u)" || "$listed_runtime" == /run/user/"$(id -u)"/* ) ) ) ]]; then recorded_uuid="$(cat "$run_dir/domain.uuid")" if [[ "$listed_backend" == flatpak ]]; then listed_virsh=(env TEST_VM_LIBVIRT_RUNTIME_DIR="$listed_runtime" "$script_dir/virsh") @@ -188,7 +195,7 @@ inspect_owned() { } runtime_from_manifest if [[ "$backend" == native ]]; then - [[ "$runtime_dir" == /run/user/"$(id -u)"/* ]] || { echo "Invalid recorded libvirt runtime" >&2; exit 1; } + [[ "$runtime_dir" == "/run/user/$(id -u)" || "$runtime_dir" == /run/user/"$(id -u)"/* ]] || { echo "Invalid recorded libvirt runtime" >&2; exit 1; } else [[ "$runtime_dir" == "/run/user/$(id -u)/skvm" ]] || { echo "Invalid Flatpak runtime" >&2; exit 1; } fi @@ -225,9 +232,11 @@ case "$operation" in host_available "$host" || { echo "Host $host needs both $host.bu and a skillet-$host crate" >&2; exit 1; } select_backend if [[ "$backend" == native ]]; then - for executable in virt-install qemu-system-x86_64 passt; do - command -v "$executable" >/dev/null || { echo "Missing native VM tool: $executable" >&2; exit 1; } - done + command -v passt >/dev/null || { echo 'Missing native VM tool: passt' >&2; exit 1; } + if ! command -v python3 >/dev/null || ! command -v xmllint >/dev/null || + ! command -v virt-xml-validate >/dev/null; then + echo 'Native VM creation needs python3, xmllint and virt-xml-validate' >&2; exit 1 + fi fi if [[ ! -e /dev/kvm ]]; then echo '/dev/kvm is unavailable; enable hardware virtualization and KVM before creating a VM.' >&2 From db3e53c40f12f647f28cc18e9889f66985af7f53 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 28 Sep 2026 13:13:07 +0200 Subject: [PATCH 085/252] feat(skillet): create and deliver KeePass host credentials --- ublue/butane/ACCEPTANCE.md | 23 ++ ublue/design/secrets.md | 35 ++- ublue/plan/CLAMPS-MIGRATION.md | 3 +- ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md | 14 +- ublue/skillet/AGENTS.md | 1 + ublue/skillet/Cargo.lock | 48 ++- ublue/skillet/Cargo.toml | 1 + ublue/skillet/README.md | 28 +- .../crates/cli-common/src/credential.rs | 96 ++++++ ublue/skillet/crates/cli-common/src/lib.rs | 25 ++ ublue/skillet/crates/cli/Cargo.toml | 4 +- ublue/skillet/crates/cli/src/main.rs | 15 +- .../skillet/crates/cli/src/secret_delivery.rs | 285 +++++++++++++++--- .../crates/cli/src/secret_delivery/tests.rs | 63 +++- .../cli/src/secret_delivery/vault_cache.rs | 59 ++++ 15 files changed, 622 insertions(+), 78 deletions(-) create mode 100644 ublue/skillet/crates/cli-common/src/credential.rs create mode 100644 ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index e3073b00..9e793985 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -188,3 +188,26 @@ host tool; it still uses normal command approvals. both storage scripts, and `git diff --check` passed. These checks did not include a post-provision OS rebase or the physical stock SSD. Other host templates do not yet merge the shared include because they do not yet exist. + +## KeePass use-or-create smoke, 2026-09-28 + +- Fresh `clamps-test-credential-e2e` VM on port 2202 passed current Butane + readiness, including signed uCore boot and the user environment. The updated + Rust host command installed a generated dummy Pi-hole credential; full apply + completed and Pi-hole became active. The guest credential is root-owned and + mode 0600. +- `skillet test smoke clamps --port 2202 --identity + ../butane/runs/clamps-test-credential-e2e/ssh/id_ed25519` passed all fixture + cases and reboot persistence. Guest snapshots remain under + `/var/lib/skillet-smoke/` on the retained VM. +- An empty disposable KDBX could not replace the VM's existing credential. + After removing only that VM's dummy credential and Podman secret, the same + vault created `skillet/hosts/clamps/pihole/web-password` and delivered it. + A repeat delivery reopened the saved entry and completed full apply. The + encrypted vault backup was retained, the host reported `present`, Pi-hole + was active, and full apply reported `Result=success` and `ExecMainStatus=0`. +- On this workstation, both Skillet and `keyctl timeout` received permission + denied when setting expiry on a new user key. Skillet unlinked that key and + continued without caching the dummy unlock; the user keyring was empty after + the run. The three-hour kernel expiry path remains unverified here. No + production vault, production host, or physical disk was used. diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index bd80c6cd..dd01e247 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -1,8 +1,9 @@ # Secret storage and delivery -Decision: use KeePassXC for durable secrets and read its KDBX database directly -from workstation Skillet through a Rust library. Exact lookup and Pi-hole -delivery are implemented; Cloudflare token issuance remains planned. +Decision: use KeePassXC for durable secrets and read and write its KDBX database +directly from workstation Skillet through a Rust library. Pi-hole delivery uses +an existing entry or creates one for a fresh host; Cloudflare token issuance +remains planned. ## Portable storage @@ -13,11 +14,18 @@ to `$XDG_DATA_HOME/skillet/secrets.kdbx` (or `$HOME/.local/share/skillet/secrets.kdbx`); a symlink may point at the existing synced vault. Vault-dependent commands fail if that path is absent, with an explicit `--database` override. Disposable VM provisioning needs no vault. -KeePassXC owns edits and conflict resolution; Skillet reads it in memory. +Skillet resolves symlinks before writing, saves a verified encrypted replacement +atomically, and retains an encrypted backup of the prior database. It rejects a +write when the database changed since opening. KeePassXC and Syncthing can +still write concurrently, so close KeePassXC before Skillet creates an entry +and resolve any Syncthing conflict copies before retrying. Backups include the encrypted database, with recovery passwords and any key files retained separately. Syncthing replication alone is not versioned backup. -The workstation currently prompts for the database password on its terminal. +The workstation prompts for the database password once and caches it in its +Linux user keyring for three hours (10,800 seconds), without refreshing the +expiry on reads. If the kernel denies expiry, Skillet removes the new key and +continues without a cache. `skillet secret lock` removes a cached key early. A new workstation can unlock the copied database without the old one's keyring. The existing `cloudflare-token-creator` keyring item still needs migration. @@ -31,17 +39,22 @@ The prefix is consistently singular, `skillet`: | `skillet/cloudflare/token-creator` | Workstation only | Host entries follow `skillet/hosts///`; shared credentials -follow `skillet//`. Exact lookups reject missing or ambiguous -entries. Production values persist across workstation changes; absence never -implicitly generates a replacement. Rotation is explicit. Normal host re-apply -can reuse credentials already installed on that host. +follow `skillet//`. Exact lookups reject ambiguous entries and +entries with empty Password fields. A missing Pi-hole entry is generated only +after Skillet successfully opens the existing vault and confirms over SSH that +the host has neither an encrypted Pi-hole credential nor a Podman secret. If +the host has either one or its state cannot be checked, Skillet refuses to +create a replacement. Production values persist across workstation +changes; rotation is an explicit vault edit and redelivery. Normal host +re-apply can reuse credentials already installed on that host. ## Delivery and consumption After signed boot and base readiness, the workstation sends only the target's selected secret values through SSH stdin using its recorded/verified host key. -The host encrypts stdin with `systemd-creds` and atomically installs root-owned -mode `0600` files at `/etc/credstore.encrypted/skillet/.cred`. +The host Rust command encrypts stdin with `systemd-creds` and atomically +installs root-owned mode `0600` files at +`/etc/credstore.encrypted/skillet/.cred`. Keeping delivery after bootstrap avoids plaintext in Ignition and run artifacts. Secret values never enter command arguments or diagnostics. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 6349fa81..c456f45f 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -28,7 +28,8 @@ the final physical disk remains open. - Image: packages, static OS files, baseline unit enablement/masking. Preserve common-before-host image builds. - Ignition: installation layout, initial access and bootstrap configuration. -- Workstation Skillet: KeePassXC reads and secret delivery over SSH after base readiness. +- Workstation Skillet: KeePassXC reads and guarded entry creation, a three-hour + kernel unlock cache, and secret delivery over SSH after base readiness. - Skillet: continuing convergence of host values, application configuration, secrets, storage resources and containers. - Public repositories and images must exclude secret values, including secrets compiled into host binaries. Decide which topology values also require private delivery. - Keep the production Samsung SSD and functioning DNS on rupik until cutover. Test on the stock clamps SSD or disposable VM disks. diff --git a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md index 1def10c4..b85fcf40 100644 --- a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -10,13 +10,17 @@ and mount-failure checks; see [the acceptance record](../butane/ACCEPTANCE.md). The include is host independent and must be merged by each future host's Butane template. -3B progress: exact KDBX lookup, an XDG data-home vault default, encrypted SSH -delivery, a full-apply unit, and Pi-hole secret file wiring are implemented. +3B progress: exact KDBX lookup and safe use-or-create for a fresh host, an XDG +data-home vault default, a three-hour kernel unlock cache, encrypted SSH +delivery through the host Rust binary, a full-apply unit, and Pi-hole secret +file wiring are implemented. Workspace checks, dummy credential delivery, repeated apply, rotation, reboot, and VM smoke passed; see [the acceptance record](../butane/ACCEPTANCE.md). Cloudflare creator migration, TPM assessment, interrupted delivery recovery, and an OS rebase -after delivery remain open. +after delivery remain open. This workstation denied kernel key timeouts during +the 2026-09-28 smoke, so the three-hour cache still needs verification from a +normal user terminal; Skillet safely continued uncached. ## Split into two small steps @@ -106,7 +110,9 @@ Implement the [KeePassXC and delivery decision](../design/secrets.md): - Use host-key encryption for VM acceptance. Confirm production TPM support, rebase compatibility, and disk protection for Podman's separate secret copy. - Rotation must recreate consumers; unchanged credentials must not restart them. - A new workstation or missing vault entry must never regenerate production values. + A missing vault entry may create a production value only after the existing + vault is unlocked and the host confirms neither an encrypted credential nor + a Podman secret exists. A new workstation with a missing vault cannot create one. Cloudflare per-VM issuance and cleanup follow in the live ACME milestone, using the [VM lifecycle decision](../design/smoke-vms.md). diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 5c16b465..3edada9e 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -9,6 +9,7 @@ This document defines the architectural mandates and project structure for `skil - **Libraries MUST NOT use `anyhow`**. `anyhow` is reserved for the CLI binary only. - **NEVER use `unwrap()` or `expect()`** in library code. All errors must be propagated and handled. - **Prioritize Crates over Shell-out**: Use Rust crates (e.g., `users`, `nix`) for system interactions whenever possible instead of executing shell commands. +- **Never embed shell scripts in Rust string constants or construct shell programs inside Rust.** Use Rust APIs and focused subprocess calls for work owned by Skillet. If a shell script is truly needed, keep it in a standalone script file with its own interface. ### 2. Idempotency - All resources (files, users, groups, etc.) must be **idempotent**. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 260aef3f..6db4eac4 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -309,6 +309,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -972,6 +978,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keyutils" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c5d275b92e255aa97acd757f9bbc9ddbaa8d0938715c5cb6c28b3e00b773637" +dependencies = [ + "bitflags 1.3.2", + "errno", + "keyutils-raw", + "libc", + "log", + "uninit", +] + +[[package]] +name = "keyutils-raw" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db322e79368bacfd8d7ecba9d9038866d2ff5e78b52377376f38b7360878392b" +dependencies = [ + "errno", + "libc", + "log", + "uninit", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1071,7 +1103,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1302,7 +1334,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -1487,7 +1519,9 @@ dependencies = [ "clap", "hex", "keepass", + "keyutils", "rpassword", + "sha2 0.10.9", "skillet_cli_common", "tempfile", "tracing", @@ -1826,6 +1860,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "uninit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac20c07bcac4f505f1e4fbd2e55b54f6297a35a27538f107cb3d4254b4514146" + [[package]] name = "users" version = "0.11.0" @@ -1963,7 +2003,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -2186,7 +2226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 0a8a6e9f..61aa2e0a 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -33,6 +33,7 @@ zbus = { version = "4.4", features = ["blocking"] } askama = "0.12" keepass = { version = "0.13.25", features = ["save_kdbx4"] } rpassword = "7.5" +keyutils = "0.4" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 66e4cfc0..b7074f42 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -114,7 +114,11 @@ that VM's host key beside the private key. Manage durable secrets in your Syncthing-synced KeePassXC database. Use group paths and entry titles from the [secret design](../design/secrets.md), storing each value in its Password field. The workstation prompts for the database -password on its terminal; it does not write decrypted values to a file. +password on its terminal and keeps the unlock in the Linux kernel keyring for +three hours. It does not write decrypted values to a file. Run +`cargo run --release -p skillet -- secret lock` to clear the cache early. +If the kernel denies key expiry, Skillet reports that it could not cache the +unlock and asks for the password on the next command. Skillet looks for `$XDG_DATA_HOME/skillet/secrets.kdbx`, falling back to `$HOME/.local/share/skillet/secrets.kdbx`. Point that location at your Syncthing-synced database, for example: @@ -143,9 +147,8 @@ by current Butane, so recreate older test VMs made before that unit existed. If an existing VM credential is corrupt, use `--rotate` to replace only that disposable value. -For a production clamps host, create the exact KeePassXC entry -`skillet/hosts/clamps/pihole/web-password`, then deliver it over an SSH -connection whose host key you have already recorded: +For a production clamps host, deliver over an SSH connection whose host key +you have already recorded: ```bash cargo run --release -p skillet -- secret deliver clamps pihole \ @@ -154,14 +157,19 @@ cargo run --release -p skillet -- secret deliver clamps pihole \ ``` Add `--key-file /path/to/keyfile` if the database uses one. The value is -encrypted on the host and loaded by `skillet-full-apply.service`; later +encrypted by the installed `skillet-clamps credential install` command on the +host and loaded by `skillet-full-apply.service`; later `systemctl start --wait skillet-full-apply.service` reuses it. If full apply reports a missing or undecryptable credential, check the host's encrypted -credential file and redeliver the KeePassXC entry. Do not create a new -production password merely because a workstation lacks the vault. Redeliver -the same entry after moving to a new workstation; use a deliberate vault edit -and redelivery for rotation. The production TPM binding decision remains in -the [secret design](../design/secrets.md). +credential file and redeliver the KeePassXC entry. If the entry is absent in +an existing vault and the host has no encrypted credential, Skillet creates +`skillet/hosts/clamps/pihole/web-password` and delivers it. Keep KeePassXC +closed during creation and resolve Syncthing conflict copies before retrying. +If the host already has an encrypted credential or Podman secret, restore the +original vault entry. +Redeliver the same entry after moving to a new workstation; use a deliberate +vault edit and redelivery for rotation. The production TPM binding decision +remains in the [secret design](../design/secrets.md). For Cloudflare token issuance, select **Create additional tokens** in the Cloudflare dashboard, with **User > API Tokens > Edit** and no extra diff --git a/ublue/skillet/crates/cli-common/src/credential.rs b/ublue/skillet/crates/cli-common/src/credential.rs new file mode 100644 index 00000000..e5ccd560 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/credential.rs @@ -0,0 +1,96 @@ +use std::{ + fs, io, + os::unix::fs::PermissionsExt as _, + path::PathBuf, + process::{Command, Stdio}, +}; +use thiserror::Error; + +const CREDENTIAL_DIRECTORY: &str = "/etc/credstore.encrypted/skillet"; + +#[derive(Debug, Error)] +pub enum CredentialError { + #[error("invalid credential name or service unit")] + InvalidName, + #[error("I/O error: {0}")] + Io(#[from] io::Error), + #[error("{0} failed")] + Command(&'static str), +} + +fn credential_path(name: &str) -> Result { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(CredentialError::InvalidName); + } + Ok(PathBuf::from(CREDENTIAL_DIRECTORY).join(format!("{name}.cred"))) +} + +pub fn state(name: &str) -> Result<&'static str, CredentialError> { + let path = credential_path(name)?; + match fs::symlink_metadata(path) { + Ok(_) => return Ok("present"), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let status = Command::new("podman") + .args(["secret", "exists", name]) + .status()?; + match status.code() { + Some(0) => Ok("present"), + Some(1) => Ok("absent"), + _ => Err(CredentialError::Command("podman secret exists")), + } +} + +pub fn install(name: &str, unit: &str) -> Result<(), CredentialError> { + let path = credential_path(name)?; + if !unit.ends_with(".service") + || !unit + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || !unit + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'@' | b'.')) + { + return Err(CredentialError::InvalidName); + } + let directory = PathBuf::from(CREDENTIAL_DIRECTORY); + fs::create_dir_all(&directory)?; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + let encrypted = tempfile::NamedTempFile::new_in(&directory)?; + let status = Command::new("systemd-creds") + .args(["encrypt", "--with-key=host"]) + .arg(format!("--name={name}")) + .args(["-", "-"]) + .stdin(Stdio::inherit()) + .stdout(Stdio::from(encrypted.reopen()?)) + .status()?; + if !status.success() { + return Err(CredentialError::Command("systemd-creds encrypt")); + } + encrypted.as_file().sync_all()?; + let status = Command::new("systemd-creds") + .arg("decrypt") + .arg(format!("--name={name}")) + .arg(encrypted.path()) + .arg("-") + .stdout(Stdio::null()) + .status()?; + if !status.success() { + return Err(CredentialError::Command("systemd-creds decrypt")); + } + encrypted.persist(path).map_err(|error| error.error)?; + fs::File::open(&directory)?.sync_all()?; + let status = Command::new("systemctl") + .args(["start", "--wait", unit]) + .status()?; + if !status.success() { + return Err(CredentialError::Command("systemctl start --wait")); + } + Ok(()) +} diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index da6cb1f2..5cee2385 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -8,6 +8,7 @@ use thiserror::Error; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; +mod credential; pub mod hosts; use hosts::ApplyPhase; @@ -23,6 +24,8 @@ pub enum CliCommonError { Io(#[from] std::io::Error), #[error("Serialization error: {0}")] Yaml(#[from] serde_yml::Error), + #[error("Credential error: {0}")] + Credential(#[from] credential::CredentialError), } #[derive(Parser, Debug)] @@ -46,6 +49,19 @@ pub enum HostCommands { #[arg(long)] record: Option, }, + /// Inspect or install an encrypted host credential + Credential { + #[command(subcommand)] + command: CredentialCommands, + }, +} + +#[derive(clap::Subcommand, Debug)] +pub enum CredentialCommands { + /// Print `present` if a host credential or Podman secret exists, otherwise `absent` + State { name: String }, + /// Encrypt stdin for this host, install it, and start the consuming unit + Install { name: String, unit: String }, } pub fn run_host(hostname: &str, apply_fn: F) -> Result<(), CliCommonError> @@ -68,6 +84,15 @@ where HostCommands::Apply { record, phase } => handle_apply(hostname, record, |system, files| { apply_fn(phase, system, files) }), + HostCommands::Credential { command } => { + match command { + CredentialCommands::State { name } => { + println!("{}", credential::state(&name)?); + } + CredentialCommands::Install { name, unit } => credential::install(&name, &unit)?, + } + Ok(()) + } } } diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 4bbaca8b..4addc533 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -14,7 +14,7 @@ tracing-subscriber.workspace = true anyhow = "1.0" keepass.workspace = true rpassword.workspace = true +keyutils.workspace = true hex.workspace = true - -[dev-dependencies] +sha2.workspace = true tempfile.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 6b8b673c..d8f0e997 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -22,7 +22,7 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { - /// Deliver an existing `KeePassXC` secret to a provisioned host + /// Manage host secrets backed by `KeePassXC` Secret { #[command(subcommand)] command: SecretCommands, @@ -48,8 +48,16 @@ enum Commands { #[derive(clap::Subcommand, Debug)] enum SecretCommands { - /// Deliver the clamps Pi-hole web password + /// Use or create the clamps Pi-hole web password and deliver it Deliver(SecretDeliverArgs), + /// Remove the cached vault password from the kernel keyring + Lock(SecretLockArgs), +} + +#[derive(clap::Args, Debug)] +struct SecretLockArgs { + #[arg(long)] + database: Option, } #[derive(clap::Args, Debug)] @@ -169,6 +177,9 @@ fn main() -> Result<()> { } => { secret_delivery::deliver_from_vault(&args)?; } + Commands::Secret { + command: SecretCommands::Lock(args), + } => secret_delivery::lock_vault(args.database.as_deref())?, Commands::Apply { phase, host, diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index c2a4265f..fe63e6d2 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -4,21 +4,13 @@ use keepass::{Database, DatabaseKey}; use std::{ fs, io::{Read as _, Write as _}, - path::Path, + os::unix::fs::OpenOptionsExt as _, + path::{Path, PathBuf}, process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, }; -const INSTALL: &str = r#"set -euo pipefail -umask 077 -dir=/etc/credstore.encrypted/skillet -install -d -m 0700 "$dir" -tmp=$(mktemp "$dir/.pihole_web_password.XXXXXX") -trap "rm -f -- $tmp" EXIT -systemd-creds encrypt --with-key=host --name=pihole_web_password - - > "$tmp" -systemd-creds decrypt --name=pihole_web_password "$tmp" - >/dev/null -chmod 0600 "$tmp" -mv -f -- "$tmp" "$dir/pihole_web_password.cred" -systemctl start --wait skillet-full-apply.service"#; +mod vault_cache; pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { let path = format!("skillet/hosts/{}/pihole/web-password", args.hostname); @@ -26,12 +18,48 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { Some(path) => path.clone(), None => default_database_path()?, }; - let secret = read_vault(&database, args.key_file.as_deref(), &path)?; if !args.identity.is_file() || !args.known_hosts.is_file() { return Err(anyhow!( "SSH identity and recorded known-hosts file must exist" )); } + let mut vault = open_vault(&database, args.key_file.as_deref())?; + let secret = if let Some(secret) = lookup(&vault.database, &path)? { + if fs::read(&vault.path).context("rechecking vault before delivery")? != vault.original { + return Err(anyhow!( + "KeePassXC database changed while Skillet was running; reopen it and retry" + )); + } + secret + } else { + if remote_credential_state(args)? != RemoteCredentialState::Absent { + return Err(anyhow!( + "host already has a Pi-hole credential; restore the missing KeePassXC entry instead of creating a replacement" + )); + } + let secret = random_password()?; + create_entry(&mut vault.database, &path, &secret)?; + save_vault(&vault, args.key_file.as_deref(), &path, &secret)?; + secret + }; + let mut command = ssh_command(args); + install(&mut command, &secret) +} + +pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { + let path = match path { + Some(path) => path.to_path_buf(), + None => default_database_path()?, + }; + // A symlink at the default XDG location points to the same cache entry as + // an explicit path to its target. + let canonical = canonical_database(&path)?; + vault_cache::clear(&canonical)?; + println!("Vault unlock removed from the kernel keyring"); + Ok(()) +} + +fn ssh_command(args: &SecretDeliverArgs) -> Command { let mut command = Command::new("ssh"); command .args([ @@ -52,7 +80,30 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { .arg("-p") .arg(args.port.to_string()) .arg(&args.target); - install(&mut command, &secret) + command +} + +#[derive(PartialEq, Eq)] +enum RemoteCredentialState { + Present, + Absent, +} + +fn remote_credential_state(args: &SecretDeliverArgs) -> Result { + let output = ssh_command(args) + .arg("sudo -n /var/usrlocal/bin/skillet-clamps credential state pihole_web_password") + .output() + .context("checking host state before generating a production credential")?; + if !output.status.success() { + return Err(anyhow!( + "SSH host credential state check failed; no production credential was generated" + )); + } + match output.stdout.as_slice() { + b"present\n" => Ok(RemoteCredentialState::Present), + b"absent\n" => Ok(RemoteCredentialState::Absent), + _ => Err(anyhow!("host returned an unrecognized credential state")), + } } fn default_database_path() -> Result { @@ -188,28 +239,65 @@ fn read_vm_port(manifest: &Path) -> Result { Ok(port) } -fn read_vault(path: &Path, key_file: Option<&Path>, entry_path: &str) -> Result { - // Check file availability before prompting for the unlock password. - let mut database_file = fs::File::open(path).with_context(|| format!( +struct OpenVault { + path: PathBuf, + original: Vec, + database: Database, + password: String, +} + +fn canonical_database(path: &Path) -> Result { + fs::canonicalize(path).with_context(|| format!( "KeePassXC database is missing or unreadable at {}; place the synced vault there or pass --database", path.display() - ))?; - let mut key_file = key_file - .map(fs::File::open) - .transpose() - .context("KeePassXC key file is missing or unreadable")?; + )) +} + +fn database_key(password: &str, key_file: Option<&Path>) -> Result { + let mut key = DatabaseKey::new().with_password(password); + if let Some(path) = key_file { + let mut file = + fs::File::open(path).context("KeePassXC key file is missing or unreadable")?; + key = key.with_keyfile(&mut file)?; + } + Ok(key) +} + +fn open_with_password(bytes: &[u8], password: &str, key_file: Option<&Path>) -> Result { + let mut reader = bytes; + Database::open(&mut reader, database_key(password, key_file)?) + .context("opening KeePassXC database; check password and key file") +} + +fn open_vault(path: &Path, key_file: Option<&Path>) -> Result { + // Resolve the symlink before any eventual atomic replacement, so the + // Syncthing-managed target is changed instead of replacing the symlink. + let path = canonical_database(path)?; + let original = fs::read(&path).context("reading KeePassXC database")?; + if let Some(cached) = vault_cache::read(&path)? { + if let Ok(database) = open_with_password(&original, &cached, key_file) { + return Ok(OpenVault { + path, + original, + database, + password: cached, + }); + } + vault_cache::clear(&path)?; + } let password = rpassword::prompt_password("KeePassXC database password: ") .context("reading database password from terminal")?; - let mut key = DatabaseKey::new().with_password(&password); - if let Some(file) = key_file.as_mut() { - key = key.with_keyfile(file)?; - } - let database = Database::open(&mut database_file, key) - .context("opening KeePassXC database; check password and key file")?; - lookup(&database, entry_path) + let database = open_with_password(&original, &password, key_file)?; + vault_cache::store(&path, &password)?; + Ok(OpenVault { + path, + original, + database, + password, + }) } -fn lookup(database: &Database, path: &str) -> Result { +fn lookup(database: &Database, path: &str) -> Result> { let parts: Vec<_> = path.split('/').collect(); if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { return Err(anyhow!("invalid KeePassXC entry path")); @@ -230,10 +318,12 @@ fn lookup(database: &Database, path: &str) -> Result { .filter_map(|id| database.entry(id)) .filter(|entry| entry.get_title() == parts.last().copied()) .collect::>(); - if matches.len() != 1 { - return Err(anyhow!("KeePassXC entry {path} must exist exactly once")); + if matches.len() > 1 { + return Err(anyhow!("KeePassXC entry {path} is ambiguous")); } - let entry = matches.remove(0); + let Some(entry) = matches.pop() else { + return Ok(None); + }; let secret = entry .get_password() .ok_or_else(|| anyhow!("KeePassXC entry {path} has no Password field"))?; @@ -242,18 +332,137 @@ fn lookup(database: &Database, path: &str) -> Result { "KeePassXC entry {path} has an empty Password field" )); } - Ok(secret.to_owned()) + Ok(Some(secret.to_owned())) +} + +fn random_password() -> Result { + let mut bytes = [0_u8; 32]; + fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; + Ok(hex::encode(bytes)) +} + +fn create_entry(database: &mut Database, path: &str, password: &str) -> Result<()> { + let parts: Vec<_> = path.split('/').collect(); + if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { + return Err(anyhow!("invalid KeePassXC entry path")); + } + let mut parent_id = database.root().id(); + for name in &parts[..parts.len() - 1] { + let parent = database + .group(parent_id) + .ok_or_else(|| anyhow!("vault group disappeared"))?; + let matching = parent + .group_ids() + .filter(|id| database.group(*id).is_some_and(|group| group.name == *name)) + .collect::>(); + parent_id = match matching.as_slice() { + [id] => *id, + [] => { + let mut parent = database + .group_mut(parent_id) + .ok_or_else(|| anyhow!("vault group disappeared"))?; + let mut group = parent.add_group(); + (*name).clone_into(&mut group.name); + group.id() + } + _ => return Err(anyhow!("KeePassXC group {name} is ambiguous")), + }; + } + if lookup(database, path)?.is_some() { + return Err(anyhow!("KeePassXC entry {path} already exists")); + } + let mut parent = database + .group_mut(parent_id) + .ok_or_else(|| anyhow!("vault group disappeared"))?; + let mut entry = parent.add_entry(); + entry.set_unprotected("Title", parts[parts.len() - 1]); + entry.set_protected("Password", password); + Ok(()) +} + +fn save_vault( + vault: &OpenVault, + key_file: Option<&Path>, + entry_path: &str, + secret: &str, +) -> Result<()> { + let parent = vault + .path + .parent() + .ok_or_else(|| anyhow!("vault has no parent directory"))?; + let lock_path = parent.join(".skillet-vault.lock"); + let lock = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(lock_path) + .context("opening vault write lock")?; + lock.lock().context("locking vault for update")?; + if fs::read(&vault.path).context("rechecking vault before write")? != vault.original { + return Err(anyhow!( + "KeePassXC database changed while Skillet was running; reopen it and retry" + )); + } + let mut candidate = + tempfile::NamedTempFile::new_in(parent).context("creating encrypted vault update")?; + vault + .database + .save( + candidate.as_file_mut(), + database_key(&vault.password, key_file)?, + ) + .context("saving KeePassXC database")?; + candidate.as_file_mut().flush()?; + candidate + .as_file() + .set_permissions(fs::metadata(&vault.path)?.permissions())?; + candidate.as_file().sync_all()?; + let candidate_bytes = fs::read(candidate.path())?; + let reopened = open_with_password(&candidate_bytes, &vault.password, key_file)?; + if lookup(&reopened, entry_path)?.as_deref() != Some(secret) { + return Err(anyhow!( + "saved vault did not retain the generated credential" + )); + } + // Retain a separate encrypted copy of the previous valid vault before + // replacing it. KeePassXC/Syncthing can then recover from a bad write. + let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let backup_name = format!( + "{}.skillet-{stamp}.bak", + vault + .path + .file_name() + .ok_or_else(|| anyhow!("vault has no filename"))? + .to_string_lossy() + ); + let mut backup = tempfile::NamedTempFile::new_in(parent).context("creating vault backup")?; + backup.write_all(&vault.original)?; + backup + .as_file() + .set_permissions(fs::metadata(&vault.path)?.permissions())?; + backup.as_file().sync_all()?; + backup + .persist_noclobber(parent.join(backup_name)) + .context("preserving previous encrypted vault")?; + if fs::read(&vault.path).context("rechecking vault before replacement")? != vault.original { + return Err(anyhow!( + "KeePassXC database changed during save; generated value was not installed" + )); + } + candidate + .persist(&vault.path) + .context("atomically replacing KeePassXC database")?; + fs::File::open(parent)?.sync_all()?; + Ok(()) } fn install(command: &mut Command, secret: &str) -> Result<()> { if secret.is_empty() { return Err(anyhow!("refusing to deliver an empty credential")); } - // The script is constant and contains no user input. Secret bytes travel - // only over stdin, never through command arguments or diagnostics. - let remote = format!("sudo -n bash -c '{INSTALL}'"); let mut child = command - .arg(remote) + .arg("sudo -n /var/usrlocal/bin/skillet-clamps credential install pihole_web_password skillet-full-apply.service") .stdin(Stdio::piped()) .spawn() .context("opening SSH credential delivery")?; diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 027924f4..3b992ad0 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,4 +1,6 @@ -use super::{database_path_from, lookup, read_vault, read_vm_port}; +use super::{ + create_entry, database_path_from, lookup, open_vault, read_vm_port, save_vault, OpenVault, +}; use keepass::Database; use keepass::DatabaseKey; @@ -19,10 +21,16 @@ fn exact_vault_path_preserves_whitespace() { entry.set_protected("Password", " value\n"); assert_eq!( lookup(&db, "skillet/hosts/clamps/pihole/web-password").unwrap(), - " value\n" + Some(" value\n".to_string()) + ); + assert_eq!( + lookup(&db, "skillet/hosts/clamps/pihole/other").unwrap(), + None + ); + assert_eq!( + lookup(&db, "skillet/hosts/other/pihole/web-password").unwrap(), + None ); - assert!(lookup(&db, "skillet/hosts/clamps/pihole/other").is_err()); - assert!(lookup(&db, "skillet/hosts/other/pihole/web-password").is_err()); } #[test] @@ -60,7 +68,10 @@ fn disposable_database_opens_with_correct_password_only() { DatabaseKey::new().with_password("fixture unlock"), ) .unwrap(); - assert_eq!(lookup(&reopened, "skillet/fixture").unwrap(), "dummy only"); + assert_eq!( + lookup(&reopened, "skillet/fixture").unwrap(), + Some("dummy only".to_string()) + ); } #[test] @@ -82,7 +93,47 @@ fn vault_path_uses_xdg_data_home_with_home_fallback() { fn missing_vault_fails_before_password_prompt() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("secrets.kdbx"); - let error = read_vault(&path, None, "skillet/fixture").unwrap_err(); + let error = open_vault(&path, None).err().unwrap(); assert!(error.to_string().contains("missing or unreadable")); assert!(error.to_string().contains("secrets.kdbx")); } + +#[test] +fn creates_and_reopens_encrypted_entry_through_symlink_target() { + let dir = tempfile::tempdir().unwrap(); + let database_path = dir.path().join("synced.kdbx"); + let link_path = dir.path().join("secrets.kdbx"); + let db = Database::new(); + let password = "fixture unlock"; + let mut original = Vec::new(); + db.save(&mut original, DatabaseKey::new().with_password(password)) + .unwrap(); + std::fs::write(&database_path, &original).unwrap(); + std::os::unix::fs::symlink(&database_path, &link_path).unwrap(); + let path = std::fs::canonicalize(&link_path).unwrap(); + let mut vault = OpenVault { + path, + original, + database: db, + password: password.to_string(), + }; + let entry_path = "skillet/hosts/clamps/pihole/web-password"; + create_entry(&mut vault.database, entry_path, "generated test value").unwrap(); + save_vault(&vault, None, entry_path, "generated test value").unwrap(); + assert!(link_path.is_symlink()); + let changed = std::fs::read(&database_path).unwrap(); + let reopened = Database::open( + &mut changed.as_slice(), + DatabaseKey::new().with_password(password), + ) + .unwrap(); + assert_eq!( + lookup(&reopened, entry_path).unwrap(), + Some("generated test value".to_string()) + ); + assert!(std::fs::read_dir(dir.path()).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".bak"))); +} diff --git a/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs b/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs new file mode 100644 index 00000000..2bb4fefc --- /dev/null +++ b/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs @@ -0,0 +1,59 @@ +use anyhow::{Context, Result}; +use keyutils::{keytypes::User, Keyring, SpecialKeyring}; +use sha2::{Digest, Sha256}; +use std::{path::Path, time::Duration}; + +const CACHE_LIFETIME: Duration = Duration::from_hours(3); + +fn description(database: &Path) -> String { + let digest = Sha256::digest(database.as_os_str().as_encoded_bytes()); + format!("skillet:vault:{}", hex::encode(digest)) +} + +fn keyring() -> Result { + // The per-user keyring survives separate CLI processes. Each password key + // has its own timeout and can be removed without affecting other keys. + Keyring::attach_or_create(SpecialKeyring::User) + .context("opening the Linux user keyring for the vault cache") +} + +pub(super) fn read(database: &Path) -> Result> { + let Ok(ring) = keyring() else { + return Ok(None); + }; + let Ok(key) = ring.search_for_key::(description(database), None) else { + return Ok(None); + }; + let payload = key.read().context("reading the cached vault password")?; + String::from_utf8(payload) + .context("cached vault password is not UTF-8") + .map(Some) +} + +pub(super) fn store(database: &Path, password: &str) -> Result<()> { + let Ok(mut ring) = keyring() else { + eprintln!("Vault password was not cached: Linux user keyring unavailable"); + return Ok(()); + }; + let Ok(mut key) = ring.add_key::(description(database), password.as_bytes()) else { + eprintln!("Vault password was not cached: could not add a kernel key"); + return Ok(()); + }; + if key.set_timeout(CACHE_LIFETIME).is_err() { + // An unbounded cache entry would retain the master password beyond the + // requested lifetime. Drop its only link before proceeding uncached. + ring.unlink_key(&key) + .context("removing a vault key whose expiry could not be set")?; + eprintln!("Vault password was not cached: kernel denied the three-hour expiry"); + } + Ok(()) +} + +pub(super) fn clear(database: &Path) -> Result<()> { + let mut ring = keyring()?; + if let Ok(key) = ring.search_for_key::(description(database), None) { + ring.unlink_key(&key) + .context("removing the cached vault password")?; + } + Ok(()) +} From db58bf0381c21afe0f3d6b6f6cb6d750a2d31100 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 28 Sep 2026 17:31:02 +0200 Subject: [PATCH 086/252] fix(skillet): cache vault unlock in expiring session keyring --- ublue/butane/ACCEPTANCE.md | 5 ++ ublue/design/secrets.md | 14 ++-- ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md | 8 +- .../cli/src/secret_delivery/vault_cache.rs | 77 ++++++++++++++++--- 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 9e793985..28fe2a32 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -211,3 +211,8 @@ host tool; it still uses normal command approvals. continued without caching the dummy unlock; the user keyring was empty after the run. The three-hour kernel expiry path remains unverified here. No production vault, production host, or physical disk was used. +- Follow-up: `keyctl timeout` succeeded for a dummy key stored in a named + session keyring. Linking that keyring from the user keyring kept the same + named ring and dummy key available to a later process. Skillet now uses that + arrangement for the three-hour cache; the KeePass-backed end-to-end flow has + not yet been repeated with this implementation. diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index dd01e247..c0a0a78a 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -22,11 +22,15 @@ and resolve any Syncthing conflict copies before retrying. Backups include the encrypted database, with recovery passwords and any key files retained separately. Syncthing replication alone is not versioned backup. -The workstation prompts for the database password once and caches it in its -Linux user keyring for three hours (10,800 seconds), without refreshing the -expiry on reads. If the kernel denies expiry, Skillet removes the new key and -continues without a cache. `skillet secret lock` removes a cached key early. -A new workstation can unlock the copied database without the old one's keyring. +The workstation prompts for the database password once and caches it in a +named Linux session keyring for three hours (10,800 seconds), without +refreshing the expiry on reads. Skillet creates and joins this keyring itself, +and links it from the per-user keyring so it survives separate CLI invocations. +The password key remains in the session keyring, where the process has the +permissions needed to set its expiry. If the kernel denies expiry, Skillet +removes the new key and continues without a cache. `skillet secret lock` +removes a cached key early. A new workstation can unlock the copied database +without the old one's keyring. The existing `cloudflare-token-creator` keyring item still needs migration. Vault paths are group paths plus an entry title; values use the Password field. diff --git a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md index b85fcf40..ff4a4cee 100644 --- a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -18,9 +18,11 @@ Workspace checks, dummy credential delivery, repeated apply, rotation, reboot, and VM smoke passed; see [the acceptance record](../butane/ACCEPTANCE.md). Cloudflare creator migration, TPM assessment, interrupted delivery recovery, and an OS rebase -after delivery remain open. This workstation denied kernel key timeouts during -the 2026-09-28 smoke, so the three-hour cache still needs verification from a -normal user terminal; Skillet safely continued uncached. +after delivery remain open. The first smoke exposed that the workstation denied +expiry on keys stored directly in the user keyring. Skillet now uses a named +session keyring, retained across CLI invocations through a link from the user +keyring; the permission and cross-invocation behavior were verified with a +dummy key on 2026-09-28. ## Split into two small steps diff --git a/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs b/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs index 2bb4fefc..024d1aa6 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs @@ -1,9 +1,12 @@ use anyhow::{Context, Result}; -use keyutils::{keytypes::User, Keyring, SpecialKeyring}; +use keyutils::{keytypes::User, Keyring, Permission, SpecialKeyring}; use sha2::{Digest, Sha256}; -use std::{path::Path, time::Duration}; +use std::{path::Path, sync::Mutex, time::Duration}; const CACHE_LIFETIME: Duration = Duration::from_hours(3); +const SESSION_KEYRING_NAME: &str = "skillet-vault-cache"; + +static SESSION_KEYRING: Mutex> = Mutex::new(None); fn description(database: &Path) -> String { let digest = Sha256::digest(database.as_os_str().as_encoded_bytes()); @@ -11,10 +14,41 @@ fn description(database: &Path) -> String { } fn keyring() -> Result { - // The per-user keyring survives separate CLI processes. Each password key - // has its own timeout and can be removed without affecting other keys. - Keyring::attach_or_create(SpecialKeyring::User) - .context("opening the Linux user keyring for the vault cache") + // Keys in the user keyring are not possessed by this process on every + // workstation, so setting their expiry can be denied. A named session + // keyring gives the process possession permissions. Link it from the user + // keyring so it remains available to later Skillet invocations after this + // process exits. + let mut cached = SESSION_KEYRING + .lock() + .map_err(|_| anyhow::anyhow!("vault session keyring lock was poisoned"))?; + if let Some(ring) = cached.as_ref() { + return Ok(ring.clone()); + } + + let current = Keyring::attach_or_create(SpecialKeyring::Session) + .ok() + .and_then(|ring| { + ring.description() + .ok() + .filter(|description| description.description == SESSION_KEYRING_NAME) + .map(|_| ring) + }); + let mut ring = match current { + Some(ring) => ring, + None => Keyring::join_session(SESSION_KEYRING_NAME) + .context("opening the named Linux session keyring for the vault cache")?, + }; + // Joining an existing named session keyring requires owner search access. + // The user keyring link below keeps the named ring alive between CLI runs. + ring.set_permissions(Permission::POSSESSOR_ALL | Permission::USER_SEARCH) + .context("setting access on the named vault session keyring")?; + let mut user = Keyring::attach_or_create(SpecialKeyring::User) + .context("opening the Linux user keyring for the vault cache")?; + user.link_keyring(&ring) + .context("keeping the vault session keyring available across invocations")?; + *cached = Some(ring.clone()); + Ok(ring) } pub(super) fn read(database: &Path) -> Result> { @@ -31,9 +65,14 @@ pub(super) fn read(database: &Path) -> Result> { } pub(super) fn store(database: &Path, password: &str) -> Result<()> { - let Ok(mut ring) = keyring() else { - eprintln!("Vault password was not cached: Linux user keyring unavailable"); - return Ok(()); + let mut ring = match keyring() { + Ok(ring) => ring, + Err(error) => { + eprintln!( + "Vault password was not cached: Linux session keyring unavailable: {error:#}" + ); + return Ok(()); + } }; let Ok(mut key) = ring.add_key::(description(database), password.as_bytes()) else { eprintln!("Vault password was not cached: could not add a kernel key"); @@ -57,3 +96,23 @@ pub(super) fn clear(database: &Path) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{clear, read, store}; + + #[test] + fn named_session_cache_expires_and_clears() { + let database = tempfile::tempdir().expect("temporary database path"); + let database = database.path().join("secrets.kdbx"); + + store(&database, "dummy-vault-password").expect("cache password"); + assert_eq!( + read(&database).expect("read cache").as_deref(), + Some("dummy-vault-password") + ); + + clear(&database).expect("clear cache"); + assert_eq!(read(&database).expect("read cleared cache"), None); + } +} From 76f0f35bb71d5153cb8d29a717efadc1ed006caa Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 28 Sep 2026 18:24:12 +0200 Subject: [PATCH 087/252] docs(plan): record clamps rebase and recovery checks --- ublue/butane/ACCEPTANCE.md | 26 ++++++++++++++++++++++++ ublue/plan/CLAMPS-MIGRATION.md | 2 +- ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md | 17 +++++++++------- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 28fe2a32..77431803 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -216,3 +216,29 @@ host tool; it still uses normal command approvals. named ring and dummy key available to a later process. Skillet now uses that arrangement for the three-hour cache; the KeePass-backed end-to-end flow has not yet been repeated with this implementation. + +## Post-delivery OS update/rebase and credential recovery, 2026-09-28 + +- On `clamps-test-credential-e2e`, the signed `latest` ref had a newer image + available (digest `5d11f1003ffa1cb2a528c2e6943fa505dad86c2d3e5bf87b5330978111f3e0d2`). + `rpm-ostree upgrade` staged it, and `test-vm clamps ready credential-e2e` + passed after reboot. The encrypted Pi-hole credential remained present and + decryptable; full apply returned success, the Podman secret was present, and + Pi-hole was active. +- An explicit signed rebase to that same image digest also staged and booted. + Readiness passed again; credential decryption and full apply succeeded, and + Pi-hole returned active. A same-tag rebase had been a no-op because the refs + were equal, so the newer signed image was staged first. +- To exercise delivery recovery, a runtime-only systemd drop-in temporarily + forced `skillet-full-apply.service` to fail. Skillet `test vm provision + clamps credential-e2e --rotate` returned the expected activation error after + atomically installing a new encrypted credential. `systemd-creds decrypt` + validated it; the existing Podman secret and running Pi-hole were retained. + After removing the drop-in, normal `test vm provision clamps credential-e2e` + reused the installed credential, full apply succeeded, the Podman secret ID + changed, and Pi-hole remained active. The runtime drop-in was removed and + systemd reloaded. +- This tests recovery after guest-side atomic credential installation when + activation fails. A disconnect during the input stream or encryption is + still untested. The software-only VM also reports that its systemd host key + is not on encrypted media; production TPM binding remains open. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index c456f45f..4fa91757 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -56,7 +56,7 @@ Resolve only the decisions needed for Pi-hole first. `/var/lib/data` passed fresh-VM acceptance. Merge the shared Butane storage include when adding each future host template. - Extend the implemented direct KeePassXC read and SSH delivery to other named credentials as services need them. Disposable Pi-hole passwords are generated in VM tests. -- Verify interruption recovery after credential delivery and credential survival across an additional OS rebase. Host-key encryption, atomic replacement, rotation, repeat apply and reboot passed in the VM. Confirm production TPM binding against actual hardware. +- A signed image update and explicit digest-pinned rebase preserved the encrypted credential; post-rebase readiness and full apply passed. Recovery after atomic credential installation followed by failed full-apply activation also passed. A transport interruption during streaming/encryption and production TPM binding remain open. Atomic replacement, rotation, repeated apply and reboot passed in the VM. - Account for Podman's on-disk secret copy in the production disk-protection decision. Done when: Pi-hole's storage and password can be provisioned, recovered after interruption, and rotated without leaking values into repository files, command arguments or logs. Reboot/rebase recovery works. The persistent-data mount failure case is exercised. diff --git a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md index ff4a4cee..ce63f6bd 100644 --- a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -16,13 +16,16 @@ delivery through the host Rust binary, a full-apply unit, and Pi-hole secret file wiring are implemented. Workspace checks, dummy credential delivery, repeated apply, rotation, reboot, and VM smoke passed; -see [the acceptance record](../butane/ACCEPTANCE.md). Cloudflare creator -migration, TPM assessment, interrupted delivery recovery, and an OS rebase -after delivery remain open. The first smoke exposed that the workstation denied -expiry on keys stored directly in the user keyring. Skillet now uses a named -session keyring, retained across CLI invocations through a link from the user -keyring; the permission and cross-invocation behavior were verified with a -dummy key on 2026-09-28. +see [the acceptance record](../butane/ACCEPTANCE.md). The first smoke exposed +that the workstation denied expiry on keys +stored directly in the user keyring. Skillet now uses a named session keyring, +retained across CLI invocations through a link from the user keyring; its +permission and cross-invocation behavior were verified with a dummy key on +2026-09-28. Credential persistence passed an updated signed image deployment +and explicit digest-pinned rebase. Recovery after atomic credential install and +full-apply failure passed; a disconnect during streaming or encryption remains +untested. Production TPM assessment and Cloudflare creator migration remain +open. ## Split into two small steps From 28850140af2ecdbba619a7bf0ab62df737ca2141 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 28 Sep 2026 19:40:42 +0200 Subject: [PATCH 088/252] fix(ci): run container checks with base phase --- .github/workflows/ci.yml | 2 +- ublue/AGENTS.md | 14 ++++++++++++++ ublue/skillet/README.md | 12 +++++++----- ublue/skillet/crates/cli/src/main.rs | 19 ++++++++++++++----- .../skillet/crates/cli/src/test_entrypoint.sh | 3 ++- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ed90dd6..42c5355a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,4 +89,4 @@ jobs: # Build binary explicitly for the test runner to find it # (default target is x86_64-unknown-linux-musl, see .cargo/config.toml) cargo build - ./target/x86_64-unknown-linux-musl/debug/skillet test run beezelbot --image fedora:latest + ./target/x86_64-unknown-linux-musl/debug/skillet test run beezelbot --phase base --image fedora:latest diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index e33f0b61..a73eaa06 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -22,3 +22,17 @@ - Remove completed or superseded plans after preserving any unfinished work in an active plan. Retain implementation evidence in the relevant acceptance record; do not label unverified checks as passed. + +## Local validation before commit + +- Before committing, run the CI checks that apply to the changed paths locally + and resolve failures first. For Skillet changes, run `cargo fmt --check`, + `cargo clippy -- -D warnings`, `cargo test`, and the CI integration command + from `.github/workflows/ci.yml`. For shell changes, run the workflow's + ShellCheck commands; for Python or Chef changes, run the corresponding + Ruff/Mypy or Cookstyle commands. +- For changes to container behavior, also run the affected host's real-runtime + smoke test against a named disposable VM as required by `skillet/AGENTS.md`. +- If a required check cannot run because a tool, runtime, or external service + is unavailable, record the exact reason and report the check as unverified; + do not describe it as passing or commit as though it passed. diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index b7074f42..c856a0d5 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -45,11 +45,13 @@ to converge only the shared host baseline before credentials are available. ## Disposable VM smoke check -The legacy CI command `skillet test run beezelbot --image fedora:latest` runs -the host apply twice in a uniquely named, temporary Podman container. It -builds the selected host binary, checks both applies succeed, and checks the -second apply does not start or restart a service. `clamps` is also supported. -This sandbox uses mock `systemctl` and `podman` executables. +The CI command `skillet test run beezelbot --phase base --image fedora:latest` +runs the selected apply phase twice in a uniquely named, temporary Podman +container. It builds the selected host binary, checks both applies succeed, +and checks the second apply does not start or restart a service. `clamps` is +also supported. This sandbox uses mock `systemctl` and `podman` executables. +The phase defaults to `base`, since full apply may require a host's persistent +data mount and runtime credentials. `--inspect` opens the container before cleanup. For real systemd/Podman and reboot coverage, create a disposable VM first: diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index d8f0e997..3db34dca 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -82,7 +82,7 @@ struct SecretDeliverArgs { #[derive(clap::Subcommand, Debug)] enum TestCommands { - /// Apply configuration twice in a fresh disposable Podman container + /// Apply a selected configuration phase twice in a disposable Podman container Run(ContainerArgs), /// Exercise the real-systemd VM scenario using an explicit disposable SSH target Smoke(SmokeArgs), @@ -138,6 +138,9 @@ struct VmListArgs { #[derive(clap::Args, Debug)] struct ContainerArgs { hostname: String, + /// Configuration phase (defaults to base because full apply may require host mounts) + #[arg(long, value_enum, default_value = "base")] + phase: ApplyPhase, #[arg(long, default_value = "fedora:latest")] image: String, #[arg(long)] @@ -486,7 +489,7 @@ fn run_container_test(args: &ContainerArgs) -> Result<()> { .context("cleaning test credentials after Podman start failure failed")?; return Err(anyhow!("Podman failed to start {name}")); } - let result = run_twice_and_check(&name, args.inspect); + let result = run_twice_and_check(&name, args.phase, args.inspect); let cleanup = Command::new("podman") .args(["rm", "-f", &name]) .status() @@ -501,11 +504,15 @@ fn run_container_test(args: &ContainerArgs) -> Result<()> { Ok(()) } -fn run_twice_and_check(name: &str, inspect: bool) -> Result<()> { +fn run_twice_and_check(name: &str, phase: ApplyPhase, inspect: bool) -> Result<()> { let entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/test_entrypoint.sh"); + let phase = match phase { + ApplyPhase::Base => "base", + ApplyPhase::Full => "full", + }; for round in ["first", "second"] { let status = Command::new("podman") - .args(["exec", "-i", name, "/bin/sh", "-s", "--", round]) + .args(["exec", "-i", name, "/bin/sh", "-s", "--", round, phase]) .stdin(fs::File::open(&entry)?) .status() .context("running apply in container failed")?; @@ -562,12 +569,14 @@ mod tests { use clap::Parser; #[test] - fn test_run_accepts_legacy_command() { + fn test_run_accepts_explicit_base_phase() { let parsed = Args::try_parse_from([ "skillet", "test", "run", "beezelbot", + "--phase", + "base", "--image", "fedora:latest", ]); diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh index 8318ff1c..d3935aab 100644 --- a/ublue/skillet/crates/cli/src/test_entrypoint.sh +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -1,6 +1,7 @@ #!/bin/sh set -eu round=$1 +phase=$2 mock=/tmp/skillet-test-mocks state=/tmp/skillet-test-state mkdir -p "$mock" "$state" @@ -43,4 +44,4 @@ MOCK fi export PATH="$mock:$PATH" if [ "$round" = second ]; then printf '\nSECOND\n' >> /tmp/skillet-test.systemctl.log; fi -/usr/bin/skillet apply +/usr/bin/skillet apply --phase "$phase" From 60fa0b5d8282551e0a7b0912989e7104faaece8f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 28 Sep 2026 20:27:29 +0200 Subject: [PATCH 089/252] feat(pihole): add shared DNS network --- ublue/design/pihole-network.md | 29 +++++ ublue/plan/CLAMPS-MIGRATION.md | 14 ++- ublue/skillet/crates/cli-common/src/hosts.rs | 19 +++- .../crates/cli-common/src/hosts/fixture.rs | 1 + ublue/skillet/crates/pihole/Cargo.toml | 3 + ublue/skillet/crates/pihole/src/lib.rs | 23 +++- ublue/skillet/crates/pihole/src/tests.rs | 83 ++++++++++++++ ublue/skillet/crates/podman/src/lib.rs | 104 +++++++++++++++++- ublue/skillet/crates/podman/src/tests.rs | 85 ++++++++++++++ 9 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 ublue/design/pihole-network.md create mode 100644 ublue/skillet/crates/pihole/src/tests.rs diff --git a/ublue/design/pihole-network.md b/ublue/design/pihole-network.md new file mode 100644 index 00000000..cd7b6a71 --- /dev/null +++ b/ublue/design/pihole-network.md @@ -0,0 +1,29 @@ +# Pi-hole container network + +Run Pi-hole and future Caddy as separate containers on one Quadlet-managed +dual-stack bridge. Keep Podman's network DNS enabled and refer to Pi-hole by +its container name (`pihole`) from Caddy, so neither container needs a fixed +address and they keep independent lifecycles. Do not use a shared pod. + +Publish Pi-hole DNS on host IPv4 and IPv6 port 53 for both TCP and UDP, matching +rupik. Keep its web listener on port 8088 inside the bridge; Caddy will proxy +to `http://pihole:8088` without publishing the admin port on the host. Pi-hole +uses `dns_listeningMode=ALL` for bridge-network clients. Mark its fully +qualified registry image for Quadlet auto-update; the OS image already enables +the auto-update timer. + +Pi-hole's host port 53 conflicts with Netavark's default Aardvark DNS listener. +Skillet moves Aardvark to port 54 through Podman's rootful `dns_bind_port`, +preserving service-name DNS while Pi-hole owns host port 53. This is global +Podman configuration and must be verified on the target uCore release. Keep the +bridge non-internal; alternate Aardvark ports have a known limitation on +internal networks. + +Podman does not update an existing network's settings from a changed Quadlet. +Skillet records the accepted network definition and refuses drift. Changing +subnets or other network options therefore requires an explicit network +recreation with its consumers stopped. + +References: [Podman network Quadlets](https://docs.podman.io/en/stable/markdown/podman-network.unit.5.html), +[Quadlet container networking](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html), +[Pi-hole Docker networking](https://docs.pi-hole.net/docker/). diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 4fa91757..a7e21007 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -17,9 +17,9 @@ Pi-hole container runs in the VM with data and Podman storage under `/var/lib/data`; service networking, live ACME, final disk selection, and post-provision rebase checks remain open. -Next: implement isolated Pi-hole service networking, following the +Next: validate isolated Pi-hole service networking on the disposable VM, following the [secret design](../design/secrets.md), [VM lifecycle](../design/smoke-vms.md), and -[storage design](../design/storage.md) and +[storage design](../design/storage.md), [Pi-hole network design](../design/pihole-network.md), and [storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The implemented VM layout uses a shared Btrfs filesystem with `/var/lib/data` as the data mount; the final physical disk remains open. @@ -63,14 +63,16 @@ Done when: Pi-hole's storage and password can be provisioned, recovered after in ## Milestone 4: working Pi-hole on an isolated test address -- Add the dual-stack network resource, explicit container attachment and auto-update configuration. -- Decide whether to retain the Chef web pod or publish Pi-hole ports independently. Keep its admin endpoint compatible with the planned Caddy proxy. -- Pi-hole password-file consumption and startup identity/permissions passed on the VM. Verify SELinux labels, DNS publication, upstreams, web port and custom-record format for the selected image version. +- Implemented the reusable Quadlet network resource. Pi-hole now has a dual-stack bridge, network DNS enabled, container name `pihole`, and registry auto-update; see the [network design](../design/pihole-network.md). +- VM apply found Aardvark DNS colliding with Pi-hole's host port 53. Skillet now configures rootful Aardvark DNS to listen on port 54. On the named uCore VM, Pi-hole starts, Aardvark resolves its dual-stack container addresses from another container, an HTTP request reaches `http://pihole:8088/admin/`, and Pi-hole answers DNS over UDP and TCP through host IPv4 and IPv6 addresses. `ss` confirms host publication on IPv4 and IPv6 for both transports. +- Replace the Chef web pod with separate containers on the shared bridge. Caddy can resolve `pihole` and proxy to port 8088 without static container IPs or a host-published admin port. +- The Chef DNS listener behavior is represented as wildcard IPv4/IPv6 TCP/UDP port 53 publication. Verify the test VM's host firewall and actual client reachability before considering this item accepted. +- Pi-hole password-file consumption and startup identity/permissions passed on the VM. Verify custom-record format for the selected image version and DNS reachability from a LAN client through the host firewall. - Replace placeholder DNS records with supplied configuration. Support multiple names per address. - Keep host bootstrap DNS independent of Pi-hole availability. Verify IPv4/IPv6 TCP and UDP DNS, web authentication, persistence, rotation, repeated apply and reboot. - Decide whether Nebula Sync is required and which instance is authoritative before enabling it. -Done when: clients can use the isolated clamps instance and all required Pi-hole configuration survives restarts. Production DNS remains on rupik. +Done when: clients can use the isolated clamps instance and all required Pi-hole configuration survives restarts. Production DNS remains on rupik. Network Quadlet unit tests pass, and the VM has confirmed service-name web access and host-port DNS publication; remaining network and persistence checks are listed above. ## Milestone 5: migrate one remaining service at a time diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index fc68f5bb..541afccf 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -10,7 +10,7 @@ use skillet_core::{ files::{FileError, FileResource}, system::{SystemError, SystemResource}, }; -use skillet_podman::{QuadletSecret, SecretTarget}; +use skillet_podman::{PodmanNetwork, QuadletSecret, SecretTarget}; use std::collections::BTreeMap; use thiserror::Error; @@ -90,6 +90,22 @@ pub const PIHOLE_WEB_PASSWORD_CREDENTIAL: &str = "pihole_web_password"; // cutover; these are still the old placeholder values. const CLAMPS_CUSTOM_DNS_RECORDS: &[(&str, &str)] = &[("192.168.1.100", "my.custom.domain")]; +fn clamps_service_network() -> PodmanNetwork { + PodmanNetwork { + unit_name: "clamps".to_string(), + options: vec![ + "DisableDNS=false".to_string(), + "Driver=bridge".to_string(), + "Gateway=172.26.26.1".to_string(), + "Gateway=fd59:4e23:2950:11f5::1".to_string(), + "IPv6=true".to_string(), + "NetworkName=clamps".to_string(), + "Subnet=172.26.26.0/24".to_string(), + "Subnet=fd59:4e23:2950:11f5::/64".to_string(), + ], + } +} + /// Apply the clamps host configuration (hardening + Pi-hole). /// /// The credential manager is constructed lazily here: hosts that need no @@ -145,6 +161,7 @@ pub fn apply_clamps( }, secrets, custom_records, + clamps_service_network(), )?; Ok(()) } diff --git a/ublue/skillet/crates/cli-common/src/hosts/fixture.rs b/ublue/skillet/crates/cli-common/src/hosts/fixture.rs index 32bdebf7..f0539a64 100644 --- a/ublue/skillet/crates/cli-common/src/hosts/fixture.rs +++ b/ublue/skillet/crates/cli-common/src/hosts/fixture.rs @@ -77,6 +77,7 @@ pub(super) fn apply( PodmanConfig { name: "skillet-smoke-fixture".to_string(), image: "docker.io/library/alpine:3.20".to_string(), + networks: Vec::new(), user: ContainerUser { container_uid: 0, container_gid: 0, diff --git a/ublue/skillet/crates/pihole/Cargo.toml b/ublue/skillet/crates/pihole/Cargo.toml index c33bc683..70783631 100644 --- a/ublue/skillet/crates/pihole/Cargo.toml +++ b/ublue/skillet/crates/pihole/Cargo.toml @@ -12,4 +12,7 @@ skillet_podman.workspace = true thiserror.workspace = true tracing.workspace = true askama.workspace = true + +[dev-dependencies] +skillet_core = { path = "../core", features = ["test-utils"] } users.workspace = true diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 33837688..93ebb4b3 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -1,7 +1,9 @@ use askama::Template; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; -use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, QuadletSecret, Volume}; +use skillet_podman::{ + self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, QuadletSecret, Volume, +}; use std::collections::BTreeMap; use std::path::Path; use thiserror::Error; @@ -39,6 +41,7 @@ pub fn apply( user_config: &PiholeUser, secrets: Vec, custom_records: BTreeMap, + network: PodmanNetwork, ) -> Result<(), PiholeError> where S: SystemResource + ?Sized, @@ -110,7 +113,18 @@ where extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); extra_config.insert( "Container".to_string(), - vec!["Environment=WEBPASSWORD_FILE=/run/secrets/pihole_web_password".to_string()], + vec![ + "AutoUpdate=registry".to_string(), + "ContainerName=pihole".to_string(), + "Environment=FTLCONF_dns_listeningMode=ALL".to_string(), + "Environment=FTLCONF_webserver_port=8088o,[::]:8088o".to_string(), + "Environment=TZ=Europe/Madrid".to_string(), + "Environment=WEBPASSWORD_FILE=/run/secrets/pihole_web_password".to_string(), + "PublishPort=[::]:53:53/tcp".to_string(), + "PublishPort=[::]:53:53/udp".to_string(), + "PublishPort=0.0.0.0:53:53/tcp".to_string(), + "PublishPort=0.0.0.0:53:53/udp".to_string(), + ], ); extra_config.insert( "Unit".to_string(), @@ -135,6 +149,7 @@ where PodmanConfig { name: "pihole".to_string(), image: "docker.io/pihole/pihole:latest".to_string(), + networks: vec![network], user, create_host_user: false, volumes, @@ -146,3 +161,7 @@ where Ok(()) } + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/pihole/src/tests.rs b/ublue/skillet/crates/pihole/src/tests.rs new file mode 100644 index 00000000..9b2c3242 --- /dev/null +++ b/ublue/skillet/crates/pihole/src/tests.rs @@ -0,0 +1,83 @@ +use crate::{apply, PiholeUser}; +use skillet_core::{ + system::SystemResource, + test_utils::{MockFiles, MockSystem}, +}; +use skillet_podman::{PodmanNetwork, QuadletSecret, SecretTarget}; +use std::collections::BTreeMap; + +#[test] +fn pihole_uses_dual_stack_dns_network_and_registry_updates() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system + .ensure_podman_secret("pihole_web_password", "dummy") + .unwrap(); + + apply( + &system, + &files, + &PiholeUser { + uid: None, + gid: None, + name: "pihole".to_string(), + group_name: "pihole".to_string(), + }, + vec![QuadletSecret { + secret_name: "pihole_web_password".to_string(), + target: SecretTarget::File { + target_path: "/run/secrets/pihole_web_password".to_string(), + mode: Some("0400".to_string()), + uid: None, + gid: None, + }, + }], + BTreeMap::new(), + PodmanNetwork { + unit_name: "clamps".to_string(), + options: vec![ + "DisableDNS=false".to_string(), + "Driver=bridge".to_string(), + "Gateway=172.26.26.1".to_string(), + "Gateway=fd59:4e23:2950:11f5::1".to_string(), + "IPv6=true".to_string(), + "NetworkName=clamps".to_string(), + "Subnet=172.26.26.0/24".to_string(), + "Subnet=fd59:4e23:2950:11f5::/64".to_string(), + ], + }, + ) + .unwrap(); + + let generated = files.files.lock().unwrap(); + let quadlet = String::from_utf8_lossy( + generated + .get("/etc/containers/systemd/pihole.container") + .unwrap(), + ); + for directive in [ + "AutoUpdate=registry", + "ContainerName=pihole", + "Network=clamps.network", + "Environment=FTLCONF_dns_listeningMode=ALL", + "Environment=FTLCONF_webserver_port=8088o,[::]:8088o", + "Environment=TZ=Europe/Madrid", + "PublishPort=[::]:53:53/tcp", + "PublishPort=[::]:53:53/udp", + "PublishPort=0.0.0.0:53:53/tcp", + "PublishPort=0.0.0.0:53:53/udp", + ] { + assert!( + quadlet.contains(directive), + "missing Quadlet directive: {directive}" + ); + } + assert!(!quadlet.contains("Pod=")); + let network = String::from_utf8_lossy( + generated + .get("/etc/containers/systemd/clamps.network") + .unwrap(), + ); + assert!(network.contains("DisableDNS=false")); + assert!(network.contains("IPv6=true")); +} diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 6baff8ef..ad67c0a3 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -17,6 +17,10 @@ pub enum PodmanError { File(#[from] FileError), #[error("User mapping error: {0}")] UserMapping(String), + #[error("Invalid Podman network unit name: {0}")] + InvalidNetworkName(String), + #[error("Network configuration changed for {0}; stop its consumers, remove the Podman network and its applied marker, then apply again")] + NetworkConfigChanged(String), } #[derive(Template)] @@ -93,6 +97,7 @@ impl QuadletSecret { pub struct PodmanConfig { pub name: String, pub image: String, + pub networks: Vec, pub user: ContainerUser, pub create_host_user: bool, pub volumes: Vec, @@ -102,6 +107,15 @@ pub struct PodmanConfig { pub extra_config: BTreeMap>, } +/// A Quadlet-managed Podman network shared by containers needing +/// container-to-container traffic or DNS service discovery. +pub struct PodmanNetwork { + /// The `.network` Quadlet unit name, without its extension. + pub unit_name: String, + /// Options written to the unit's `[Network]` section. + pub options: Vec, +} + pub fn container(system: &S, files: &F, config: PodmanConfig) -> Result where S: SystemResource + ?Sized, @@ -111,6 +125,55 @@ where info!("Ensuring podman container: {name}"); let mut extra_config = config.extra_config; + let config_revisions = config.config_revisions; + let mut network_states = Vec::new(); + + // Pi-hole publishes port 53 on the host. Keep Netavark's bridge DNS + // listener off that port while retaining its container-name DNS service. + // This is a global Podman setting, so only manage it when using a + // DNS-enabled user-defined network. + if !config.networks.is_empty() { + let config_dir = Path::new("/etc/containers/containers.conf.d"); + files.ensure_directory(config_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_file( + &config_dir.join("90-skillet-aardvark.conf"), + b"[network]\ndns_bind_port=54\n", + Some(0o644), + Some("root"), + Some("root"), + )?; + } + + // A container's reference to the network Quadlet creates the systemd + // dependency that starts the network before the container. + for network in &config.networks { + let network_content = render_network(network)?; + let marker_path = + Path::new("/var/lib/skillet/networks").join(format!("{}.applied", network.unit_name)); + if let Some(applied) = files.read_file(&marker_path)? { + if applied != network_content.as_bytes() { + return Err(PodmanError::NetworkConfigChanged(network.unit_name.clone())); + } + } + + let quadlet_dir = Path::new("/etc/containers/systemd"); + files.ensure_directory(quadlet_dir, Some(0o755), Some("root"), Some("root"))?; + let network_unit_changed = files.ensure_file( + &quadlet_dir.join(format!("{}.network", network.unit_name)), + network_content.as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?; + if network_unit_changed { + system.daemon_reload()?; + } + network_states.push((marker_path, network_content.into_bytes())); + extra_config + .entry("Container".to_string()) + .or_default() + .push(format!("Network={}.network", network.unit_name)); + } // 1. Resolve and ensure host user let host_info = resolve_host_user(system, &config.user, config.create_host_user)?; @@ -163,12 +226,51 @@ where name, extra_config, &secret_ids, - &config.config_revisions, + &config_revisions, )?; + if !network_states.is_empty() { + let state_dir = Path::new("/var/lib/skillet/networks"); + files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; + for (marker_path, content) in network_states { + files.ensure_file( + &marker_path, + &content, + Some(0o644), + Some("root"), + Some("root"), + )?; + } + } + Ok(changed) } +fn render_network(network: &PodmanNetwork) -> Result { + let valid_name = !network.unit_name.is_empty() + && network + .unit_name + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && network + .unit_name + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_.-".contains(character)); + if !valid_name { + return Err(PodmanError::InvalidNetworkName(network.unit_name.clone())); + } + + let mut options = network.options.clone(); + options.sort(); + let mut content = String::from("[Network]\n"); + for option in options { + content.push_str(&option); + content.push('\n'); + } + Ok(content) +} + fn resolve_host_user( system: &S, user: &ContainerUser, diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index 37f38837..21dd9581 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -11,6 +11,7 @@ fn fixture() -> PodmanConfig { PodmanConfig { name: "unit-fixture".to_string(), image: "example.invalid/fixture:1".to_string(), + networks: Vec::new(), user: ContainerUser { container_uid: 0, container_gid: 0, @@ -32,6 +33,90 @@ fn fixture() -> PodmanConfig { } } +fn clamps_network() -> PodmanNetwork { + PodmanNetwork { + unit_name: "clamps".to_string(), + options: vec![ + "NetworkName=clamps".to_string(), + "IPv6=true".to_string(), + "DisableDNS=false".to_string(), + "Driver=bridge".to_string(), + ], + } +} + +#[test] +fn shared_network_quadlet_is_written_and_attached_idempotently() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + + let mut config = fixture(); + config.networks.push(clamps_network()); + container(&system, &files, config).unwrap(); + + let managed_files = files.files.lock().unwrap(); + let dns_config = managed_files + .get("/etc/containers/containers.conf.d/90-skillet-aardvark.conf") + .unwrap(); + assert_eq!(dns_config, b"[network]\ndns_bind_port=54\n"); + let network = managed_files + .get("/etc/containers/systemd/clamps.network") + .unwrap(); + let network = String::from_utf8_lossy(network); + assert!(network.contains("DisableDNS=false")); + assert!(network.contains("Driver=bridge")); + assert!(network.contains("IPv6=true")); + assert!(network.contains("NetworkName=clamps")); + + let quadlet = managed_files + .get("/etc/containers/systemd/unit-fixture.container") + .unwrap(); + assert!(String::from_utf8_lossy(quadlet).contains("Network=clamps.network")); + drop(managed_files); + + let mut repeated = fixture(); + repeated.networks.push(clamps_network()); + container(&system, &files, repeated).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); +} + +#[test] +fn changing_a_created_network_fails_before_replacing_its_quadlet() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + + let mut initial = fixture(); + initial.networks.push(clamps_network()); + container(&system, &files, initial).unwrap(); + + let original = files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/clamps.network") + .cloned() + .unwrap(); + let mut changed = clamps_network(); + changed.options.push("Internal=true".to_string()); + let mut config = fixture(); + config.networks.push(changed); + assert!(matches!( + container(&system, &files, config), + Err(PodmanError::NetworkConfigChanged(name)) if name == "clamps" + )); + assert_eq!( + files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/clamps.network"), + Some(&original) + ); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); +} + #[test] fn repeat_apply_and_stopped_service() { let system = MockSystem::new(); From 10a407db6f283b110b493299744f27404a778894 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 29 Sep 2026 00:27:33 +0200 Subject: [PATCH 090/252] feat(syncthing): provision clamps service --- ublue/butane/ACCEPTANCE.md | 17 +++ ublue/design/storage.md | 4 +- ublue/design/syncthing.md | 21 ++++ ublue/skillet/Cargo.lock | 11 ++ ublue/skillet/Cargo.toml | 2 + ublue/skillet/crates/cli-common/Cargo.toml | 1 + ublue/skillet/crates/cli-common/src/hosts.rs | 15 +++ ublue/skillet/crates/syncthing/Cargo.toml | 16 +++ ublue/skillet/crates/syncthing/src/lib.rs | 109 +++++++++++++++++++ ublue/skillet/crates/syncthing/src/tests.rs | 52 +++++++++ 10 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 ublue/design/syncthing.md create mode 100644 ublue/skillet/crates/syncthing/Cargo.toml create mode 100644 ublue/skillet/crates/syncthing/src/lib.rs create mode 100644 ublue/skillet/crates/syncthing/src/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 77431803..27dd1e00 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -242,3 +242,20 @@ host tool; it still uses normal command approvals. activation fails. A disconnect during the input stream or encryption is still untested. The software-only VM also reports that its systemd host key is not on encrypted media; production TPM binding remains open. + +## Syncthing service, 2026-09-28 + +- On the named disposable `clamps-test-credential-e2e` VM, a full apply created + `/var/lib/data/syncthing` as a Btrfs subvolume and started the separate + `syncthing` Quadlet container on the shared clamps network. Container-name + DNS resolved both bridge addresses, and `http://syncthing:8384` returned + HTTP 200 from another container. +- The VM showed the Chef-equivalent host publications for GUI port 8384 and + transfer port 22000 on IPv4 and IPv6. A repeated full apply preserved the + container ID and configuration hash. Its empty data tree generated a VM-only + device identity; no production Syncthing identity was copied or run. +- These are the previous session's runtime results. On the current workstation, + `test-vm clamps list` reports this domain as `missing`, so no fresh VM + rerun was made before committing. Real peer connectivity and production + state transfer remain cutover work. The GUI publication must be removed or + locally restricted before tailnet access can serve as its sole gate. diff --git a/ublue/design/storage.md b/ublue/design/storage.md index b5e7da4d..1bbe4318 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -9,8 +9,8 @@ subvolume must not be copied from boxy. The physical disk for clamps is still undecided. `/var/lib/data` is the host's canonical service-state root. Create child -subvolumes only for independently managed data: initially `pihole` and -`containers`. Pi-hole's container `/etc/pihole` maps to +subvolumes only for independently managed data: initially `pihole`, +`syncthing`, and `containers`. Pi-hole's container `/etc/pihole` maps to `/var/lib/data/pihole/etc`; any retained dnsmasq configuration and logs live under that Pi-hole tree. Rootful Podman's graphroot is `/var/lib/data/containers/system` on fresh installs, with runroot under diff --git a/ublue/design/syncthing.md b/ublue/design/syncthing.md new file mode 100644 index 00000000..bef763fb --- /dev/null +++ b/ublue/design/syncthing.md @@ -0,0 +1,21 @@ +# Syncthing service + +Run Syncthing as its own Quadlet container on the shared clamps bridge, with +`ContainerName=syncthing`. Caddy can reach its UI at +`http://syncthing:8384`. The current implementation retains rupik's host port +publications for the UI and transfer protocol. Before using tailnet identity as +the UI access gate, remove or locally restrict the GUI publication; preserve +the peer-transfer ports. See the [private UI design](private-ui-access.md). + +Mount `/var/lib/data/syncthing` at `/var/syncthing`. Keep the upstream +directory layout there, including `config/` and synced folders, so a planned +state transfer can retain the device identity and folder definitions. The +directory is a Btrfs subvolume owned by the existing `giacomo` account (UID/GID +1000), matching Chef. Never copy rupik's live identity into a disposable VM. + +The bridged container can limit LAN discovery compared with host networking. +This follows the existing Chef port-published container and keeps service-name +DNS available; validate real peer connectivity before cutover. + +References: [official Syncthing container guidance](https://github.com/syncthing/syncthing/blob/main/README-Docker.md), +[shared storage design](storage.md), [Pi-hole network design](pihole-network.md). diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 6db4eac4..2a613f98 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1560,6 +1560,7 @@ dependencies = [ "skillet_hardening", "skillet_pihole", "skillet_podman", + "skillet_syncthing", "tempfile", "thiserror 1.0.69", "tracing", @@ -1621,6 +1622,16 @@ dependencies = [ "users", ] +[[package]] +name = "skillet_syncthing" +version = "0.1.0" +dependencies = [ + "skillet_core", + "skillet_podman", + "thiserror 1.0.69", + "tracing", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 61aa2e0a..ae0ad096 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/core", "crates/hardening", "crates/pihole", + "crates/syncthing", "crates/podman", "crates/cli", "crates/hosts/beezelbot", @@ -15,6 +16,7 @@ members = [ skillet_core = { path = "crates/core" } skillet_hardening = { path = "crates/hardening" } skillet_pihole = { path = "crates/pihole" } +skillet_syncthing = { path = "crates/syncthing" } skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } thiserror = "1.0" diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index 307d16bb..19a745e4 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -10,6 +10,7 @@ workspace = true skillet_core.workspace = true skillet_hardening.workspace = true skillet_pihole.workspace = true +skillet_syncthing.workspace = true skillet_podman.workspace = true users.workspace = true clap.workspace = true diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 541afccf..c6dca4a0 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -32,6 +32,8 @@ pub enum ApplyError { Hardening(String), #[error("Pihole apply error: {0}")] Pihole(#[from] skillet_pihole::PiholeError), + #[error("Syncthing apply error: {0}")] + Syncthing(#[from] skillet_syncthing::SyncthingError), #[error("Podman error: {0}")] Podman(#[from] skillet_podman::PodmanError), #[error("Fixture input error: {0}")] @@ -163,6 +165,19 @@ pub fn apply_clamps( custom_records, clamps_service_network(), )?; + + skillet_syncthing::apply( + system, + files, + skillet_syncthing::SyncthingConfig { + data_path: "/var/lib/data/syncthing".to_string(), + data_owner: "giacomo".to_string(), + data_group: "giacomo".to_string(), + uid: 1000, + gid: 1000, + network: clamps_service_network(), + }, + )?; Ok(()) } diff --git a/ublue/skillet/crates/syncthing/Cargo.toml b/ublue/skillet/crates/syncthing/Cargo.toml new file mode 100644 index 00000000..57f45fab --- /dev/null +++ b/ublue/skillet/crates/syncthing/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "skillet_syncthing" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs new file mode 100644 index 00000000..77028839 --- /dev/null +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -0,0 +1,109 @@ +use skillet_core::files::{FileError, FileResource}; +use skillet_core::system::{SystemError, SystemResource}; +use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, Volume}; +use std::collections::BTreeMap; +use std::path::Path; +use thiserror::Error; +use tracing::info; + +#[derive(Error, Debug)] +pub enum SyncthingError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), +} + +pub struct SyncthingConfig { + pub data_path: String, + pub data_owner: String, + pub data_group: String, + pub uid: u32, + pub gid: u32, + pub network: PodmanNetwork, +} + +pub fn apply(system: &S, files: &F, config: SyncthingConfig) -> Result<(), SyncthingError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Applying Syncthing configuration..."); + let data_path = Path::new(&config.data_path); + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + files.ensure_btrfs_subvolume(data_path)?; + files.ensure_directory( + data_path, + Some(0o755), + Some(&config.data_owner), + Some(&config.data_group), + )?; + + let volumes = vec![Volume { + host_path: config.data_path, + container_path: "/var/syncthing".to_string(), + options: Some("z".to_string()), + }]; + + let mut extra_config = BTreeMap::new(); + extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); + extra_config.insert( + "Container".to_string(), + vec![ + "AutoUpdate=registry".to_string(), + "ContainerName=syncthing".to_string(), + format!("Environment=PGID={}", config.gid), + format!("Environment=PUID={}", config.uid), + "PublishPort=[::]:8384:8384/tcp".to_string(), + "PublishPort=0.0.0.0:8384:8384/tcp".to_string(), + "PublishPort=[::]:22000:22000/tcp".to_string(), + "PublishPort=0.0.0.0:22000:22000/tcp".to_string(), + "PublishPort=[::]:22000:22000/udp".to_string(), + "PublishPort=0.0.0.0:22000:22000/udp".to_string(), + ], + ); + extra_config.insert( + "Unit".to_string(), + vec![ + "Description=Syncthing file synchronization".to_string(), + "After=network-online.target".to_string(), + "Requires=skillet-data-prepare.service".to_string(), + "After=skillet-data-prepare.service".to_string(), + "BindsTo=var-lib-data.mount".to_string(), + "After=var-lib-data.mount".to_string(), + "AssertPathIsMountPoint=/var/lib/data".to_string(), + ], + ); + extra_config.insert( + "Install".to_string(), + vec!["WantedBy=multi-user.target default.target".to_string()], + ); + + skillet_podman::container( + system, + files, + PodmanConfig { + name: "syncthing".to_string(), + image: "docker.io/syncthing/syncthing:latest".to_string(), + networks: vec![config.network], + user: ContainerUser { + container_uid: 0, + container_gid: 0, + host_user: None, + }, + create_host_user: false, + volumes, + secrets: Vec::new(), + config_revisions: Vec::new(), + extra_config, + }, + )?; + + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/syncthing/src/tests.rs b/ublue/skillet/crates/syncthing/src/tests.rs new file mode 100644 index 00000000..ed39b5da --- /dev/null +++ b/ublue/skillet/crates/syncthing/src/tests.rs @@ -0,0 +1,52 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; + +#[test] +fn syncthing_uses_persistent_data_and_shared_dns_network() { + let system = MockSystem::new(); + let files = MockFiles::new(); + + apply( + &system, + &files, + SyncthingConfig { + data_path: "/var/lib/data/syncthing".to_string(), + data_owner: "giacomo".to_string(), + data_group: "giacomo".to_string(), + uid: 1000, + gid: 1000, + network: PodmanNetwork { + unit_name: "clamps".to_string(), + options: vec![ + "DisableDNS=false".to_string(), + "Driver=bridge".to_string(), + "NetworkName=clamps".to_string(), + "Subnet=172.26.26.0/24".to_string(), + ], + }, + }, + ) + .unwrap(); + + let managed_files = files.files.lock().unwrap(); + let quadlet = managed_files + .get("/etc/containers/systemd/syncthing.container") + .unwrap(); + let quadlet = String::from_utf8_lossy(quadlet); + for directive in [ + "AutoUpdate=registry", + "ContainerName=syncthing", + "Environment=PGID=1000", + "Environment=PUID=1000", + "Network=clamps.network", + "PublishPort=[::]:8384:8384/tcp", + "PublishPort=0.0.0.0:8384:8384/tcp", + "PublishPort=[::]:22000:22000/tcp", + "PublishPort=0.0.0.0:22000:22000/tcp", + "PublishPort=[::]:22000:22000/udp", + "PublishPort=0.0.0.0:22000:22000/udp", + "Volume=/var/lib/data/syncthing:/var/syncthing:z", + ] { + assert!(quadlet.contains(directive), "missing directive {directive}"); + } +} From b35bb8df917132f26b76c21abd9a67040435ee60 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 29 Sep 2026 00:27:50 +0200 Subject: [PATCH 091/252] docs(access): choose private tailnet UI routing --- ublue/design/pihole-network.md | 10 ++++- ublue/design/private-ui-access.md | 66 +++++++++++++++++++++++++++++++ ublue/plan/CLAMPS-MIGRATION.md | 24 +++++++---- 3 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 ublue/design/private-ui-access.md diff --git a/ublue/design/pihole-network.md b/ublue/design/pihole-network.md index cd7b6a71..20fa5902 100644 --- a/ublue/design/pihole-network.md +++ b/ublue/design/pihole-network.md @@ -10,7 +10,15 @@ rupik. Keep its web listener on port 8088 inside the bridge; Caddy will proxy to `http://pihole:8088` without publishing the admin port on the host. Pi-hole uses `dns_listeningMode=ALL` for bridge-network clients. Mark its fully qualified registry image for Quadlet auto-update; the OS image already enables -the auto-update timer. +the auto-update timer. The planned private UI route and tailnet DNS dependency +are described in the [private UI design](private-ui-access.md). + +The planned second Pi-hole on beelzebot beside clamps is a separate DNS +instance on the same power and internet connection. A possible third on remote +bender adds site diversity. All instances offered to the same clients must +serve consistent local records and filtering policy; the synchronization +mechanism and LAN client resolver settings remain to be decided before those +hosts are deployed. Pi-hole's host port 53 conflicts with Netavark's default Aardvark DNS listener. Skillet moves Aardvark to port 54 through Podman's rootful `dns_bind_port`, diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md new file mode 100644 index 00000000..e7fa73cb --- /dev/null +++ b/ublue/design/private-ui-access.md @@ -0,0 +1,66 @@ +# Private web UI access + +## Decision + +Use a private hostname per administrative UI, with Caddy terminating HTTPS and +proxying to the named container on the shared Podman bridge. Pi-hole and +Syncthing are the first targets. Their UIs are reachable only through the +tailnet; neither needs a public listener or an OAuth2 proxy. Keep Pi-hole's LAN +DNS listener and Syncthing's peer-transfer ports separate from their UIs. + +Use names under a domain we control, rather than `*.ts.net`, so the UI URLs and +Caddy routes survive a move between hosted Tailscale and Headscale. Caddy gets +publicly trusted certificates through Cloudflare DNS-01 using the existing +secret-delivery design. DNS-01 does not require publicly reachable UI endpoints. + +Hosted Tailscale MagicDNS cannot store arbitrary records. Configure split DNS +for the private UI zone, forwarding to the fleet's Pi-hole resolvers. Each +resolver must return the same host-specific UI records, pointing to the +relevant host's tailnet address. Headscale can instead supply the same names +through its `dns.extra_records`. Moving control planes requires updating the +tailnet addresses and DNS configuration, but not application URLs or Caddy +routes. + +Plan a second Pi-hole on beelzebot beside clamps. They cover individual host +failure but share power and internet failure. A third Pi-hole on bender, +the calculon replacement in the other country, would add a separate site. +Advertise multiple resolvers for the hosted Tailscale split DNS zone only after +their records and reachability agree; clients may query them in any order, so +none is a fixed primary. A healthy resolver can resolve an offline host's name +but cannot make its UI available. Give each Pi-hole UI its own host-specific +name; do not promise a floating service name without health-aware routing. + +Restrict Caddy's UI listener to tailnet traffic and grant access only to the +intended user identities. Tailnet access authorizes the connection; it does not +create an application login. Keep application passwords until the private path +and absence of direct UI access have been verified, then decide per application +whether a second login is useful. In particular, Syncthing's current wildcard +host publication of GUI port 8384 must be removed or limited to a safe local +proxy path before relying on tailnet access alone. + +Pi-hole is a dependency for resolving private UI names under hosted Tailscale; +multiple independent resolvers reduce that dependency. Retain access to each +host by its tailnet device name or address, and SSH access for DNS recovery; +these must not depend on Pi-hole. Do not expose an unauthenticated UI as the +recovery path. + +## Why + +Separate hostnames avoid applications' subpath problems. Tailnet identity and +grants keep admin UIs private without maintaining a public OAuth2 proxy. Caddy +and our own domain names work with either control plane: Tailscale Services +currently requires Tailscale's hosted control plane, while Headscale offers +extra DNS records but not Tailscale Services. + +## Status and references + +This is the planned access design; Caddy, tailnet DNS, resolver redundancy, and +UI isolation have not yet been implemented or accepted on clamps. The current +Pi-hole UI is private to the container bridge; Syncthing's GUI is still +published on the host. + +References: [Tailscale DNS](https://tailscale.com/docs/reference/dns-in-tailscale/), +[Headscale DNS](https://headscale.net/stable/ref/dns/), +[Headscale Services feature gap](https://github.com/juanfont/headscale/issues/2845), +[Let's Encrypt DNS-01](https://letsencrypt.org/docs/challenge-types/), +[Caddy DNS challenge](https://caddyserver.com/docs/caddyfile/directives/tls). diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index a7e21007..c17963b8 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -17,11 +17,14 @@ Pi-hole container runs in the VM with data and Podman storage under `/var/lib/data`; service networking, live ACME, final disk selection, and post-provision rebase checks remain open. -Next: validate isolated Pi-hole service networking on the disposable VM, following the -[secret design](../design/secrets.md), [VM lifecycle](../design/smoke-vms.md), and -[storage design](../design/storage.md), [Pi-hole network design](../design/pihole-network.md), and -[storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The implemented VM -layout uses a shared Btrfs filesystem with `/var/lib/data` as the data mount; +Next: btrbk is the next service in the sequence when work resumes. Syncthing +passed the named disposable-VM service and repeat-apply checks. Pi-hole +LAN-client access and final custom DNS records remain open; production DNS +stays on rupik. See the [Syncthing design](../design/syncthing.md), [Pi-hole +network design](../design/pihole-network.md), [storage design](../design/storage.md), +[secret design](../design/secrets.md), [VM lifecycle](../design/smoke-vms.md), +and [storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The implemented +VM layout uses a shared Btrfs filesystem with `/var/lib/data` as the data mount; the final physical disk remains open. ## Boundaries @@ -80,16 +83,23 @@ Each row is a separate implementation and validation step. Adjust order for depe | Order | Capability | Behavior and state to preserve | | --- | --- | --- | -| 1 | Syncthing | Data, device identity, folder configuration, UID/GID, required ports | +| 1 | Syncthing (implemented; disposable VM service and repeat-apply checks passed) | Data, device identity, folder configuration, UID/GID, required ports | | 2 | btrbk | Real Btrfs subvolumes, hourly snapshots, Chef retention policy, restore exercise; local snapshots are not an independent backup | | 3 | UniFi | Controller data or supported backup restore, version compatibility, adoption and ownership | | 4 | Tailscale | Authentication/identity policy, exit-node approval, forwarding, persistent state, DNS policy and hardware interface settings | -| 5 | Caddy evaluation + OAuth2 proxy + ACME | Port required nginx domains/routes and access rules; validate Cloudflare DNS-01 with staging, per-VM tokens, certificate persistence and cleanup | +| 5 | Private UI access with Caddy + ACME | Implement the [private UI design](../design/private-ui-access.md): host-specific names, hosted Tailscale split DNS with consistent reachable Pi-hole resolvers, tailnet-only access, Syncthing GUI isolation, and DNS recovery; validate Cloudflare DNS-01 with staging, per-VM tokens, certificate persistence and cleanup. Add public OAuth2 proxying only for an explicitly required public UI. | | 6 | Cloudflare DDNS | Required records and token delivery; reconcile the legacy updater before enabling competing writers | | 7 | Monitoring and remaining host baseline | Explicit keep/drop decision for Datadog; required hardening, users, SSH/sudo, ET and WOL behavior | For each service: inspect effective Chef inputs, settle its open decisions, add only needed Skillet support, migrate a copy of state, validate functionality and repeat convergence. Account for ARM-to-x86 application/image and data compatibility. Avoid activating duplicate production identities or DNS writers during testing. +Syncthing validation used an empty disposable data subvolume, creating a VM-only +device identity. `syncthing` resolved to both bridge addresses, its UI returned +HTTP 200 by container name, all Chef-published dual-stack ports were present, +and a repeated full apply preserved its container ID and config hash. Testing +connectivity with a real peer and migrating rupik's identity/data remain +cutover tasks; never run both copies of the production device identity at once. + Legacy NAT rules and the separate updater live in a recipe that is not included by rupik's default recipe. Confirm whether they are active or still required before porting them. Argon One support is Pi-specific and excluded. ## Milestone 6: cut over with a recovery path From 802880a8267ca3266ed8e807b6414d38d6b9201c Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 29 Sep 2026 00:28:42 +0200 Subject: [PATCH 092/252] docs(plan): hand off clamps btrbk milestone --- ublue/design/btrbk.md | 19 ++++++++++ ublue/plan/CLAMPS-BTRBK.md | 65 ++++++++++++++++++++++++++++++++++ ublue/plan/CLAMPS-MIGRATION.md | 4 +-- 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 ublue/design/btrbk.md create mode 100644 ublue/plan/CLAMPS-BTRBK.md diff --git a/ublue/design/btrbk.md b/ublue/design/btrbk.md new file mode 100644 index 00000000..84accffc --- /dev/null +++ b/ublue/design/btrbk.md @@ -0,0 +1,19 @@ +# Local Btrfs snapshots + +Plan to run btrbk from the OS image with configuration and timer state managed +by reusable Skillet code. The first source on clamps is the independent +`/var/lib/data/syncthing` subvolume. Keep rupik's hourly schedule and retention +(`6h` minimum; `24h 31d 6m` schedule) with snapshots under +`/var/lib/data/snapshots/syncthing`. Do not snapshot `/var/lib/data` as a parent: +nested Btrfs subvolume contents, including Syncthing and Podman storage, would +not be captured. Exclude `containers`; decide Pi-hole snapshot policy separately. + +Guard both convergence and the timer against a missing or wrong data mount. +Run one systemd timer instead of preserving Chef's cron job. This keeps +snapshots on the intended filesystem and avoids duplicate runs. Local +snapshots share the source disk's failure domain and are not an independent +backup. Verify recovery by restoring a disposable snapshot to a separate path +before relying on the schedule. + +Status: planned. See the [implementation milestone](../plan/CLAMPS-BTRBK.md) +and [shared storage design](storage.md). diff --git a/ublue/plan/CLAMPS-BTRBK.md b/ublue/plan/CLAMPS-BTRBK.md new file mode 100644 index 00000000..f60fb496 --- /dev/null +++ b/ublue/plan/CLAMPS-BTRBK.md @@ -0,0 +1,65 @@ +# Next milestone: btrbk snapshots on clamps + +Status: ready to implement after the Syncthing slice. Scope is local snapshot +parity for rupik; remote backup and Pi-hole snapshot policy are separate work. +The [snapshot design](../design/btrbk.md) records the chosen scope and why. + +## Starting point + +- Rupik's [Chef recipe](../../site-cookbooks/rupik/recipes/btrbk.rb) runs btrbk + hourly against `/srv/sync`, with `timestamp_format long`, + `snapshot_preserve_min 6h` and + `snapshot_preserve 24h 31d 6m`. +- Clamps now stores Syncthing in the real Btrfs subvolume + `/var/lib/data/syncthing`; `/var/lib/data` is mounted from `/data`. The + `containers` subvolume must not be captured by a parent-directory snapshot. +- Fedora [packages btrbk](https://packages.fedoraproject.org/pkgs/btrbk/btrbk/) + with systemd units. Verify the package and timer on the actual uCore base. + Upstream documents the [volume/subvolume layout and dry run](https://github.com/digint/btrbk#example-local-regular-snapshots). + +## Implementation sequence + +1. In `ucore-images`, add `btrbk` to the shared OS recipe + `recipes/ucore-common.yml` if absent from the base. Build the common image + before the host images as their CI requires. Do not install packages at + runtime or copy Chef's source-build recipe. +2. Add a reusable Skillet btrbk module. Guard the verified `/var/lib/data` + mount before creating anything. Ensure `/var/lib/data/snapshots/syncthing` + exists, and manage `/etc/btrbk/btrbk.conf` idempotently. Configure + `volume /var/lib/data`, `snapshot_dir snapshots/syncthing`, and + `subvolume syncthing`, with rupik's timestamp and retention values. Do not snapshot + `containers` or the parent `data` subvolume. Keep Pi-hole out of this first + slice because rupik did not snapshot it. +3. Enable exactly one hourly systemd timer, using the packaged unit if its + schedule matches. Otherwise manage a unit/drop-in with an hourly schedule. + The service must verify the actual `/data` Btrfs mount on every run, using + the same checks as `butane/includes/data-storage.bu`; a mountpoint assertion + alone does not reject a wrong filesystem. Do not enable a second packaged + timer or create a cron job. Apply the shared module from clamps' host + convergence only after Syncthing storage exists. +4. Add isolated module tests for rendered config, repeat apply, and missing or + wrong data mount. Keep tests in a separate `tests.rs`; use Rust resources + rather than embedded shell. +5. Rebuild/rebase a named disposable clamps VM onto the image with btrbk, + provision dummy credentials, and validate the real systemd timer and Btrfs + subvolume. Use `ublue/butane/bin/test-vm` and its README; the previous VM + manifests on this workstation currently report `missing`. + +## Exit criteria + +- Workspace format, pedantic Clippy, unit tests, and the CI container command + pass. The named VM smoke runs with real systemd and Podman. +- `btrbk -n -v run` targets only the intended Syncthing subvolume; a real run + creates a read-only snapshot under the intended directory. Repeated Skillet + apply preserves the config, timer, and existing snapshots. The timer remains + active after reboot and runs at the intended hourly cadence. +- Put a disposable sentinel in the VM's Syncthing data, snapshot it, change + the live file, and restore the snapshot into a **separate temporary path**. + Verify the original bytes without overwriting the live Syncthing tree. +- With `/var/lib/data` absent or replaced by the wrong mount, convergence and + the timer fail closed without writing snapshots to the underlying `/var`. + Record commands, guest state, and limitations in `butane/ACCEPTANCE.md`. + +Local snapshots share the source disk's failure domain; this milestone does not +claim independent backup. Before cutover, inspect whether any real Syncthing +folders are nested Btrfs subvolumes and list each such source explicitly. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index c17963b8..36e521b8 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -17,7 +17,7 @@ Pi-hole container runs in the VM with data and Podman storage under `/var/lib/data`; service networking, live ACME, final disk selection, and post-provision rebase checks remain open. -Next: btrbk is the next service in the sequence when work resumes. Syncthing +Next: implement the [btrbk milestone](CLAMPS-BTRBK.md). Syncthing passed the named disposable-VM service and repeat-apply checks. Pi-hole LAN-client access and final custom DNS records remain open; production DNS stays on rupik. See the [Syncthing design](../design/syncthing.md), [Pi-hole @@ -84,7 +84,7 @@ Each row is a separate implementation and validation step. Adjust order for depe | Order | Capability | Behavior and state to preserve | | --- | --- | --- | | 1 | Syncthing (implemented; disposable VM service and repeat-apply checks passed) | Data, device identity, folder configuration, UID/GID, required ports | -| 2 | btrbk | Real Btrfs subvolumes, hourly snapshots, Chef retention policy, restore exercise; local snapshots are not an independent backup | +| 2 | btrbk | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | | 3 | UniFi | Controller data or supported backup restore, version compatibility, adoption and ownership | | 4 | Tailscale | Authentication/identity policy, exit-node approval, forwarding, persistent state, DNS policy and hardware interface settings | | 5 | Private UI access with Caddy + ACME | Implement the [private UI design](../design/private-ui-access.md): host-specific names, hosted Tailscale split DNS with consistent reachable Pi-hole resolvers, tailnet-only access, Syncthing GUI isolation, and DNS recovery; validate Cloudflare DNS-01 with staging, per-VM tokens, certificate persistence and cleanup. Add public OAuth2 proxying only for an explicitly required public UI. | From 3e3d9ed23c26811dee7a25741321d7857fdeb1cd Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 29 Sep 2026 11:57:53 +0200 Subject: [PATCH 093/252] feat(btrbk): add host-configurable snapshot convergence --- ublue/design/btrbk.md | 26 ++-- ublue/plan/CLAMPS-BTRBK.md | 42 ++--- ublue/skillet/Cargo.lock | 9 ++ ublue/skillet/Cargo.toml | 2 + ublue/skillet/crates/btrbk/Cargo.toml | 14 ++ ublue/skillet/crates/btrbk/src/lib.rs | 152 +++++++++++++++++++ ublue/skillet/crates/btrbk/src/tests.rs | 116 ++++++++++++++ ublue/skillet/crates/cli-common/Cargo.toml | 1 + ublue/skillet/crates/cli-common/src/hosts.rs | 9 ++ ublue/skillet/crates/core/src/files.rs | 13 ++ ublue/skillet/crates/core/src/recorder.rs | 4 + ublue/skillet/crates/core/src/system.rs | 6 +- ublue/skillet/crates/core/src/test_utils.rs | 21 ++- 13 files changed, 380 insertions(+), 35 deletions(-) create mode 100644 ublue/skillet/crates/btrbk/Cargo.toml create mode 100644 ublue/skillet/crates/btrbk/src/lib.rs create mode 100644 ublue/skillet/crates/btrbk/src/tests.rs diff --git a/ublue/design/btrbk.md b/ublue/design/btrbk.md index 84accffc..bf29084d 100644 --- a/ublue/design/btrbk.md +++ b/ublue/design/btrbk.md @@ -1,12 +1,18 @@ # Local Btrfs snapshots -Plan to run btrbk from the OS image with configuration and timer state managed -by reusable Skillet code. The first source on clamps is the independent -`/var/lib/data/syncthing` subvolume. Keep rupik's hourly schedule and retention -(`6h` minimum; `24h 31d 6m` schedule) with snapshots under -`/var/lib/data/snapshots/syncthing`. Do not snapshot `/var/lib/data` as a parent: -nested Btrfs subvolume contents, including Syncthing and Podman storage, would -not be captured. Exclude `containers`; decide Pi-hole snapshot policy separately. +Install btrbk in the shared uCore image, mask its packaged daily timer, and +manage configuration and the hourly timer through a reusable Skillet module. +Each host opts in by passing explicit +subvolume paths relative to `/var/lib/data`; an empty list opts out. Clamps +selects only `syncthing`, stored at `/var/lib/data/syncthing`, with snapshots +under `/var/lib/data/snapshots/syncthing`. The module rejects absolute paths, +traversal, and the data root, and requires each selected source to be an existing +Btrfs subvolume. It never defaults to snapshotting the parent or `containers`. + +Keep rupik's hourly schedule and retention (`6h` minimum; `24h 31d 6m`). The +service reruns the shared data preparation validation before calling btrbk, so +it fails closed if the intended `/data` Btrfs mount is absent or wrong. Pi-hole +snapshot policy remains separate. Guard both convergence and the timer against a missing or wrong data mount. Run one systemd timer instead of preserving Chef's cron job. This keeps @@ -15,5 +21,7 @@ snapshots share the source disk's failure domain and are not an independent backup. Verify recovery by restoring a disposable snapshot to a separate path before relying on the schedule. -Status: planned. See the [implementation milestone](../plan/CLAMPS-BTRBK.md) -and [shared storage design](storage.md). +Status: Skillet module and clamps opt-in are implemented, and btrbk is in the +shared image recipe with its packaged daily timer masked. Real-VM +snapshot/restore verification remains. See the [implementation +milestone](../plan/CLAMPS-BTRBK.md) and [shared storage design](storage.md). diff --git a/ublue/plan/CLAMPS-BTRBK.md b/ublue/plan/CLAMPS-BTRBK.md index f60fb496..db4e7331 100644 --- a/ublue/plan/CLAMPS-BTRBK.md +++ b/ublue/plan/CLAMPS-BTRBK.md @@ -1,7 +1,9 @@ # Next milestone: btrbk snapshots on clamps -Status: ready to implement after the Syncthing slice. Scope is local snapshot -parity for rupik; remote backup and Pi-hole snapshot policy are separate work. +Status: implementation in progress. The Skillet module, clamps opt-in, and +shared image package/timer policy are implemented; real-VM verification +remains. Scope is local snapshot parity for rupik; remote backup and Pi-hole +snapshot policy are separate work. The [snapshot design](../design/btrbk.md) records the chosen scope and why. ## Starting point @@ -19,27 +21,26 @@ The [snapshot design](../design/btrbk.md) records the chosen scope and why. ## Implementation sequence -1. In `ucore-images`, add `btrbk` to the shared OS recipe - `recipes/ucore-common.yml` if absent from the base. Build the common image - before the host images as their CI requires. Do not install packages at - runtime or copy Chef's source-build recipe. -2. Add a reusable Skillet btrbk module. Guard the verified `/var/lib/data` - mount before creating anything. Ensure `/var/lib/data/snapshots/syncthing` - exists, and manage `/etc/btrbk/btrbk.conf` idempotently. Configure - `volume /var/lib/data`, `snapshot_dir snapshots/syncthing`, and - `subvolume syncthing`, with rupik's timestamp and retention values. Do not snapshot - `containers` or the parent `data` subvolume. Keep Pi-hole out of this first - slice because rupik did not snapshot it. -3. Enable exactly one hourly systemd timer, using the packaged unit if its - schedule matches. Otherwise manage a unit/drop-in with an hourly schedule. +1. Done in `ucore-images`: add `btrbk` to the shared OS recipe + `recipes/ucore-common.yml` and mask its packaged daily timer. Build the + common image before the host images as their CI requires. Do not install + packages at runtime or copy Chef's source-build recipe. +2. Done in Skillet: add a reusable btrbk module and make hosts opt in with + explicit paths relative to `/var/lib/data`. Clamps passes only `syncthing`; + no host implicitly snapshots the root data subvolume or Podman containers. + The module requires the expected data mount and an existing source + subvolume, then manages retention/config and its timer idempotently. +3. Implemented: mask the package's daily timer in the shared image and manage + one hourly Skillet systemd timer. The service runs the shared data mount + validation before btrbk, and is bound to the mount. The service must verify the actual `/data` Btrfs mount on every run, using the same checks as `butane/includes/data-storage.bu`; a mountpoint assertion alone does not reject a wrong filesystem. Do not enable a second packaged timer or create a cron job. Apply the shared module from clamps' host convergence only after Syncthing storage exists. -4. Add isolated module tests for rendered config, repeat apply, and missing or - wrong data mount. Keep tests in a separate `tests.rs`; use Rust resources - rather than embedded shell. +4. Done: isolated unit tests cover caller-selected rendering, source path + safety, opt-out, repeat apply, and fail-closed behavior for a wrong data + mount. 5. Rebuild/rebase a named disposable clamps VM onto the image with btrbk, provision dummy credentials, and validate the real systemd timer and Btrfs subvolume. Use `ublue/butane/bin/test-vm` and its README; the previous VM @@ -47,8 +48,9 @@ The [snapshot design](../design/btrbk.md) records the chosen scope and why. ## Exit criteria -- Workspace format, pedantic Clippy, unit tests, and the CI container command - pass. The named VM smoke runs with real systemd and Podman. +- Shared `ucore-images` recipe contains btrbk. Workspace format, pedantic + Clippy, unit tests, and the CI container command pass. The named VM smoke runs + with real systemd and Podman. - `btrbk -n -v run` targets only the intended Syncthing subvolume; a real run creates a read-only snapshot under the intended directory. Repeated Skillet apply preserves the config, timer, and existing snapshots. The timer remains diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 2a613f98..216e91c5 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1549,6 +1549,14 @@ dependencies = [ "skillet_podman", ] +[[package]] +name = "skillet_btrbk" +version = "0.1.0" +dependencies = [ + "skillet_core", + "thiserror 1.0.69", +] + [[package]] name = "skillet_cli_common" version = "0.1.0" @@ -1556,6 +1564,7 @@ dependencies = [ "clap", "serde", "serde_yml", + "skillet_btrbk", "skillet_core", "skillet_hardening", "skillet_pihole", diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index ae0ad096..aba225c8 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/hardening", "crates/pihole", "crates/syncthing", + "crates/btrbk", "crates/podman", "crates/cli", "crates/hosts/beezelbot", @@ -17,6 +18,7 @@ skillet_core = { path = "crates/core" } skillet_hardening = { path = "crates/hardening" } skillet_pihole = { path = "crates/pihole" } skillet_syncthing = { path = "crates/syncthing" } +skillet_btrbk = { path = "crates/btrbk" } skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } thiserror = "1.0" diff --git a/ublue/skillet/crates/btrbk/Cargo.toml b/ublue/skillet/crates/btrbk/Cargo.toml new file mode 100644 index 00000000..43c901a9 --- /dev/null +++ b/ublue/skillet/crates/btrbk/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "skillet_btrbk" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/btrbk/src/lib.rs b/ublue/skillet/crates/btrbk/src/lib.rs new file mode 100644 index 00000000..f75d3198 --- /dev/null +++ b/ublue/skillet/crates/btrbk/src/lib.rs @@ -0,0 +1,152 @@ +use skillet_core::{ + files::{FileError, FileResource}, + system::{SystemError, SystemResource}, +}; +use std::path::{Component, Path, PathBuf}; +use thiserror::Error; + +const DATA_MOUNT: &str = "/var/lib/data"; +const CONFIG_PATH: &str = "/etc/btrbk/btrbk.conf"; +const SERVICE_PATH: &str = "/etc/systemd/system/skillet-btrbk.service"; +const TIMER_PATH: &str = "/etc/systemd/system/skillet-btrbk.timer"; + +#[derive(Debug, Error)] +pub enum BtrbkError { + #[error("File error: {0}")] + File(#[from] FileError), + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("Invalid Btrfs subvolume path '{0}': use safe relative path components beneath /var/lib/data")] + InvalidSubvolume(String), +} + +/// Caller-selected paths relative to `/var/lib/data`. The parent data +/// subvolume is deliberately not a default source. +pub struct BtrbkConfig { + pub snapshot_subvolumes: Vec, +} + +pub fn apply(system: &S, files: &F, config: &BtrbkConfig) -> Result<(), BtrbkError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + if config.snapshot_subvolumes.is_empty() { + return Ok(()); + } + + files.require_btrfs_subvolume_mount(Path::new(DATA_MOUNT), Path::new("/var"), "/data")?; + + let mut sources = Vec::with_capacity(config.snapshot_subvolumes.len()); + for relative in &config.snapshot_subvolumes { + validate_relative_subvolume(relative)?; + let source = Path::new(DATA_MOUNT).join(relative); + files.require_btrfs_subvolume(&source)?; + let snapshot_dir = Path::new(DATA_MOUNT).join("snapshots").join(relative); + files.ensure_directory(&snapshot_dir, Some(0o755), None, None)?; + sources.push(( + relative, + snapshot_dir + .strip_prefix(DATA_MOUNT) + .map_err(|_| BtrbkError::InvalidSubvolume(relative.display().to_string()))? + .to_path_buf(), + )); + } + + files.ensure_directory( + Path::new("/etc/btrbk"), + Some(0o755), + Some("root"), + Some("root"), + )?; + let config_changed = files.ensure_file( + Path::new(CONFIG_PATH), + render_config(&sources).as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?; + let service_changed = files.ensure_file( + Path::new(SERVICE_PATH), + service_unit().as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?; + let timer_changed = files.ensure_file( + Path::new(TIMER_PATH), + timer_unit().as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?; + + if config_changed || service_changed || timer_changed { + system.daemon_reload()?; + } + system.service_enable("skillet-btrbk.timer")?; + if !system.service_is_active("skillet-btrbk.timer")? { + system.service_start("skillet-btrbk.timer")?; + } + Ok(()) +} + +fn validate_relative_subvolume(path: &Path) -> Result<(), BtrbkError> { + if path.as_os_str().is_empty() + || path.components().any(|component| match component { + Component::Normal(value) => value.to_str().is_none_or(|part| { + part.is_empty() + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + }), + _ => true, + }) + { + return Err(BtrbkError::InvalidSubvolume(path.display().to_string())); + } + Ok(()) +} + +fn render_config(sources: &[(&PathBuf, PathBuf)]) -> String { + let mut output = String::from( + "timestamp_format long\nsnapshot_preserve_min 6h\nsnapshot_preserve 24h 31d 6m\n\ + volume /var/lib/data\n", + ); + for (source, snapshot_dir) in sources { + output.push_str(" subvolume "); + output.push_str(&source.display().to_string()); + output.push_str("\n snapshot_dir "); + output.push_str(&snapshot_dir.display().to_string()); + output.push('\n'); + } + output +} + +fn service_unit() -> &'static str { + "[Unit]\n\ + Description=Create configured Btrfs snapshots with btrbk\n\ + Requires=var-lib-data.mount\n\ + After=var-lib-data.mount\n\ + BindsTo=var-lib-data.mount\n\ + AssertPathIsMountPoint=/var/lib/data\n\ + [Service]\n\ + Type=oneshot\n\ + ExecStartPre=/var/usrlocal/bin/skillet-data-prepare\n\ + ExecStart=/usr/bin/btrbk --config /etc/btrbk/btrbk.conf run\n" +} + +fn timer_unit() -> &'static str { + "[Unit]\n\ + Description=Hourly configured Btrfs snapshots\n\ + [Timer]\n\ + OnCalendar=hourly\n\ + Persistent=true\n\ + Unit=skillet-btrbk.service\n\ + [Install]\n\ + WantedBy=timers.target\n" +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/btrbk/src/tests.rs b/ublue/skillet/crates/btrbk/src/tests.rs new file mode 100644 index 00000000..e63d516d --- /dev/null +++ b/ublue/skillet/crates/btrbk/src/tests.rs @@ -0,0 +1,116 @@ +use super::{apply, BtrbkConfig, BtrbkError, CONFIG_PATH, SERVICE_PATH, TIMER_PATH}; +use skillet_core::files::FileResource; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::path::PathBuf; + +#[test] +fn renders_only_caller_selected_subvolume() { + let files = MockFiles::new(); + let system = MockSystem::new(); + apply( + &system, + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ) + .unwrap(); + let config = read(&files, CONFIG_PATH); + assert!(config.contains("subvolume syncthing")); + assert!(config.contains("snapshot_dir snapshots/syncthing")); + assert!(!config.contains("subvolume containers")); + assert!(!config.contains("subvolume data")); + assert!(read(&files, SERVICE_PATH).contains("skillet-data-prepare")); + assert!(read(&files, TIMER_PATH).contains("OnCalendar=hourly")); +} + +#[test] +fn rejects_parent_absolute_and_traversal_paths() { + for invalid in [ + "/var/lib/data", + "..", + "nested/../escape", + ".", + "with space", + "bad\nvalue", + ] { + let error = apply( + &MockSystem::new(), + &MockFiles::new(), + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from(invalid)], + }, + ) + .expect_err("unsafe source should be rejected"); + assert!(matches!(error, BtrbkError::InvalidSubvolume(_))); + } +} + +#[test] +fn empty_config_is_an_opt_out() { + let files = MockFiles::new(); + let system = MockSystem::new(); + apply( + &system, + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![], + }, + ) + .unwrap(); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +#[test] +fn wrong_data_mount_fails_before_any_state_is_written() { + let files = MockFiles::new(); + files + .fail_btrfs_mount_check + .store(true, std::sync::atomic::Ordering::SeqCst); + let result = apply( + &MockSystem::new(), + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ); + assert!(result.is_err()); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +fn read(files: &MockFiles, path: &str) -> String { + String::from_utf8( + files + .read_file(std::path::Path::new(path)) + .unwrap() + .unwrap(), + ) + .unwrap() +} + +#[test] +fn repeated_apply_keeps_managed_state_and_timer_running() { + let files = MockFiles::new(); + let system = MockSystem::new(); + let config = BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }; + apply(&system, &files, &config).unwrap(); + apply(&system, &files, &config).unwrap(); + assert_eq!( + system.start_count.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + assert_eq!( + system + .restart_count + .load(std::sync::atomic::Ordering::SeqCst), + 0 + ); +} diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index 19a745e4..a19656ea 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -11,6 +11,7 @@ skillet_core.workspace = true skillet_hardening.workspace = true skillet_pihole.workspace = true skillet_syncthing.workspace = true +skillet_btrbk.workspace = true skillet_podman.workspace = true users.workspace = true clap.workspace = true diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index c6dca4a0..88f88b9c 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -34,6 +34,8 @@ pub enum ApplyError { Pihole(#[from] skillet_pihole::PiholeError), #[error("Syncthing apply error: {0}")] Syncthing(#[from] skillet_syncthing::SyncthingError), + #[error("Btrbk apply error: {0}")] + Btrbk(#[from] skillet_btrbk::BtrbkError), #[error("Podman error: {0}")] Podman(#[from] skillet_podman::PodmanError), #[error("Fixture input error: {0}")] @@ -178,6 +180,13 @@ pub fn apply_clamps( network: clamps_service_network(), }, )?; + skillet_btrbk::apply( + system, + files, + &skillet_btrbk::BtrbkConfig { + snapshot_subvolumes: vec![std::path::PathBuf::from("syncthing")], + }, + )?; Ok(()) } diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 9727d4c4..94f1047a 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -70,6 +70,7 @@ pub trait FileResource { backing_mount: &Path, subvolume_root: &str, ) -> Result<(), FileError>; + fn require_btrfs_subvolume(&self, path: &Path) -> Result<(), FileError>; fn ensure_btrfs_subvolume(&self, path: &Path) -> Result; } @@ -211,6 +212,18 @@ impl FileResource for LocalFileResource { require_btrfs_mount_in(&mountinfo, path, backing_mount, subvolume_root) } + fn require_btrfs_subvolume(&self, path: &Path) -> Result<(), FileError> { + let output = Command::new("btrfs") + .args(["subvolume", "show"]) + .arg(path) + .output()?; + if output.status.success() { + Ok(()) + } else { + Err(FileError::NotASubvolume(path.display().to_string())) + } + } + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { let path_text = path.display().to_string(); if Command::new("btrfs") diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index b32f3984..ae2985a4 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -52,6 +52,10 @@ impl FileResource for Recorder { .require_btrfs_subvolume_mount(path, backing_mount, subvolume_root) } + fn require_btrfs_subvolume(&self, path: &Path) -> Result<(), FileError> { + self.inner.require_btrfs_subvolume(path) + } + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { self.record(ResourceOp::EnsureBtrfsSubvolume { path: path.display().to_string(), diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 200156da..cded1462 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -96,8 +96,7 @@ impl LinuxSystemResource { /// Run a systemctl action via `DBus` (preferred) or the CLI (fallback). /// - /// CLI start/restart wait for the startup job to complete by default. - /// `--wait` would additionally wait for service termination. + /// Start/restart operations wait for systemd to finish the requested job. fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { let name_with_suffix = ensure_systemd_suffix(name); @@ -141,6 +140,9 @@ impl LinuxSystemResource { fn run_systemctl_cli(action: &str, name_with_suffix: &str) -> Result<(), SystemError> { info!("Running systemctl {action} {name_with_suffix} via CLI"); let mut cmd = Command::new("systemctl"); + if matches!(action, "start" | "restart") { + cmd.arg("--wait"); + } cmd.arg(action); let output = cmd.arg(name_with_suffix).output()?; diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 152d8124..f80dbde7 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -148,10 +148,13 @@ impl SystemResource for MockSystem { } fn service_enable(&self, name: &str) -> Result<(), SystemError> { - self.services + let mut services = self + .services .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(name.to_string(), "enabled".to_string()); + .unwrap_or_else(std::sync::PoisonError::into_inner); + services + .entry(name.to_string()) + .or_insert_with(|| "enabled".to_string()); Ok(()) } @@ -173,6 +176,7 @@ pub struct MockFiles { pub files: Arc>>>, pub metadata: Arc>>, pub directories: Arc>>, + pub fail_btrfs_mount_check: Arc, } impl MockFiles { @@ -181,6 +185,7 @@ impl MockFiles { files: Arc::new(Mutex::new(HashMap::new())), metadata: Arc::new(Mutex::new(HashMap::new())), directories: Arc::new(Mutex::new(HashSet::new())), + fail_btrfs_mount_check: Arc::new(AtomicBool::new(false)), } } } @@ -194,10 +199,18 @@ impl Default for MockFiles { impl FileResource for MockFiles { fn require_btrfs_subvolume_mount( &self, - _path: &Path, + path: &Path, _backing_mount: &Path, _subvolume_root: &str, ) -> Result<(), FileError> { + if self.fail_btrfs_mount_check.load(Ordering::SeqCst) { + Err(FileError::WrongMount(path.display().to_string())) + } else { + Ok(()) + } + } + + fn require_btrfs_subvolume(&self, _path: &Path) -> Result<(), FileError> { Ok(()) } From 8a3488781e21c127f5485c03c2cc61b544c6422c Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 29 Sep 2026 14:19:04 +0200 Subject: [PATCH 094/252] fix(systemd): avoid waiting for unit termination --- ublue/butane/ACCEPTANCE.md | 50 ++++++++++++++++ ublue/butane/bin/test-vm-ready | 4 +- ublue/plan/CLAMPS-BTRBK.md | 60 +++++++++++++++---- ublue/skillet/AGENTS.md | 3 + ublue/skillet/README.md | 2 +- .../crates/cli-common/src/credential.rs | 6 +- .../skillet/crates/cli/src/secret_delivery.rs | 2 +- ublue/skillet/crates/core/src/system.rs | 9 ++- 8 files changed, 110 insertions(+), 26 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 27dd1e00..dcebb2aa 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -259,3 +259,53 @@ host tool; it still uses normal command approvals. rerun was made before committing. Real peer connectivity and production state transfer remain cutover work. The GUI publication must be removed or locally restricted before tailnet access can serve as its sole gate. + +## Btrbk image and VM attempt, 2026-09-29 + +- Image workflow [run 36552978960](https://github.com/gbagnoli/ucore-images/actions/runs/36552978960) + succeeded for `ucore-common` and all three host images. The published clamps + image digest is + `sha256:e6c0cd35641199ddb5e2d9ca0f281dc1d640126ae5b2a04fa87c65163cedfd7c`. +- The user created `clamps-test-smoke` with `./butane/bin/test-vm clamps + create smoke`. The guest booted the expected signed image and confirmed + `btrbk-0.32.7-1.fc44.noarch` is present, `btrbk.timer` is masked, and + `/var/lib/data` is Btrfs subvolume `/data` on the expected UUID. +- The first readiness attempt hung during baseline convergence. Guest + `systemd-sysctl.service` is `Type=oneshot`, `RemainAfterExit=yes`; passing + `--wait` made systemctl wait for this persistent unit to stop. The rule was + corrected: ordinary `systemctl start` and `restart` wait for the requested + job. Skillet, credential delivery, the VM readiness helper, and its usage + example now omit `--wait`. Rebuilt clamps completed base apply successfully. + +## Btrbk snapshot verification, 2026-09-29 + +- Dummy-credential provisioning completed full apply. Pi-hole and Syncthing + started, the `/var/lib/data/syncthing` Btrfs subvolume was present, and + `/etc/btrbk/btrbk.conf` contained only that source with the Chef-equivalent + hourly retention (`6h`, `24h 31d 6m`). `skillet-btrbk.timer` was active and + enabled while the packaged daily `btrbk.timer` remained masked. +- `btrbk -n -v run` showed only `/var/lib/data/syncthing` as its source and + `/var/lib/data/snapshots/syncthing/syncthing.` as its target. + A real run created read-only snapshot `syncthing.20260929T1402`. A disposable + sentinel was changed in the live tree, then restored from that snapshot into + `/var/tmp/skillet-btrbk-restore`; byte comparison passed. The temporary + restore subvolume and sentinel were deleted, while the btrbk snapshot was + retained. +- Repeated full apply preserved the btrbk config and timer hashes and retained + the existing snapshots. The timer fired at 14:00:30 and created + `syncthing.20260929T1400`. After a VM reboot, Pi-hole, Syncthing, and + `skillet-btrbk.timer` were active; the timer remained enabled, the package + timer remained masked, both snapshots remained, and the next hourly run was + scheduled for 15:00 CEST. +- `cargo run --release -p skillet -- test smoke clamps` passed the real-runtime + systemd/Podman fixture checks through reboot. Local validation passed: + formatting, 45 workspace tests, pedantic Clippy with warnings denied, the + CI-style container command, `bash -n` and ShellCheck for `test-vm-ready`, + and `git diff --check`. The CI-style command ran from `/tmp` because the + checked-out workspace mount is read-only to shell commands. +- Fail-closed behavior for missing and wrong `/var/lib/data` mounts was already + exercised against the shared storage configuration (see the shared Btrfs + data storage section above); btrbk unit tests also reject a wrong mount + before writing managed state. The disposable VM used an empty Syncthing + subvolume; real production folders and nested subvolumes still need a + pre-cutover inventory. diff --git a/ublue/butane/bin/test-vm-ready b/ublue/butane/bin/test-vm-ready index debcd78c..eb09d4f9 100755 --- a/ublue/butane/bin/test-vm-ready +++ b/ublue/butane/bin/test-vm-ready @@ -88,13 +88,13 @@ if ((SECONDS >= deadline)); then echo "Diagnostics: $run_dir/failure.log" >&2 exit 1 fi -if ! ssh_guest 'sudo -n systemctl reset-failed skillet-apply.service && sudo -n systemctl --wait restart skillet-apply.service'; then +if ! ssh_guest 'sudo -n systemctl reset-failed skillet-apply.service && sudo -n systemctl restart skillet-apply.service'; then ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true echo "Skillet base apply failed; diagnostics: $run_dir/failure.log" >&2 exit 1 fi echo "Waiting for Homebrew and the core dotfiles profile..." -if ! ssh_guest 'sudo -n systemctl reset-failed brew-install.service dotfiles-install.service && sudo -n systemctl --wait start brew-install.service && sudo -n systemctl --wait start dotfiles-install.service'; then +if ! ssh_guest 'sudo -n systemctl reset-failed brew-install.service dotfiles-install.service && sudo -n systemctl start brew-install.service && sudo -n systemctl start dotfiles-install.service'; then ssh_guest 'sudo -n journalctl -b -u brew-install.service -u dotfiles-install.service --no-pager -n 200' > "$run_dir/user-environment-failure.log" 2>&1 || true echo "User environment install failed; diagnostics: $run_dir/user-environment-failure.log" >&2 exit 1 diff --git a/ublue/plan/CLAMPS-BTRBK.md b/ublue/plan/CLAMPS-BTRBK.md index db4e7331..96dcf55a 100644 --- a/ublue/plan/CLAMPS-BTRBK.md +++ b/ublue/plan/CLAMPS-BTRBK.md @@ -1,9 +1,8 @@ # Next milestone: btrbk snapshots on clamps -Status: implementation in progress. The Skillet module, clamps opt-in, and -shared image package/timer policy are implemented; real-VM verification -remains. Scope is local snapshot parity for rupik; remote backup and Pi-hole -snapshot policy are separate work. +Status: implemented and verified on the retained disposable clamps VM. Scope is +local snapshot parity for rupik; remote backup and Pi-hole snapshot policy are +separate work. The [snapshot design](../design/btrbk.md) records the chosen scope and why. ## Starting point @@ -41,27 +40,62 @@ The [snapshot design](../design/btrbk.md) records the chosen scope and why. 4. Done: isolated unit tests cover caller-selected rendering, source path safety, opt-out, repeat apply, and fail-closed behavior for a wrong data mount. -5. Rebuild/rebase a named disposable clamps VM onto the image with btrbk, - provision dummy credentials, and validate the real systemd timer and Btrfs - subvolume. Use `ublue/butane/bin/test-vm` and its README; the previous VM - manifests on this workstation currently report `missing`. +5. Done: rebuilt a named disposable clamps VM from the image with btrbk, + provisioned dummy credentials, and validated the real systemd timer and + Btrfs subvolume. ## Exit criteria - Shared `ucore-images` recipe contains btrbk. Workspace format, pedantic - Clippy, unit tests, and the CI container command pass. The named VM smoke runs - with real systemd and Podman. + Clippy, unit tests, the CI container command, and the named VM smoke pass. + The VM uses real systemd and Podman. - `btrbk -n -v run` targets only the intended Syncthing subvolume; a real run creates a read-only snapshot under the intended directory. Repeated Skillet - apply preserves the config, timer, and existing snapshots. The timer remains - active after reboot and runs at the intended hourly cadence. + apply preserves the config, timer, and snapshots. After reboot the timer is + active and enabled, and its next run is scheduled at the next hourly boundary. - Put a disposable sentinel in the VM's Syncthing data, snapshot it, change the live file, and restore the snapshot into a **separate temporary path**. Verify the original bytes without overwriting the live Syncthing tree. - With `/var/lib/data` absent or replaced by the wrong mount, convergence and the timer fail closed without writing snapshots to the underlying `/var`. - Record commands, guest state, and limitations in `butane/ACCEPTANCE.md`. + The real wrong-mount and absent-mount checks are recorded in + `butane/ACCEPTANCE.md` under shared Btrfs data storage. Local snapshots share the source disk's failure domain; this milestone does not claim independent backup. Before cutover, inspect whether any real Syncthing folders are nested Btrfs subvolumes and list each such source explicitly. + +## Current verification + +- Image workflow [run 36552978960](https://github.com/gbagnoli/ucore-images/actions/runs/36552978960) + succeeded for the common image and all three host images. The published + `ucore-clamps:latest` digest is + `sha256:e6c0cd35641199ddb5e2d9ca0f281dc1d640126ae5b2a04fa87c65163cedfd7c`. +- The user created `clamps-test-smoke`; the current session can inspect its + native `qemu:///session` domain. It booted the signed clamps image at the + digest above. The guest confirms `btrbk-0.32.7-1.fc44.noarch` is installed, + the package's `btrbk.timer` is masked, and `/var/lib/data` is mounted from + Btrfs `/data` on the expected filesystem. +- The first readiness attempt exposed an incorrect inherited rule to pass + `--wait` to systemctl. Because that option waits for the unit to stop, + `systemd-sysctl.service` (`Type=oneshot`, `RemainAfterExit=yes`) hung. The + rule and all Skillet and VM helper callers were corrected to use ordinary + blocking `start`/`restart`; the updated binary completed base apply. +- Full apply created the Syncthing subvolume and configured the hourly timer. + `btrbk -n -v run` selected only `/var/lib/data/syncthing`. A real run created + `syncthing.20260929T1402`, reported `ro=true`, and a separate temporary + subvolume restored the original sentinel bytes after the live sentinel was + changed. The timer also ran at 14:00:30 and created + `syncthing.20260929T1400`. +- Repeated full apply retained config/timer hashes and both snapshots. After + reboot, Pi-hole and Syncthing were active, the Syncthing subvolume and both + snapshots remained, `skillet-btrbk.timer` was active/enabled, and the + packaged `btrbk.timer` remained masked; the next timer run was 15:00 CEST. + `test smoke clamps` passed its real-systemd/Podman cases through reboot. +- Local validation after the correction: `cargo fmt --check`, all 45 workspace + tests, pedantic Clippy with warnings denied, the CI-style container command + (`cargo run --release -p skillet -- test run clamps --phase base --image + fedora:latest`), ShellCheck and `bash -n` for `test-vm-ready`, and + `git diff --check` passed. The container command ran from a writable + temporary workspace copy because the checked-out workspace is mounted + read-only to shell commands. diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 3edada9e..c5d071de 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -15,6 +15,9 @@ This document defines the architectural mandates and project structure for `skil - All resources (files, users, groups, etc.) must be **idempotent**. - Before performing an action, check the current state (e.g., compare SHA256 hashes for files, check existence for users). - Actions should only be taken if the system state does not match the desired state. +- Use the normal blocking `systemctl start` or `restart` command and inspect its + result. Do not pass `--wait`: it waits for a started unit to stop and can hang + on long-running services or `RemainAfterExit=yes` oneshot units. ### 3. Testing Strategy - **Unit Tests**: Place unit tests in a `tests` submodule within each module's directory (e.g., `src/files/tests.rs`). diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index c856a0d5..59908fe1 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -161,7 +161,7 @@ cargo run --release -p skillet -- secret deliver clamps pihole \ Add `--key-file /path/to/keyfile` if the database uses one. The value is encrypted by the installed `skillet-clamps credential install` command on the host and loaded by `skillet-full-apply.service`; later -`systemctl start --wait skillet-full-apply.service` reuses it. If full apply +`systemctl start skillet-full-apply.service` reuses it. If full apply reports a missing or undecryptable credential, check the host's encrypted credential file and redeliver the KeePassXC entry. If the entry is absent in an existing vault and the host has no encrypted credential, Skillet creates diff --git a/ublue/skillet/crates/cli-common/src/credential.rs b/ublue/skillet/crates/cli-common/src/credential.rs index e5ccd560..d8122515 100644 --- a/ublue/skillet/crates/cli-common/src/credential.rs +++ b/ublue/skillet/crates/cli-common/src/credential.rs @@ -86,11 +86,9 @@ pub fn install(name: &str, unit: &str) -> Result<(), CredentialError> { } encrypted.persist(path).map_err(|error| error.error)?; fs::File::open(&directory)?.sync_all()?; - let status = Command::new("systemctl") - .args(["start", "--wait", unit]) - .status()?; + let status = Command::new("systemctl").args(["start", unit]).status()?; if !status.success() { - return Err(CredentialError::Command("systemctl start --wait")); + return Err(CredentialError::Command("systemctl start")); } Ok(()) } diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index fe63e6d2..3cb39889 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -210,7 +210,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { .arg(port.to_string()) .args([ "giacomo@127.0.0.1", - "sudo -n systemctl start --wait skillet-full-apply.service", + "sudo -n systemctl start skillet-full-apply.service", ]) .status() .context("running full apply")?; diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index cded1462..44b0c0e0 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -94,9 +94,11 @@ impl LinuxSystemResource { Self { conn } } - /// Run a systemctl action via `DBus` (preferred) or the CLI (fallback). + /// Run a systemctl action via `DBus` where suitable, otherwise via the CLI. /// - /// Start/restart operations wait for systemd to finish the requested job. + /// Start/restart operations use the CLI so the caller waits for the systemd + /// job result. Do not pass `--wait`: for a `RemainAfterExit` oneshot unit it + /// waits for the unit to stop rather than for its start job to complete. fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { let name_with_suffix = ensure_systemd_suffix(name); @@ -140,9 +142,6 @@ impl LinuxSystemResource { fn run_systemctl_cli(action: &str, name_with_suffix: &str) -> Result<(), SystemError> { info!("Running systemctl {action} {name_with_suffix} via CLI"); let mut cmd = Command::new("systemctl"); - if matches!(action, "start" | "restart") { - cmd.arg("--wait"); - } cmd.arg(action); let output = cmd.arg(name_with_suffix).output()?; From aba3f9106cb6331d536587891257c02a85b69ddf Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 29 Sep 2026 14:24:26 +0200 Subject: [PATCH 095/252] docs(btrbk): require leaf snapshot sources --- ublue/butane/ACCEPTANCE.md | 5 +++-- ublue/design/btrbk.md | 13 +++++++++---- ublue/design/storage.md | 8 +++++--- ublue/plan/CLAMPS-BTRBK.md | 4 ++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index dcebb2aa..3ccc5355 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -307,5 +307,6 @@ host tool; it still uses normal command approvals. exercised against the shared storage configuration (see the shared Btrfs data storage section above); btrbk unit tests also reject a wrong mount before writing managed state. The disposable VM used an empty Syncthing - subvolume; real production folders and nested subvolumes still need a - pre-cutover inventory. + subvolume. The user confirms Syncthing data is designed without nested + subvolumes; the btrbk design treats selected sources as leaf subvolumes and + does not support nested subvolumes. diff --git a/ublue/design/btrbk.md b/ublue/design/btrbk.md index bf29084d..cc8f9d52 100644 --- a/ublue/design/btrbk.md +++ b/ublue/design/btrbk.md @@ -9,6 +9,12 @@ under `/var/lib/data/snapshots/syncthing`. The module rejects absolute paths, traversal, and the data root, and requires each selected source to be an existing Btrfs subvolume. It never defaults to snapshotting the parent or `containers`. +Selected snapshot sources must be leaf subvolumes. Nested subvolumes inside a +selected source are unsupported: Btrfs snapshots do not include their contents. +Syncthing's data layout is designed without nested subvolumes, and this is an +invariant of the current policy rather than a recursively discovered source +list. A future need for nested subvolumes requires an explicit design change. + Keep rupik's hourly schedule and retention (`6h` minimum; `24h 31d 6m`). The service reruns the shared data preparation validation before calling btrbk, so it fails closed if the intended `/data` Btrfs mount is absent or wrong. Pi-hole @@ -21,7 +27,6 @@ snapshots share the source disk's failure domain and are not an independent backup. Verify recovery by restoring a disposable snapshot to a separate path before relying on the schedule. -Status: Skillet module and clamps opt-in are implemented, and btrbk is in the -shared image recipe with its packaged daily timer masked. Real-VM -snapshot/restore verification remains. See the [implementation -milestone](../plan/CLAMPS-BTRBK.md) and [shared storage design](storage.md). +Status: implementation and real-VM snapshot/restore verification are complete. +See the [implementation milestone](../plan/CLAMPS-BTRBK.md) and +[shared storage design](storage.md). diff --git a/ublue/design/storage.md b/ublue/design/storage.md index 1bbe4318..3fe9c1ce 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -38,9 +38,11 @@ mounted data subvolume and fail when it is absent, preventing their writes to a fallback directory. A manually invoked Podman command does not have this unit dependency and must only run after validating the mount. Btrfs snapshots do not include nested subvolume contents, so backup policy -must list service subvolumes explicitly. The `containers` subvolume can then -be omitted from service-data snapshots. A shared filesystem shares free space -and physical failure; snapshots are not independent backups. +must list service subvolumes explicitly. Snapshot source subvolumes are leaf +subvolumes: nested subvolumes inside them are unsupported. Syncthing follows +this rule by design. The `containers` subvolume can then be omitted from +service-data snapshots. A shared filesystem shares free space and physical +failure; snapshots are not independent backups. The clamps VM passed fresh provisioning, mount, SELinux, reboot, repeat apply, and unavailable/wrong-mount recovery. A post-provision OS rebase and the diff --git a/ublue/plan/CLAMPS-BTRBK.md b/ublue/plan/CLAMPS-BTRBK.md index 96dcf55a..3eca218c 100644 --- a/ublue/plan/CLAMPS-BTRBK.md +++ b/ublue/plan/CLAMPS-BTRBK.md @@ -62,8 +62,8 @@ The [snapshot design](../design/btrbk.md) records the chosen scope and why. `butane/ACCEPTANCE.md` under shared Btrfs data storage. Local snapshots share the source disk's failure domain; this milestone does not -claim independent backup. Before cutover, inspect whether any real Syncthing -folders are nested Btrfs subvolumes and list each such source explicitly. +claim independent backup. Nested subvolumes inside a selected snapshot source +are unsupported by design; Syncthing data sources must remain leaf subvolumes. ## Current verification From 58782e2406b96c8c8e11302493a397aeace56985 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 29 Sep 2026 17:04:29 +0200 Subject: [PATCH 096/252] docs(tailscale): document provisioning and split DNS --- ublue/design/private-ui-access.md | 28 ++++++++++----- ublue/design/secrets.md | 13 ++++--- ublue/plan/CLAMPS-MIGRATION.md | 27 +++++++++------ ublue/skillet/README.md | 57 +++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 23 deletions(-) diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index e7fa73cb..c61082ea 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -8,10 +8,22 @@ Syncthing are the first targets. Their UIs are reachable only through the tailnet; neither needs a public listener or an OAuth2 proxy. Keep Pi-hole's LAN DNS listener and Syncthing's peer-transfer ports separate from their UIs. -Use names under a domain we control, rather than `*.ts.net`, so the UI URLs and -Caddy routes survive a move between hosted Tailscale and Headscale. Caddy gets -publicly trusted certificates through Cloudflare DNS-01 using the existing -secret-delivery design. DNS-01 does not require publicly reachable UI endpoints. +Run Tailscale in its Podman container with `Network=host`, matching the +existing Chef configuration on rupik, calculon, and boxy. This gives the host +a normal Tailscale interface for host-level routing and port forwarding. +Persist its state under `/var/lib/data/tailscale`. Use separate tagged +identities and access rules for production servers and smoke VMs. The +workstation uses a narrowly scoped OAuth client to mint one-use enrollment +keys; only those short-lived keys are delivered to a host, never the OAuth +client secret. + +Use a privately managed production zone rather than `*.ts.net`, so the UI URLs +and Caddy routes survive a move between hosted Tailscale and Headscale. Keep a +separate privately managed zone for disposable smoke-test names and +certificates. Store the literal zones and records outside this public +repository. Caddy gets publicly trusted certificates through Cloudflare +DNS-01 using the existing secret-delivery design. DNS-01 does not require +publicly reachable UI endpoints. Hosted Tailscale MagicDNS cannot store arbitrary records. Configure split DNS for the private UI zone, forwarding to the fleet's Pi-hole resolvers. Each @@ -54,10 +66,10 @@ extra DNS records but not Tailscale Services. ## Status and references -This is the planned access design; Caddy, tailnet DNS, resolver redundancy, and -UI isolation have not yet been implemented or accepted on clamps. The current -Pi-hole UI is private to the container bridge; Syncthing's GUI is still -published on the host. +This is the planned access design; Skillet-managed Tailscale, Caddy, tailnet +DNS, resolver redundancy, and UI isolation have not yet been implemented or +accepted on clamps. The current Pi-hole UI is private to the container bridge; +Syncthing's GUI is still published on the host. References: [Tailscale DNS](https://tailscale.com/docs/reference/dns-in-tailscale/), [Headscale DNS](https://headscale.net/stable/ref/dns/), diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index c0a0a78a..e49bb12a 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -41,9 +41,13 @@ The prefix is consistently singular, `skillet`: | `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | | `skillet/hosts/clamps/cloudflare/acme-token` | `cloudflare_acme_token` | | `skillet/cloudflare/token-creator` | Workstation only | +| `skillet/tailscale/provisioner-client-id` | Workstation only; OAuth Client ID | +| `skillet/tailscale/provisioner-client-secret` | Workstation only; OAuth Client secret | Host entries follow `skillet/hosts///`; shared credentials -follow `skillet//`. Exact lookups reject ambiguous entries and +follow `skillet//`. The Tailscale OAuth client is a workstation +master credential used to mint one-use host enrollment keys; it is never +delivered to a host. Exact lookups reject ambiguous entries and entries with empty Password fields. A missing Pi-hole entry is generated only after Skillet successfully opens the existing vault and confirms over SSH that the host has neither an encrypted Pi-hole credential nor a Podman secret. If @@ -82,10 +86,11 @@ credential encryption does not encrypt that copy. Smoke VMs get generated test passwords, never production host entries. Live ACME testing reads `skillet/cloudflare/token-creator` locally and creates one short-lived Cloudflare token per VM, with Zone Read and DNS Edit restricted to -the separately managed smoke-test zone. That permission covers the whole zone; +the separately managed smoke-test zone. The permission covers the whole zone; unique test names prevent accidental record collisions, not API access to -other records. Keep the literal zone outside this public repository. The -creator remains on the workstation. +other records. Production UI records use a separate private zone. Keep literal +zone names and records outside this public repository. The token creator +remains on the workstation. VM Pi-hole secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, expiry, and owned record IDs in run metadata, without token values. Existing diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 36e521b8..399fd624 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -17,15 +17,20 @@ Pi-hole container runs in the VM with data and Podman storage under `/var/lib/data`; service networking, live ACME, final disk selection, and post-provision rebase checks remain open. -Next: implement the [btrbk milestone](CLAMPS-BTRBK.md). Syncthing -passed the named disposable-VM service and repeat-apply checks. Pi-hole -LAN-client access and final custom DNS records remain open; production DNS -stays on rupik. See the [Syncthing design](../design/syncthing.md), [Pi-hole -network design](../design/pihole-network.md), [storage design](../design/storage.md), -[secret design](../design/secrets.md), [VM lifecycle](../design/smoke-vms.md), -and [storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The implemented -VM layout uses a shared Btrfs filesystem with `/var/lib/data` as the data mount; -the final physical disk remains open. +Next: port Tailscale to clamps, deciding its enrollment/authentication and +tailnet DNS inputs before provisioning. Then implement Caddy with Cloudflare +DNS-01 staging for private Pi-hole and Syncthing UIs. The private-UI design +requires a tailnet-only listener and consistent split-DNS answers from the +Pi-hole resolvers, so certificate issuance alone is not the acceptance target. +Pi-hole LAN-client reachability and final custom DNS records also remain open; +production DNS stays on rupik. Syncthing passed the named disposable-VM service +and repeat-apply checks, and the [btrbk milestone](CLAMPS-BTRBK.md) is complete. +See the [private UI design](../design/private-ui-access.md), [Syncthing +design](../design/syncthing.md), [Pi-hole network design](../design/pihole-network.md), +[storage design](../design/storage.md), [secret design](../design/secrets.md), +[VM lifecycle](../design/smoke-vms.md), and +[storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The final physical +disk remains open. ## Boundaries @@ -84,9 +89,9 @@ Each row is a separate implementation and validation step. Adjust order for depe | Order | Capability | Behavior and state to preserve | | --- | --- | --- | | 1 | Syncthing (implemented; disposable VM service and repeat-apply checks passed) | Data, device identity, folder configuration, UID/GID, required ports | -| 2 | btrbk | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | +| 2 | btrbk (implemented and verified) | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | | 3 | UniFi | Controller data or supported backup restore, version compatibility, adoption and ownership | -| 4 | Tailscale | Authentication/identity policy, exit-node approval, forwarding, persistent state, DNS policy and hardware interface settings | +| 4 | Tailscale (next) | Authentication/identity policy, exit-node approval, forwarding, persistent state, DNS policy and hardware interface settings. This is a prerequisite for accepting tailnet-only Caddy UI access. | | 5 | Private UI access with Caddy + ACME | Implement the [private UI design](../design/private-ui-access.md): host-specific names, hosted Tailscale split DNS with consistent reachable Pi-hole resolvers, tailnet-only access, Syncthing GUI isolation, and DNS recovery; validate Cloudflare DNS-01 with staging, per-VM tokens, certificate persistence and cleanup. Add public OAuth2 proxying only for an explicitly required public UI. | | 6 | Cloudflare DDNS | Required records and token delivery; reconcile the legacy updater before enabling competing writers | | 7 | Monitoring and remaining host baseline | Explicit keep/drop decision for Datadog; required hardening, users, SSH/sudo, ET and WOL behavior | diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 59908fe1..af99abaf 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -179,6 +179,63 @@ permissions. Save it as `skillet/cloudflare/token-creator`. If already stored in the desktop keyring, move that value into KeePassXC and verify the saved entry before removing the old copy. The current smoke command does not use it. +### Tailscale setup for Skillet + +Skillet will run the official `tailscale/tailscale` container with +`Network=host`, matching the current Chef configuration on rupik, calculon, +and boxy. This gives Tailscale a normal host interface for host-level routing +and port forwarding. Persist its state under `/var/lib/data/tailscale`. Do not +put the OAuth client secret or a reusable auth key on the host. See the +[private UI design](../design/private-ui-access.md) for the network decision. + +1. In the Tailscale admin console, open **Access controls** and ensure the + policy defines separate tags for production servers and smoke VMs, such as + `tag:skillet-server` and `tag:skillet-smoke`. Give each tag only the access + that role needs. Make a provisioning tag the owner of those tags, following + the existing `tagOwners` policy pattern; do not give smoke VMs production + server permissions. +2. Open **Trust credentials** and create an **OAuth** credential for Skillet. + Grant **Keys > Auth Keys: Write** (`auth_keys`) and restrict it to the + provisioning tag from step 1. This lets Skillet mint a tagged enrollment + key per target. Keys should be one-use, preauthorized, and non-ephemeral so + a retained smoke VM stays registered through shutdowns. Until automated + device cleanup is implemented, remove a destroyed VM from **Machines** in + the admin console; revoking its auth key does not remove an enrolled device. + Automated cleanup will require a separately reviewed `devices:core` scope. +3. Copy the OAuth **Client ID** and **Client secret** from the creation page. + In KeePassXC create two entries, putting each value in its **Password** + field: + + | Group path | Entry title | Password value | + | --- | --- | --- | + | `skillet/tailscale` | `provisioner-client-id` | OAuth Client ID | + | `skillet/tailscale` | `provisioner-client-secret` | OAuth Client secret | + + The ID is not secret, but storing both this way lets Skillet use its + Password-field lookup for the complete credential. The client secret can + mint new auth keys, so treat it as the workstation-side master credential. + Never put it in a VM, Quadlet, environment file, or repository. Close + KeePassXC before Skillet edits the database. +4. In the Tailscale admin console, open **DNS** and leave **Override DNS + servers** off. Under **Nameservers**, choose **Add nameserver > Custom**. + Add the numeric Tailscale IPv4 address of each Pi-hole that is reachable + over Tailscale, enable **Restrict to search domain**, and enter the private + UI zone. Add a resolver only after it is reachable and serves the same + records as the others. Clients may query resolvers in any order. +5. Configure production and smoke-test UI zones outside this public repository. + Use a restricted nameserver for each zone only when its Pi-hole resolver has + the matching local records. Once clamps and its Pi-hole are enrolled, add + identical production records to every configured Pi-hole, using the form + `..` and resolving to that host's + Tailscale address. Keep smoke-test records under a separate zone and point + them at the disposable VM's Tailscale address. Caddy obtains trusted + certificates through Cloudflare DNS-01; UI A/AAAA records do not need to be + public. + +The Tailscale vault entries and split DNS are not consumed/configured by +Skillet yet. This guide prepares the tailnet and vault for that milestone; +Skillet support and disposable-VM validation remain required. + ## Development checks - **Error Handling**: Use `thiserror` in library crates; `anyhow` is reserved for CLI binaries. No `unwrap()` or `expect()` in library code. From afb6f405a618117789306617a59fc4170ab9d2de Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 30 Sep 2026 22:05:17 +0200 Subject: [PATCH 097/252] feat(tailscale): provision and clean up host identities --- ublue/butane/bin/coreos-install | 11 +- ublue/butane/bin/test-vm | 6 +- ublue/butane/includes/skillet.bu | 2 + ublue/skillet/Cargo.lock | 2 + ublue/skillet/crates/cli-common/src/hosts.rs | 97 +++- .../crates/cli-common/src/hosts_tests.rs | 30 ++ ublue/skillet/crates/cli/Cargo.toml | 2 + ublue/skillet/crates/cli/src/main.rs | 21 +- .../skillet/crates/cli/src/secret_delivery.rs | 421 ++++++++++++++---- .../crates/cli/src/secret_delivery/tests.rs | 22 +- ublue/skillet/crates/cli/src/tailscale.rs | 340 ++++++++++++++ .../skillet/crates/cli/src/tailscale_tests.rs | 27 ++ .../skillet/crates/cli/src/test_entrypoint.sh | 11 +- 13 files changed, 891 insertions(+), 101 deletions(-) create mode 100644 ublue/skillet/crates/cli-common/src/hosts_tests.rs create mode 100644 ublue/skillet/crates/cli/src/tailscale.rs create mode 100644 ublue/skillet/crates/cli/src/tailscale_tests.rs diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 33e88e26..6af556c9 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -68,7 +68,11 @@ if [[ -e "$output_dir" || -L "$output_dir" ]]; then exit 1 fi if [[ -z "$artifact" ]]; then - artifact="$repo_dir/skillet/target/x86_64-unknown-linux-musl/release/skillet-$host" + artifact_target_dir="${CARGO_TARGET_DIR:-$repo_dir/skillet/target}" + if [[ "$artifact_target_dir" != /* ]]; then + artifact_target_dir="$repo_dir/skillet/$artifact_target_dir" + fi + artifact="$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet-$host" (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p "skillet-$host") fi [[ -f "$artifact" ]] || { echo "Missing Skillet artifact: $artifact" >&2; exit 1; } @@ -95,6 +99,11 @@ cp "$input" "$output_dir/source/$host.bu" cp "$butane_dir"/includes/*.bu "$output_dir/source/includes/" cp "$artifact" "$output_dir/source/files/skillet-$host" chmod 755 "$output_dir/source/files/skillet-$host" +# Give each test VM its own hostname so Tailscale device cleanup can verify +# the exact node belonging to this run, even when several VMs share a host OS. +TEST_VM_HOSTNAME="$vm_name" yq -i \ + '(.storage.files[] | select(.path == "/etc/hostname").contents.inline) = strenv(TEST_VM_HOSTNAME)' \ + "$output_dir/source/$host.bu" # Keep the multi-megabyte static binary out of QEMU fw_cfg. Ignition reads # fw_cfg very slowly for large configs; test-vm-ready installs this artifact # over SSH after the guest has completed its first Ignition boot. diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index d97217eb..baa1c211 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -258,7 +258,11 @@ case "$operation" in update) (($# == 0)) || usage inspect_owned - artifact="$(realpath -e "$script_dir/../../skillet/target/x86_64-unknown-linux-musl/release/skillet-$host")" + artifact_target_dir="${CARGO_TARGET_DIR:-$script_dir/../../skillet/target}" + if [[ "$artifact_target_dir" != /* ]]; then + artifact_target_dir="$script_dir/../../skillet/$artifact_target_dir" + fi + artifact="$(realpath -e "$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet-$host")" [[ -f "$artifact" && ! -L "$artifact" ]] || { echo "Build skillet-$host first" >&2; exit 1; } ssh_args scp -O -i "$run_dir/ssh/id_ed25519" -P "$port" -o BatchMode=yes \ diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index ce06d385..8604d67f 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -34,10 +34,12 @@ systemd: After=skillet-data-prepare.service AssertPathIsMountPoint=/var/lib/data ConditionPathExists=/etc/credstore.encrypted/skillet/pihole_web_password.cred + ConditionPathExists=/etc/credstore.encrypted/skillet/tailscale_auth_key.cred [Service] Type=oneshot LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred + LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase full [Install] diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 216e91c5..bba5c081 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1521,6 +1521,8 @@ dependencies = [ "keepass", "keyutils", "rpassword", + "serde", + "serde_json", "sha2 0.10.9", "skillet_cli_common", "tempfile", diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 88f88b9c..e83aed78 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -10,7 +10,9 @@ use skillet_core::{ files::{FileError, FileResource}, system::{SystemError, SystemResource}, }; -use skillet_podman::{PodmanNetwork, QuadletSecret, SecretTarget}; +use skillet_podman::{ + ContainerUser, PodmanConfig, PodmanNetwork, QuadletSecret, SecretTarget, Volume, +}; use std::collections::BTreeMap; use thiserror::Error; @@ -88,6 +90,9 @@ pub fn apply_full_base( /// web UI password. Full `clamps` apply reads it from the systemd credential /// directory supplied to that invocation. pub const PIHOLE_WEB_PASSWORD_CREDENTIAL: &str = "pihole_web_password"; +/// Name of the systemd credential (and Podman secret) holding the one-use +/// Tailscale enrollment key used when the node has not joined yet. +pub const TAILSCALE_AUTH_KEY_CREDENTIAL: &str = "tailscale_auth_key"; /// Custom DNS records for the clamps Pi-hole (`ip -> fqdn`). // TODO: replace with the real LAN IP and domain before the production @@ -110,6 +115,55 @@ fn clamps_service_network() -> PodmanNetwork { } } +fn clamps_tailscale_config(hostname: &str, auth_key: String) -> PodmanConfig { + let extra_config = BTreeMap::from([ + ( + "Container".to_string(), + vec![ + "ContainerName=tailscale".to_string(), + "AddCapability=NET_ADMIN".to_string(), + "AddCapability=NET_RAW".to_string(), + "AddDevice=/dev/net/tun:/dev/net/tun".to_string(), + "AutoUpdate=registry".to_string(), + "Environment=TS_ACCEPT_DNS=false".to_string(), + "Environment=TS_AUTH_ONCE=true".to_string(), + format!("Environment=TS_HOSTNAME={hostname}"), + "Environment=TS_STATE_DIR=/var/lib/tailscale".to_string(), + "Environment=TS_USERSPACE=false".to_string(), + "Network=host".to_string(), + ], + ), + ( + "Unit".to_string(), + vec!["After=network-online.target".to_string()], + ), + ]); + PodmanConfig { + name: "tailscale".to_string(), + image: "docker.io/tailscale/tailscale:stable".to_string(), + networks: Vec::new(), + user: ContainerUser { + container_uid: 0, + container_gid: 0, + host_user: None, + }, + create_host_user: false, + volumes: vec![Volume { + host_path: "/var/lib/data/tailscale".to_string(), + container_path: "/var/lib/tailscale".to_string(), + options: Some("Z".to_string()), + }], + secrets: vec![QuadletSecret { + secret_name: TAILSCALE_AUTH_KEY_CREDENTIAL.to_string(), + target: SecretTarget::Environment { + env_var_name: "TS_AUTHKEY".to_string(), + }, + }], + config_revisions: vec![auth_key.into_bytes()], + extra_config, + } +} + /// Apply the clamps host configuration (hardening + Pi-hole). /// /// The credential manager is constructed lazily here: hosts that need no @@ -123,14 +177,43 @@ pub fn apply_clamps( // Check before Podman can create a graphroot on the fallback /var tree. apply_full_base(system, files)?; - // 1. Ingest secret from systemd (lazy: only this host needs secrets) + // 1. Ingest secrets from systemd (lazily: only clamps needs these values) let credentials = CredentialManager::new()?; let secret_payload = credentials.read_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL)?; + let tailscale_auth_key = credentials.read_secret(TAILSCALE_AUTH_KEY_CREDENTIAL)?; - // 2. Provision to Podman + // 2. Provision the Podman secrets system.ensure_podman_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL, &secret_payload)?; + system.ensure_podman_secret(TAILSCALE_AUTH_KEY_CREDENTIAL, &tailscale_auth_key)?; + + let hostname = files + .read_file(std::path::Path::new("/etc/hostname"))? + .map(|contents| { + String::from_utf8(contents).map_err(|error| ApplyError::FixtureInput(error.to_string())) + }) + .transpose()? + .unwrap_or_else(|| "clamps".to_string()); + let hostname = hostname.trim(); + if hostname.is_empty() + || hostname.len() > 63 + || !hostname + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(ApplyError::FixtureInput( + "invalid hostname in /etc/hostname for Tailscale".to_string(), + )); + } + + // Tailscale uses host networking so its interface and routes belong to + // the OS. The one-use auth key bootstraps login; state persists separately. + skillet_podman::container( + system, + files, + clamps_tailscale_config(hostname, tailscale_auth_key), + )?; - // Look up pihole user and group IDs; None if the user/group + // 3. Look up pihole user and group IDs; None if the user/group // doesn't exist yet (ensure_user/group will assign dynamic IDs). // Passing the looked-up IDs through (rather than None) keeps the // secret file ownership and the user record consistent when the @@ -138,7 +221,7 @@ pub fn apply_clamps( let pihole_uid = user_lookup::lookup_uid("pihole"); let pihole_gid = user_lookup::lookup_gid("pihole"); - // 3. Apply pihole with the secret + // 4. Apply pihole with the secret let secrets = vec![QuadletSecret { secret_name: PIHOLE_WEB_PASSWORD_CREDENTIAL.to_string(), target: SecretTarget::File { @@ -211,6 +294,10 @@ pub fn apply_host( #[path = "hosts/fixture.rs"] mod fixture; +#[cfg(test)] +#[path = "hosts_tests.rs"] +mod tests; + pub fn apply_host_phase( hostname: &str, phase: ApplyPhase, diff --git a/ublue/skillet/crates/cli-common/src/hosts_tests.rs b/ublue/skillet/crates/cli-common/src/hosts_tests.rs new file mode 100644 index 00000000..86614b74 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/hosts_tests.rs @@ -0,0 +1,30 @@ +use super::{clamps_tailscale_config, TAILSCALE_AUTH_KEY_CREDENTIAL}; +use skillet_podman::SecretTarget; + +#[test] +fn clamps_tailscale_uses_host_network_and_persistent_state() { + let config = clamps_tailscale_config("clamps-test-smoke", "test-auth-key".to_string()); + + assert_eq!(config.name, "tailscale"); + assert_eq!(config.image, "docker.io/tailscale/tailscale:stable"); + assert!(config.networks.is_empty()); + assert_eq!(config.volumes.len(), 1); + assert_eq!(config.volumes[0].host_path, "/var/lib/data/tailscale"); + assert_eq!(config.volumes[0].container_path, "/var/lib/tailscale"); + let container = &config.extra_config["Container"]; + assert!(container.contains(&"ContainerName=tailscale".to_string())); + assert!(container.contains(&"Network=host".to_string())); + assert!(container.contains(&"AddCapability=NET_ADMIN".to_string())); + assert!(container.contains(&"AddCapability=NET_RAW".to_string())); + assert!(container.contains(&"AddDevice=/dev/net/tun:/dev/net/tun".to_string())); + assert!(container.contains(&"Environment=TS_AUTH_ONCE=true".to_string())); + assert!(container.contains(&"Environment=TS_ACCEPT_DNS=false".to_string())); + assert!(container.contains(&"Environment=TS_HOSTNAME=clamps-test-smoke".to_string())); + assert!(config.secrets.iter().any(|secret| { + secret.secret_name == TAILSCALE_AUTH_KEY_CREDENTIAL + && matches!( + &secret.target, + SecretTarget::Environment { env_var_name } if env_var_name == "TS_AUTHKEY" + ) + })); +} diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 4addc533..93aa54cb 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -18,3 +18,5 @@ keyutils.workspace = true hex.workspace = true sha2.workspace = true tempfile.workspace = true +serde_json.workspace = true +serde.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 3db34dca..f6520a06 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -10,6 +10,7 @@ use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; mod secret_delivery; +mod tailscale; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -48,7 +49,7 @@ enum Commands { #[derive(clap::Subcommand, Debug)] enum SecretCommands { - /// Use or create the clamps Pi-hole web password and deliver it + /// Deliver a clamps Pi-hole credential or enroll its Tailscale client Deliver(SecretDeliverArgs), /// Remove the cached vault password from the kernel keyring Lock(SecretLockArgs), @@ -64,7 +65,7 @@ struct SecretLockArgs { struct SecretDeliverArgs { #[arg(value_parser = ["clamps"])] hostname: String, - #[arg(value_parser = ["pihole"])] + #[arg(value_parser = ["pihole", "tailscale"])] service: String, #[arg(long)] database: Option, @@ -101,7 +102,7 @@ enum VmCommands { Destroy(VmDestroyArgs), /// List available host templates and their recorded disposable VMs List(VmListArgs), - /// Deliver a generated dummy Pi-hole password and run full apply + /// Provision Pi-hole and enroll the disposable VM in Tailscale Provision(VmProvisionArgs), /// Install the current host binary on a retained disposable VM Update(VmDestroyArgs), @@ -119,12 +120,20 @@ struct VmCreateArgs { struct VmDestroyArgs { hostname: String, instance: String, + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, } #[derive(clap::Args, Debug)] struct VmProvisionArgs { hostname: String, instance: String, + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, /// Replace the disposable password and restart its consumer #[arg(long)] rotate: bool, @@ -317,6 +326,8 @@ fn run_vm_create(args: &VmCreateArgs) -> Result<()> { fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { vm_name(&args.hostname, &args.instance)?; let helper = butane_root()?.join("bin/test-vm"); + run_helper(&helper, &[&args.hostname, "status", &args.instance])?; + secret_delivery::remove_vm_from_tailscale(args)?; run_helper(&helper, &[&args.hostname, "destroy", &args.instance]) } @@ -454,6 +465,10 @@ fn run_container_test(args: &ContainerArgs) -> Result<()> { creds.join("pihole_web_password"), "skillet-test-dummy-secret", )?; + fs::write( + creds.join("tailscale_auth_key"), + "skillet-test-dummy-auth-key", + )?; let start = Command::new("podman") .args([ "run", diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 3cb39889..8f296e81 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -1,10 +1,10 @@ -use super::{butane_root, vm_name, SecretDeliverArgs, VmProvisionArgs}; +use super::{butane_root, tailscale, vm_name, SecretDeliverArgs, VmDestroyArgs, VmProvisionArgs}; use anyhow::{anyhow, Context, Result}; use keepass::{Database, DatabaseKey}; use std::{ fs, io::{Read as _, Write as _}, - os::unix::fs::OpenOptionsExt as _, + os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}, path::{Path, PathBuf}, process::{Command, Stdio}, time::{SystemTime, UNIX_EPOCH}, @@ -13,7 +13,9 @@ use std::{ mod vault_cache; pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { - let path = format!("skillet/hosts/{}/pihole/web-password", args.hostname); + if args.hostname != "clamps" { + return Err(anyhow!("secret delivery currently supports clamps only")); + } let database = match &args.database { Some(path) => path.clone(), None => default_database_path()?, @@ -24,26 +26,38 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { )); } let mut vault = open_vault(&database, args.key_file.as_deref())?; - let secret = if let Some(secret) = lookup(&vault.database, &path)? { - if fs::read(&vault.path).context("rechecking vault before delivery")? != vault.original { - return Err(anyhow!( - "KeePassXC database changed while Skillet was running; reopen it and retry" - )); + match args.service.as_str() { + "pihole" => { + let path = format!("skillet/hosts/{}/pihole/web-password", args.hostname); + let secret = if let Some(secret) = lookup(&vault.database, &path)? { + ensure_vault_unchanged(&vault)?; + secret + } else { + if remote_credential_state(args)? != RemoteCredentialState::Absent { + return Err(anyhow!( + "host already has a Pi-hole credential; restore the missing KeePassXC entry instead of creating a replacement" + )); + } + let secret = random_password()?; + create_entry(&mut vault.database, &path, &secret)?; + save_vault(&vault, args.key_file.as_deref(), &path, &secret)?; + secret + }; + let mut command = ssh_command(args); + install(&mut command, "pihole_web_password", &secret) } - secret - } else { - if remote_credential_state(args)? != RemoteCredentialState::Absent { - return Err(anyhow!( - "host already has a Pi-hole credential; restore the missing KeePassXC entry instead of creating a replacement" - )); + "tailscale" => { + let credentials = tailscale_credentials(&vault)?; + let auth_key = tailscale::create_auth_key( + &credentials, + tailscale::SERVER_TAG, + "Skillet clamps production host", + )?; + let mut command = ssh_command(args); + install(&mut command, "tailscale_auth_key", &auth_key.key) } - let secret = random_password()?; - create_entry(&mut vault.database, &path, &secret)?; - save_vault(&vault, args.key_file.as_deref(), &path, &secret)?; - secret - }; - let mut command = ssh_command(args); - install(&mut command, &secret) + _ => Err(anyhow!("unsupported secret service {}", args.service)), + } } pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { @@ -59,6 +73,30 @@ pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { Ok(()) } +fn ensure_vault_unchanged(vault: &OpenVault) -> Result<()> { + if fs::read(&vault.path).context("rechecking vault before delivery")? != vault.original { + return Err(anyhow!( + "KeePassXC database changed while Skillet was running; reopen it and retry" + )); + } + Ok(()) +} + +fn tailscale_credentials(vault: &OpenVault) -> Result { + let client_id = lookup(&vault.database, "skillet/tailscale/provisioner-client-id")? + .ok_or_else(|| { + anyhow!("KeePassXC entry skillet/tailscale/provisioner-client-id is missing") + })?; + let client_secret = lookup( + &vault.database, + "skillet/tailscale/provisioner-client-secret", + )? + .ok_or_else(|| { + anyhow!("KeePassXC entry skillet/tailscale/provisioner-client-secret is missing") + })?; + tailscale::OAuthCredentials::new(client_id, client_secret) +} + fn ssh_command(args: &SecretDeliverArgs) -> Command { let mut command = Command::new("ssh"); command @@ -144,32 +182,103 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { if !status.success() { return Err(anyhow!("VM ownership or status validation failed")); } - let run_dir = butane.join("runs").join(name); + let run_dir = butane.join("runs").join(&name); let identity = run_dir.join("ssh/id_ed25519"); let known_hosts = run_dir.join("ssh/known_hosts"); let port = read_vm_port(&run_dir.join("run.conf"))?; - let mut command = Command::new("ssh"); - command - .args([ - "-T", - "-o", - "BatchMode=yes", - "-o", - "IdentitiesOnly=yes", - "-o", - "StrictHostKeyChecking=yes", - "-o", - ]) - .arg(format!("UserKnownHostsFile={}", known_hosts.display())) - .arg("-i") - .arg(&identity) - .arg("-p") - .arg(port.to_string()) - .arg("giacomo@127.0.0.1"); - if !args.rotate { - // An existing encrypted credential is reusable after reboot or on a - // different workstation. Missing or corrupt files fail in full apply. - let check = Command::new("ssh") + let mut ssh = VmSsh::new(&identity, &known_hosts, port); + validate_vm_tailscale_delivery(&mut ssh)?; + let vault_path = match &args.database { + Some(path) => path.clone(), + None => default_database_path()?, + }; + let vault = open_vault(&vault_path, args.key_file.as_deref())?; + let credentials = tailscale_credentials(&vault)?; + let expected_hostname = name.as_str(); + + write_pending_tailscale(&run_dir, expected_hostname)?; + let mut addresses = vm_tailscale_addresses(&mut ssh).unwrap_or_default(); + if addresses.is_empty() { + let auth_key = tailscale::create_auth_key( + &credentials, + tailscale::SMOKE_TAG, + &format!("Skillet disposable VM {expected_hostname}"), + )?; + ssh.install("tailscale_auth_key", &auth_key.key)?; + } + + let pihole_credential = "/etc/credstore.encrypted/skillet/pihole_web_password.cred"; + let has_pihole_credential = vm_credential_present(&mut ssh, pihole_credential)?; + if args.rotate || !has_pihole_credential { + let secret = random_password()?; + ssh.install("pihole_web_password", &secret)?; + } else { + ssh.run("sudo -n systemctl start skillet-full-apply.service")?; + } + + addresses = wait_for_vm_tailscale(&mut ssh)?; + let record = tailscale::find_device( + &credentials, + expected_hostname, + tailscale::SMOKE_TAG, + &addresses, + )?; + save_vm_tailscale_record(&run_dir, &record)?; + remove_pending_tailscale(&run_dir)?; + println!("Tailscale connected VM {expected_hostname}"); + Ok(()) +} + +pub(super) fn remove_vm_from_tailscale(args: &VmDestroyArgs) -> Result<()> { + if args.hostname != "clamps" { + return Ok(()); + } + let name = vm_name(&args.hostname, &args.instance)?; + let butane = butane_root()?; + let run_dir = butane.join("runs").join(&name); + let pending = run_dir.join("tailscale-pending"); + let record_path = run_dir.join("tailscale.json"); + if !pending.exists() && !record_path.exists() { + return Ok(()); + } + let expected = if record_path.exists() { + Some(read_vm_tailscale_record(&record_path)?) + } else { + None + }; + let vault_path = match &args.database { + Some(path) => path.clone(), + None => default_database_path()?, + }; + let vault = open_vault(&vault_path, args.key_file.as_deref())?; + let credentials = tailscale_credentials(&vault)?; + if tailscale::remove_device_for_hostname( + &credentials, + &name, + tailscale::SMOKE_TAG, + expected.as_ref(), + )? + .is_some() + { + println!("Removed Tailscale device for {name}"); + } + remove_pending_tailscale(&run_dir)?; + if record_path.exists() { + fs::remove_file(record_path).context("removing Tailscale VM metadata")?; + } + Ok(()) +} + +struct VmSsh<'a> { + identity: &'a Path, + known_hosts: &'a Path, + port: u16, +} + +impl VmSsh<'_> { + fn command(&self, remote: &str) -> Command { + let mut command = Command::new("ssh"); + command .args([ "-T", "-o", @@ -179,51 +288,169 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { "-o", "StrictHostKeyChecking=yes", "-o", + "ConnectTimeout=10", + "-o", ]) - .arg(format!("UserKnownHostsFile={}", known_hosts.display())) + .arg(format!("UserKnownHostsFile={}", self.known_hosts.display())) .arg("-i") - .arg(&identity) + .arg(self.identity) .arg("-p") - .arg(port.to_string()) - .args([ - "giacomo@127.0.0.1", - "sudo -n test -s /etc/credstore.encrypted/skillet/pihole_web_password.cred", - ]) - .status() - .context("checking VM credential")?; - if check.success() { - let apply = Command::new("ssh") - .args([ - "-T", - "-o", - "BatchMode=yes", - "-o", - "IdentitiesOnly=yes", - "-o", - "StrictHostKeyChecking=yes", - "-o", - ]) - .arg(format!("UserKnownHostsFile={}", known_hosts.display())) - .arg("-i") - .arg(&identity) - .arg("-p") - .arg(port.to_string()) - .args([ - "giacomo@127.0.0.1", - "sudo -n systemctl start skillet-full-apply.service", - ]) - .status() - .context("running full apply")?; - if !apply.success() { - return Err(anyhow!("full apply failed")); + .arg(self.port.to_string()) + .arg("giacomo@127.0.0.1") + .arg(remote); + command + } + + fn run(&mut self, remote: &str) -> Result<()> { + let output = self + .command(remote) + .output() + .context("running command over VM SSH")?; + if !output.status.success() { + return Err(anyhow!("VM command failed with status {}", output.status)); + } + Ok(()) + } + + fn output(&mut self, remote: &str) -> Result { + self.command(remote) + .output() + .context("running command over VM SSH") + } + + fn install(&mut self, name: &str, secret: &str) -> Result<()> { + if secret.is_empty() { + return Err(anyhow!("refusing to deliver an empty credential")); + } + let mut child = self + .command(&format!( + "sudo -n /var/usrlocal/bin/skillet-clamps credential install {name} skillet-full-apply.service" + )) + .stdin(Stdio::piped()) + .spawn() + .context("opening VM credential delivery")?; + child + .stdin + .take() + .ok_or_else(|| anyhow!("VM SSH stdin unavailable"))? + .write_all(secret.as_bytes()) + .context("sending credential to VM over SSH")?; + let status = child.wait().context("waiting for VM credential delivery")?; + if !status.success() { + return Err(anyhow!( + "VM credential delivery or full apply failed: {status}" + )); + } + Ok(()) + } +} + +impl<'a> VmSsh<'a> { + fn new(identity: &'a Path, known_hosts: &'a Path, port: u16) -> Self { + Self { + identity, + known_hosts, + port, + } + } +} + +fn vm_credential_present(ssh: &mut VmSsh<'_>, path: &str) -> Result { + let output = ssh.output(&format!("sudo -n test -s {path}"))?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => Err(anyhow!("could not inspect VM credential state")), + } +} + +fn vm_tailscale_addresses(ssh: &mut VmSsh<'_>) -> Result> { + let output = ssh.output("sudo -n podman exec tailscale tailscale status --json")?; + if !output.status.success() { + return Ok(std::collections::BTreeSet::new()); + } + parse_tailscale_addresses(&output.stdout) +} + +fn parse_tailscale_addresses(output: &[u8]) -> Result> { + let status: serde_json::Value = + serde_json::from_slice(output).context("decoding Tailscale status returned by the VM")?; + if status + .get("BackendState") + .and_then(serde_json::Value::as_str) + != Some("Running") + { + return Ok(std::collections::BTreeSet::new()); + } + let addresses = status + .get("Self") + .and_then(|value| value.get("TailscaleIPs")) + .and_then(serde_json::Value::as_array) + .ok_or_else(|| anyhow!("Tailscale status has no self addresses"))? + .iter() + .filter_map(serde_json::Value::as_str) + .filter(|address| address.parse::().is_ok()) + .map(ToOwned::to_owned) + .collect::>(); + Ok(addresses) +} + +fn wait_for_vm_tailscale(ssh: &mut VmSsh<'_>) -> Result> { + for _ in 0..60 { + if let Ok(addresses) = vm_tailscale_addresses(ssh) { + if !addresses.is_empty() { + return Ok(addresses); } - return Ok(()); } + std::thread::sleep(std::time::Duration::from_secs(2)); } - let mut bytes = [0_u8; 32]; - fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; - let secret = hex::encode(bytes); - install(&mut command, &secret) + Err(anyhow!("Tailscale did not connect on the VM within 120 seconds; inspect tailscale.service and its journal")) +} + +fn write_pending_tailscale(run_dir: &Path, hostname: &str) -> Result<()> { + let path = run_dir.join("tailscale-pending"); + if path.exists() || path.is_symlink() { + return Ok(()); + } + let mut file = tempfile::NamedTempFile::new_in(run_dir)?; + file.as_file_mut().write_all(hostname.as_bytes())?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + file.as_file().sync_all()?; + file.persist_noclobber(path) + .context("recording pending Tailscale cleanup")?; + Ok(()) +} + +fn save_vm_tailscale_record(run_dir: &Path, record: &tailscale::DeviceRecord) -> Result<()> { + let path = run_dir.join("tailscale.json"); + if path.is_symlink() { + return Err(anyhow!("refusing symlinked Tailscale VM metadata")); + } + let mut file = tempfile::NamedTempFile::new_in(run_dir)?; + serde_json::to_writer(file.as_file_mut(), record)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + file.as_file().sync_all()?; + file.persist(&path) + .context("saving Tailscale VM identity")?; + Ok(()) +} + +fn read_vm_tailscale_record(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(anyhow!("Tailscale VM metadata is not a regular file")); + } + serde_json::from_slice(&fs::read(path)?).context("reading Tailscale VM metadata") +} + +fn remove_pending_tailscale(run_dir: &Path) -> Result<()> { + let pending = run_dir.join("tailscale-pending"); + if pending.exists() { + fs::remove_file(pending).context("removing pending Tailscale metadata")?; + } + Ok(()) } fn read_vm_port(manifest: &Path) -> Result { @@ -239,6 +466,30 @@ fn read_vm_port(manifest: &Path) -> Result { Ok(port) } +fn validate_vm_tailscale_delivery(ssh: &mut VmSsh<'_>) -> Result<()> { + let output = ssh + .output("sudo -n systemctl cat skillet-full-apply.service") + .context("checking the VM's full-apply credential configuration")?; + if !output.status.success() { + return Err(anyhow!( + "could not read skillet-full-apply.service from the VM" + )); + } + validate_tailscale_unit_config(&String::from_utf8_lossy(&output.stdout)) +} + +fn validate_tailscale_unit_config(contents: &str) -> Result<()> { + if !contents.lines().any(|line| { + line.trim() + == "LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred" + }) { + return Err(anyhow!( + "this VM was created from an older Butane config that does not pass the Tailscale credential to full apply; recreate the smoke VM with the current config, then retry provisioning" + )); + } + Ok(()) +} + struct OpenVault { path: PathBuf, original: Vec, @@ -457,12 +708,12 @@ fn save_vault( Ok(()) } -fn install(command: &mut Command, secret: &str) -> Result<()> { +fn install(command: &mut Command, credential: &str, secret: &str) -> Result<()> { if secret.is_empty() { return Err(anyhow!("refusing to deliver an empty credential")); } let mut child = command - .arg("sudo -n /var/usrlocal/bin/skillet-clamps credential install pihole_web_password skillet-full-apply.service") + .arg(format!("sudo -n /var/usrlocal/bin/skillet-clamps credential install {credential} skillet-full-apply.service")) .stdin(Stdio::piped()) .spawn() .context("opening SSH credential delivery")?; diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 3b992ad0..edb3a1e8 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,5 +1,6 @@ use super::{ - create_entry, database_path_from, lookup, open_vault, read_vm_port, save_vault, OpenVault, + create_entry, database_path_from, lookup, open_vault, parse_tailscale_addresses, read_vm_port, + save_vault, validate_tailscale_unit_config, OpenVault, }; use keepass::Database; use keepass::DatabaseKey; @@ -43,6 +44,25 @@ fn vm_port_requires_manifest_range() { assert!(read_vm_port(&file).is_err()); } +#[test] +fn tailscale_status_returns_only_running_valid_ip_addresses() { + let output = br#"{"BackendState":"Running","Self":{"TailscaleIPs":["100.64.0.5","fd7a:115c:a1e0::5","bad"]}}"#; + let addresses = parse_tailscale_addresses(output).unwrap(); + assert_eq!(addresses.len(), 2); + let offline = br#"{"BackendState":"NeedsLogin","Self":{"TailscaleIPs":[]}}"#; + assert!(parse_tailscale_addresses(offline).unwrap().is_empty()); +} + +#[test] +fn rejects_vm_without_tailscale_systemd_credential() { + let old_unit = "LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred"; + let error = validate_tailscale_unit_config(old_unit).unwrap_err(); + assert!(error.to_string().contains("recreate the smoke VM")); + + let current_unit = "LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred"; + assert!(validate_tailscale_unit_config(current_unit).is_ok()); +} + #[test] fn disposable_database_opens_with_correct_password_only() { let mut db = Database::new(); diff --git a/ublue/skillet/crates/cli/src/tailscale.rs b/ublue/skillet/crates/cli/src/tailscale.rs new file mode 100644 index 00000000..c8fe96f8 --- /dev/null +++ b/ublue/skillet/crates/cli/src/tailscale.rs @@ -0,0 +1,340 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::BTreeSet, + fmt::Write as _, + io::Write as _, + process::{Command, Stdio}, +}; + +const API_BASE: &str = "https://api.tailscale.com/api/v2"; +pub(crate) const PROVISIONER_TAG: &str = "tag:skillet-provisioner"; +pub(crate) const SERVER_TAG: &str = "tag:skillet-server"; +pub(crate) const SMOKE_TAG: &str = "tag:skillet-smoke"; + +pub(crate) struct OAuthCredentials { + client_id: String, + client_secret: String, +} + +impl OAuthCredentials { + pub(crate) fn new(client_id: String, client_secret: String) -> Result { + if client_id.trim().is_empty() || client_secret.trim().is_empty() { + return Err(anyhow!("Tailscale OAuth credentials are empty")); + } + Ok(Self { + client_id, + client_secret, + }) + } + + fn access_token(&self, scope: &str) -> Result { + let form = [ + ("grant_type", "client_credentials"), + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("scope", scope), + ("tags", PROVISIONER_TAG), + ] + .into_iter() + .map(|(key, value)| format!("{}={}", form_encode(key), form_encode(value))) + .collect::>() + .join("&"); + let response = request( + "POST", + &format!("{API_BASE}/oauth/token"), + None, + Some(("application/x-www-form-urlencoded", &form)), + )?; + response + .get("access_token") + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("Tailscale OAuth response did not contain an access token")) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct DeviceRecord { + pub(crate) id: String, + pub(crate) hostname: String, + pub(crate) addresses: BTreeSet, +} + +pub(crate) struct AuthKey { + pub(crate) key: String, +} + +pub(crate) fn create_auth_key( + credentials: &OAuthCredentials, + target_tag: &str, + description: &str, +) -> Result { + if !matches!(target_tag, SERVER_TAG | SMOKE_TAG) { + return Err(anyhow!("unsupported Tailscale device tag")); + } + let token = credentials.access_token("auth_keys")?; + let payload = json!({ + "capabilities": {"devices": {"create": { + "reusable": false, + "ephemeral": false, + "preauthorized": true, + "tags": [target_tag] + }}}, + "expirySeconds": 3600, + "description": description + }); + let body = serde_json::to_string(&payload).context("encoding Tailscale auth key request")?; + let response = request( + "POST", + &format!("{API_BASE}/tailnet/-/keys"), + Some(&token), + Some(("application/json", &body)), + )?; + let key = response + .get("key") + .and_then(Value::as_str) + .filter(|key| !key.is_empty()) + .ok_or_else(|| anyhow!("Tailscale auth key response did not contain a key"))? + .to_owned(); + Ok(AuthKey { key }) +} + +pub(crate) fn find_device( + credentials: &OAuthCredentials, + expected_hostname: &str, + expected_tag: &str, + addresses: &BTreeSet, +) -> Result { + if !matches!(expected_tag, SERVER_TAG | SMOKE_TAG) || addresses.is_empty() { + return Err(anyhow!("invalid Tailscale device lookup constraints")); + } + let token = credentials.access_token("devices:core")?; + let response = request( + "GET", + &format!("{API_BASE}/tailnet/-/devices"), + Some(&token), + None, + )?; + let devices = response + .get("devices") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("Tailscale device list response is malformed"))?; + let matches = devices + .iter() + .filter_map(|device| device_record(device, expected_tag)) + .filter(|device| { + device.hostname == expected_hostname + && device + .addresses + .iter() + .any(|address| addresses.contains(address)) + }) + .collect::>(); + match matches.as_slice() { + [device] => Ok(device.clone()), + [] => Err(anyhow!( + "Tailscale has not registered the expected device yet" + )), + _ => Err(anyhow!( + "multiple Tailscale devices match this VM; refusing ambiguity" + )), + } +} + +pub(crate) fn remove_device_for_hostname( + credentials: &OAuthCredentials, + hostname: &str, + expected_tag: &str, + expected: Option<&DeviceRecord>, +) -> Result> { + if !matches!(expected_tag, SERVER_TAG | SMOKE_TAG) || hostname.is_empty() { + return Err(anyhow!("invalid Tailscale device removal constraints")); + } + let token = credentials.access_token("devices:core")?; + let response = request( + "GET", + &format!("{API_BASE}/tailnet/-/devices"), + Some(&token), + None, + )?; + let devices = response + .get("devices") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("Tailscale device list response is malformed"))?; + let matches = devices + .iter() + .filter_map(|device| device_record(device, expected_tag)) + .filter(|device| device.hostname == hostname) + .collect::>(); + let actual = match matches.as_slice() { + [] => return Ok(None), + [actual] => actual, + _ => { + return Err(anyhow!( + "multiple tagged Tailscale devices have hostname {hostname}; refusing removal" + )) + } + }; + if let Some(expected) = expected { + if actual.id != expected.id + || !actual + .addresses + .iter() + .any(|address| expected.addresses.contains(address)) + { + return Err(anyhow!( + "recorded Tailscale ID no longer matches this VM; refusing removal" + )); + } + } + request( + "DELETE", + &format!("{API_BASE}/device/{}", path_segment(&actual.id)?), + Some(&token), + None, + )?; + Ok(Some(actual.clone())) +} + +fn device_record(value: &Value, expected_tag: &str) -> Option { + let tags = value.get("tags")?.as_array()?; + if !tags.iter().any(|tag| tag.as_str() == Some(expected_tag)) { + return None; + } + let id = value.get("id")?.as_str()?.to_owned(); + let hostname = value.get("hostname")?.as_str()?.to_owned(); + let addresses = value + .get("addresses")? + .as_array()? + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect(); + Some(DeviceRecord { + id, + hostname, + addresses, + }) +} + +fn request( + method: &str, + url: &str, + bearer: Option<&str>, + body: Option<(&str, &str)>, +) -> Result { + let mut config = format!( + "url = {}\nrequest = {}\n", + curl_quote(url), + curl_quote(method) + ); + if let Some(token) = bearer { + let _ = writeln!( + config, + "header = {}", + curl_quote(&format!("Authorization: Bearer {token}")) + ); + } + if let Some((content_type, payload)) = body { + let _ = write!( + config, + "header = {}\ndata = {}\n", + curl_quote(&format!("Content-Type: {content_type}")), + curl_quote(payload) + ); + } + let mut child = Command::new("curl") + .args([ + "--config", + "-", + "--silent", + "--show-error", + "--fail-with-body", + "--connect-timeout", + "10", + "--max-time", + "30", + "--write-out", + "\n%{http_code}", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("starting curl for Tailscale API; install curl on the workstation")?; + child + .stdin + .take() + .ok_or_else(|| anyhow!("curl stdin unavailable"))? + .write_all(config.as_bytes()) + .context("sending private Tailscale API request to curl")?; + let output = child + .wait_with_output() + .context("waiting for Tailscale API response")?; + let response_text = + String::from_utf8(output.stdout).context("Tailscale API response was not UTF-8")?; + let (payload, status) = response_text + .rsplit_once('\n') + .ok_or_else(|| anyhow!("Tailscale API response omitted HTTP status"))?; + let status: u16 = status + .parse() + .context("Tailscale API returned an invalid HTTP status")?; + if !output.status.success() || !(200..300).contains(&status) { + let message = serde_json::from_str::(payload) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(Value::as_str) + .map(str::to_owned) + }); + return Err(match message { + Some(message) => anyhow!("Tailscale API returned HTTP {status}: {message}"), + None => anyhow!("Tailscale API request failed with HTTP {status}"), + }); + } + if payload.is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(payload).context("decoding Tailscale API response") +} + +fn form_encode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(char::from(byte)); + } else { + let _ = write!(encoded, "%{byte:02X}"); + } + } + encoded +} + +fn curl_quote(value: &str) -> String { + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\r', "\\r") + .replace('\n', "\\n") + .replace('\t', "\\t"); + format!("\"{escaped}\"") +} + +fn path_segment(value: &str) -> Result { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(anyhow!("invalid Tailscale device ID")); + } + Ok(value.to_owned()) +} + +#[cfg(test)] +#[path = "tailscale_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/cli/src/tailscale_tests.rs b/ublue/skillet/crates/cli/src/tailscale_tests.rs new file mode 100644 index 00000000..30804680 --- /dev/null +++ b/ublue/skillet/crates/cli/src/tailscale_tests.rs @@ -0,0 +1,27 @@ +use super::{curl_quote, device_record, form_encode, SMOKE_TAG}; +use serde_json::json; + +#[test] +fn form_encoding_escapes_oauth_credentials() { + assert_eq!(form_encode("a b+c/%"), "a%20b%2Bc%2F%25"); +} + +#[test] +fn curl_config_quotes_newlines_and_quotes() { + assert_eq!(curl_quote("one\ntwo\"\\"), "\"one\\ntwo\\\"\\\\\""); +} + +#[test] +fn device_record_requires_expected_tag_and_identity_fields() { + let value = json!({ + "id": "node-id", + "hostname": "clamps-test-smoke", + "addresses": ["100.64.0.5", "fd7a:115c:a1e0::5"], + "tags": [SMOKE_TAG] + }); + let record = device_record(&value, SMOKE_TAG).expect("valid tagged device"); + assert_eq!(record.id, "node-id"); + assert_eq!(record.hostname, "clamps-test-smoke"); + assert_eq!(record.addresses.len(), 2); + assert!(device_record(&value, "tag:skillet-server").is_none()); +} diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh index d3935aab..8c8801ac 100644 --- a/ublue/skillet/crates/cli/src/test_entrypoint.sh +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -22,9 +22,10 @@ MOCK set -eu printf '%s\n' "$*" >> /tmp/skillet-test.podman.log case "$1 $2" in - "secret exists") [ -f /tmp/skillet-test-state/secret ] ;; + "secret exists") name=$3; [ -f "/tmp/skillet-test-state/secret-$name" ] ;; "secret inspect") - case "$*" in *'{{.ID}}'*) printf 'skillet-test-secret-id\n' ;; *) cat /tmp/skillet-test-state/secret-hash ;; esac + for name do :; done + case "$*" in *'{{.ID}}'*) printf 'skillet-test-%s-id\n' "$name" ;; *) cat "/tmp/skillet-test-state/secret-hash-$name" ;; esac ;; "secret create") shift 2 @@ -33,9 +34,9 @@ case "$1 $2" in case "$1" in --replace) shift ;; --label) hash=${2#skillet.payload_hash=}; shift 2 ;; *) break ;; esac done name=$1 - cat > /tmp/skillet-test-state/secret-payload - printf '%s\n' "$hash" > /tmp/skillet-test-state/secret-hash - touch /tmp/skillet-test-state/secret + cat > "/tmp/skillet-test-state/secret-payload-$name" + printf '%s\n' "$hash" > "/tmp/skillet-test-state/secret-hash-$name" + touch "/tmp/skillet-test-state/secret-$name" ;; *) exit 2 ;; esac From 86c7c2ad9990703dcc6159ae5ef042841b7993ca Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 30 Sep 2026 22:05:35 +0200 Subject: [PATCH 098/252] docs(tailscale): document provisioning and DNS decisions --- ublue/design/pihole-network.md | 4 +- ublue/design/private-ui-access.md | 69 ++++++++++++------------ ublue/design/secrets.md | 35 ++++++++---- ublue/design/smoke-vms.md | 39 ++++++++------ ublue/plan/CLAMPS-MIGRATION.md | 14 ++--- ublue/skillet/README.md | 90 +++++++++++++++++-------------- 6 files changed, 143 insertions(+), 108 deletions(-) diff --git a/ublue/design/pihole-network.md b/ublue/design/pihole-network.md index 20fa5902..e84a8581 100644 --- a/ublue/design/pihole-network.md +++ b/ublue/design/pihole-network.md @@ -10,8 +10,8 @@ rupik. Keep its web listener on port 8088 inside the bridge; Caddy will proxy to `http://pihole:8088` without publishing the admin port on the host. Pi-hole uses `dns_listeningMode=ALL` for bridge-network clients. Mark its fully qualified registry image for Quadlet auto-update; the OS image already enables -the auto-update timer. The planned private UI route and tailnet DNS dependency -are described in the [private UI design](private-ui-access.md). +the auto-update timer. The planned private UI route and Cloudflare DNS-only +records are described in the [private UI design](private-ui-access.md). The planned second Pi-hole on beelzebot beside clamps is a separate DNS instance on the same power and internet connection. A possible third on remote diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index c61082ea..f91b876b 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -17,30 +17,33 @@ workstation uses a narrowly scoped OAuth client to mint one-use enrollment keys; only those short-lived keys are delivered to a host, never the OAuth client secret. -Use a privately managed production zone rather than `*.ts.net`, so the UI URLs -and Caddy routes survive a move between hosted Tailscale and Headscale. Keep a -separate privately managed zone for disposable smoke-test names and -certificates. Store the literal zones and records outside this public -repository. Caddy gets publicly trusted certificates through Cloudflare -DNS-01 using the existing secret-delivery design. DNS-01 does not require -publicly reachable UI endpoints. +Use host-specific names in existing Cloudflare-managed production and smoke-test +zones rather than `*.ts.net`, so UI URLs and Caddy routes can survive a move +between hosted Tailscale and Headscale. Publish DNS-only A/AAAA records pointing +to each host's tailnet address. Do not enable Cloudflare proxying. Keep literal +zones and records outside this public repository; public DNS still exposes the +names and addresses. Some resolvers block public answers containing tailnet +addresses as DNS rebinding, so check resolution from real clients before +cutover. Caddy gets publicly trusted certificates through Cloudflare DNS-01; +the UIs need no public route. Cloudflare's certificate is not used for DNS-only +records: Caddy serves the certificate. -Hosted Tailscale MagicDNS cannot store arbitrary records. Configure split DNS -for the private UI zone, forwarding to the fleet's Pi-hole resolvers. Each -resolver must return the same host-specific UI records, pointing to the -relevant host's tailnet address. Headscale can instead supply the same names -through its `dns.extra_records`. Moving control planes requires updating the -tailnet addresses and DNS configuration, but not application URLs or Caddy -routes. +Do not configure Tailscale split DNS for the UI names. A VM can enroll before +its DNS record exists. After enrollment, the workstation reads its tailnet +address and creates a DNS-only record with a short-lived token scoped to the +smoke-test zone. VM destruction deletes only its recorded DNS records and +revokes that token. Production records use a separately scoped credential; +update them if a host's tailnet address changes. Tailscale enrollment needs no +DNS-management OAuth scope. Keep the Cloudflare token creator on the workstation. Plan a second Pi-hole on beelzebot beside clamps. They cover individual host failure but share power and internet failure. A third Pi-hole on bender, the calculon replacement in the other country, would add a separate site. -Advertise multiple resolvers for the hosted Tailscale split DNS zone only after -their records and reachability agree; clients may query them in any order, so -none is a fixed primary. A healthy resolver can resolve an offline host's name -but cannot make its UI available. Give each Pi-hole UI its own host-specific -name; do not promise a floating service name without health-aware routing. +These Pi-hole instances provide redundant client DNS service, independent of +UI name publication in Cloudflare. A healthy resolver can resolve an offline +host's name but cannot make its UI available. Give each Pi-hole UI its own +host-specific name; do not promise a floating service name without health-aware +routing. Restrict Caddy's UI listener to tailnet traffic and grant access only to the intended user identities. Tailnet access authorizes the connection; it does not @@ -50,29 +53,29 @@ whether a second login is useful. In particular, Syncthing's current wildcard host publication of GUI port 8384 must be removed or limited to a safe local proxy path before relying on tailnet access alone. -Pi-hole is a dependency for resolving private UI names under hosted Tailscale; -multiple independent resolvers reduce that dependency. Retain access to each -host by its tailnet device name or address, and SSH access for DNS recovery; -these must not depend on Pi-hole. Do not expose an unauthenticated UI as the -recovery path. +Retain access to each host by its tailnet device name or address, and SSH +access for DNS recovery. Do not expose an unauthenticated UI as the recovery +path. ## Why Separate hostnames avoid applications' subpath problems. Tailnet identity and -grants keep admin UIs private without maintaining a public OAuth2 proxy. Caddy -and our own domain names work with either control plane: Tailscale Services -currently requires Tailscale's hosted control plane, while Headscale offers -extra DNS records but not Tailscale Services. +grants keep admin UIs private without maintaining a public OAuth2 proxy. +Cloudflare DNS-only records remove the Pi-hole and tailnet DNS configuration +dependency from UI name resolution, while keeping our own names across control +planes. The names and tailnet IPs are public DNS data. ## Status and references -This is the planned access design; Skillet-managed Tailscale, Caddy, tailnet -DNS, resolver redundancy, and UI isolation have not yet been implemented or -accepted on clamps. The current Pi-hole UI is private to the container bridge; +Skillet now configures the host-network Tailscale container and manages tagged +enrollment for clamps smoke VMs, including device removal during VM disposal. +This has passed workspace checks but still needs live tailnet acceptance. Caddy, +Cloudflare UI records, and UI isolation have not been implemented or accepted +on clamps. The current Pi-hole UI is private to the container bridge; Syncthing's GUI is still published on the host. References: [Tailscale DNS](https://tailscale.com/docs/reference/dns-in-tailscale/), -[Headscale DNS](https://headscale.net/stable/ref/dns/), -[Headscale Services feature gap](https://github.com/juanfont/headscale/issues/2845), +[Cloudflare DNS-only records](https://developers.cloudflare.com/dns/proxy-status/), +[DNS rebinding](https://tailscale.com/docs/reference/faq/dns-rebinding/), [Let's Encrypt DNS-01](https://letsencrypt.org/docs/challenge-types/), [Caddy DNS challenge](https://caddyserver.com/docs/caddyfile/directives/tls). diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index e49bb12a..aaff623f 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -2,8 +2,8 @@ Decision: use KeePassXC for durable secrets and read and write its KDBX database directly from workstation Skillet through a Rust library. Pi-hole delivery uses -an existing entry or creates one for a fresh host; Cloudflare token issuance -remains planned. +an existing entry or creates one for a fresh host. Tailscale OAuth credentials +stay on the workstation and mint short-lived, one-use enrollment keys. ## Portable storage @@ -83,14 +83,29 @@ credential encryption does not encrypt that copy. ## Disposable credentials -Smoke VMs get generated test passwords, never production host entries. Live -ACME testing reads `skillet/cloudflare/token-creator` locally and creates one -short-lived Cloudflare token per VM, with Zone Read and DNS Edit restricted to -the separately managed smoke-test zone. The permission covers the whole zone; -unique test names prevent accidental record collisions, not API access to -other records. Production UI records use a separate private zone. Keep literal -zone names and records outside this public repository. The token creator -remains on the workstation. +Smoke VMs get generated test passwords, never production host entries. Tailscale +enrollment reads `skillet/tailscale/provisioner-client-id` and +`skillet/tailscale/provisioner-client-secret` locally, then mints a one-use, +preauthorized, non-ephemeral key tagged for that smoke VM. The key expires in +one hour if unused and is delivered through the same SSH, systemd, and Podman +secret path as other credentials. The workstation verifies the VM's tailnet +addresses and saves its device ID and addresses in mode-0600 run metadata. On +destroy, Skillet uses `devices:core` to find and remove only the device with the +recorded name, smoke tag, and identity. If enrollment or deletion is +interrupted, pending metadata remains so cleanup can be retried. Production +uses the server tag and the same one-use-key path. + +Live UI/ACME testing will read `skillet/cloudflare/token-creator` locally and mint +one short-lived Cloudflare token per VM, with Zone Read and DNS Edit restricted +to the existing smoke-test zone. Once a VM joins the tailnet, the workstation +will create DNS-only A/AAAA records pointing at its tailnet address; the VM +token also supports Caddy's DNS-01 challenge. DNS Edit covers the whole zone; +unique test names prevent collisions, not access to other records. Production +UI records belong in the existing production zone and use a separate scoped +credential. Keep literal zone names and records outside this public repository. +The token creator remains on the workstation. Cloudflare token issuance and +record management are planned; current smoke provisioning only enrolls +Tailscale and provisions Pi-hole. VM Pi-hole secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, expiry, and owned record IDs in run metadata, without token values. Existing diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 68954c03..4a23cc84 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -3,8 +3,9 @@ Decision: keep VM creation, readiness, repeatable smoke checks, and destruction separate. A retained VM supports inspection and repeated convergence checks; a fresh VM establishes that bootstrap is reproducible. The existing fixture -tests real systemd/Podman convergence. Application and live ACME scenarios, -external token cleanup are planned. Pi-hole credential delivery is implemented. +tests real systemd/Podman convergence. Pi-hole credential delivery and +Tailscale enrollment/device cleanup are implemented. Cloudflare DNS and live +ACME scenarios remain planned. ## Identity and ownership @@ -36,28 +37,34 @@ the test artifacts and uses QEMU user networking. `ready` uses the artifact captured at creation. Explicit `update` builds and installs current host code on a retained VM, recording the deployed hash separately while preserving the original creation snapshot. -3. **Provision applications:** `test vm provision` delivers a generated Pi-hole - credential using the [shared secret mechanism](secrets.md), then performs - full apply. Repeated provisioning reuses the installed credential; `--rotate` - replaces it. Live ACME token delivery remains planned. +3. **Provision applications:** `test vm provision` unlocks the workstation + KeePassXC database, reads the Tailscale OAuth client, mints a one-use smoke + tagged key, and delivers it through the [shared secret mechanism](secrets.md). + It waits for the VM to join and records its device identity before delivering + a generated Pi-hole credential and running full apply. Repeated provisioning + reuses the Tailscale identity and installed Pi-hole credential; `--rotate` + replaces the latter. 4. **Smoke:** run assertions against the retained VM. Check repeat apply, configuration/secret changes, interruption recovery, and reboot persistence. The container sandbox uses mocked systemctl/Podman; only VM checks establish real runtime behavior. Failures retain the VM and diagnostics for repair. -5. **Destroy:** validate ownership, stop the VM, revoke its external tokens and - remove only its recorded test DNS records, then remove the domain, disk, - SSH key, and artifacts. External cleanup is planned; current destroy only - handles local resources. Failed cleanup retains metadata for a retry, even - if the domain is already gone. Token expiry limits abandoned credentials; - it does not remove abandoned DNS records. +5. **Destroy:** validate ownership, remove only the recorded smoke-tagged + Tailscale device, then remove the domain, disk, SSH key, and artifacts. + Failed Tailscale cleanup prevents local destruction and retains metadata for + retry, even if the domain is already gone. Cloudflare records and tokens are + not yet part of the lifecycle. ## Live ACME boundary Routine smoke checks need no Cloudflare access. The optional live scenario -uses Let's Encrypt staging and a unique hostname under the separately managed -smoke-test DNS zone, preserving its other records. Keep the literal zone -outside this public repository. DNS-01 needs no publicly reachable VM. Staging certificates are untrusted; -the test verifies their expected identity using explicit staging trust. +enrolls the VM in Tailscale, obtains its tailnet IP, and creates a DNS-only +A/AAAA record under the existing smoke-test Cloudflare zone using a per-VM +token minted by the workstation token creator. It uses Let's Encrypt staging +and a unique hostname, preserving the zone's other records. Keep the literal +zone outside this public repository. DNS-01 needs no publicly reachable VM. +Staging certificates are untrusted; the test verifies their expected identity +using explicit staging trust. Test name resolution through a client's actual +resolver because some resolvers block public answers containing tailnet IPs. Normal reruns reuse credentials and certificate state; fresh issuance is an explicit scenario. Production DNS cutover is separate from VM acceptance. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 399fd624..081e7287 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -17,11 +17,11 @@ Pi-hole container runs in the VM with data and Podman storage under `/var/lib/data`; service networking, live ACME, final disk selection, and post-provision rebase checks remain open. -Next: port Tailscale to clamps, deciding its enrollment/authentication and -tailnet DNS inputs before provisioning. Then implement Caddy with Cloudflare -DNS-01 staging for private Pi-hole and Syncthing UIs. The private-UI design -requires a tailnet-only listener and consistent split-DNS answers from the -Pi-hole resolvers, so certificate issuance alone is not the acceptance target. +Next: validate live Tailscale enrollment and cleanup on the clamps smoke VM, +then implement Caddy with Cloudflare DNS-01 staging for private +Pi-hole and Syncthing UIs. The private-UI design requires a tailnet-only +listener and DNS-only Cloudflare records pointing to tailnet addresses, so +certificate issuance alone is not the acceptance target. Pi-hole LAN-client reachability and final custom DNS records also remain open; production DNS stays on rupik. Syncthing passed the named disposable-VM service and repeat-apply checks, and the [btrbk milestone](CLAMPS-BTRBK.md) is complete. @@ -91,8 +91,8 @@ Each row is a separate implementation and validation step. Adjust order for depe | 1 | Syncthing (implemented; disposable VM service and repeat-apply checks passed) | Data, device identity, folder configuration, UID/GID, required ports | | 2 | btrbk (implemented and verified) | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | | 3 | UniFi | Controller data or supported backup restore, version compatibility, adoption and ownership | -| 4 | Tailscale (next) | Authentication/identity policy, exit-node approval, forwarding, persistent state, DNS policy and hardware interface settings. This is a prerequisite for accepting tailnet-only Caddy UI access. | -| 5 | Private UI access with Caddy + ACME | Implement the [private UI design](../design/private-ui-access.md): host-specific names, hosted Tailscale split DNS with consistent reachable Pi-hole resolvers, tailnet-only access, Syncthing GUI isolation, and DNS recovery; validate Cloudflare DNS-01 with staging, per-VM tokens, certificate persistence and cleanup. Add public OAuth2 proxying only for an explicitly required public UI. | +| 4 | Tailscale (implemented; live VM acceptance next) | Host-network container, persistent state, KeePassXC OAuth client, one-use tagged key delivery, and smoke device cleanup are implemented. Validate OAuth scopes, tailnet enrollment, removal, re-provisioning, and forwarding. UI names use Cloudflare DNS-only records; Tailscale DNS management is not required. This is a prerequisite for accepting tailnet-only Caddy UI access. | +| 5 | Private UI access with Caddy + ACME | Implement the [private UI design](../design/private-ui-access.md): host-specific names, DNS-only Cloudflare A/AAAA records pointing to tailnet addresses, tailnet-only access, Syncthing GUI isolation, and DNS recovery. Mint per-VM zone-scoped tokens from the workstation creator; after enrollment, create test records and check client resolution, including DNS rebinding filtering. Validate DNS-01 with staging, certificate persistence, record/token cleanup, repeated runs and interruption recovery. Add public OAuth2 proxying only for an explicitly required public UI. | | 6 | Cloudflare DDNS | Required records and token delivery; reconcile the legacy updater before enabling competing writers | | 7 | Monitoring and remaining host baseline | Explicit keep/drop decision for Datadog; required hardening, users, SSH/sudo, ET and WOL behavior | diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index af99abaf..a794e45f 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -144,8 +144,10 @@ cargo run --release -p skillet -- test vm provision clamps smoke The password stays encrypted on the VM. Repeat this command after a reboot to reuse it; use `--rotate` to replace it explicitly. A fresh VM starts with a -new password. The command requires the `skillet-full-apply.service` supplied -by current Butane, so recreate older test VMs made before that unit existed. +new password. Provisioning also requires the current Butane +`skillet-full-apply.service` to load both the Pi-hole and Tailscale credentials. +Skillet checks this before unlocking the vault and tells you to recreate older +test VMs that lack the Tailscale credential load. If an existing VM credential is corrupt, use `--rotate` to replace only that disposable value. @@ -181,27 +183,25 @@ entry before removing the old copy. The current smoke command does not use it. ### Tailscale setup for Skillet -Skillet will run the official `tailscale/tailscale` container with -`Network=host`, matching the current Chef configuration on rupik, calculon, -and boxy. This gives Tailscale a normal host interface for host-level routing -and port forwarding. Persist its state under `/var/lib/data/tailscale`. Do not -put the OAuth client secret or a reusable auth key on the host. See the -[private UI design](../design/private-ui-access.md) for the network decision. +Clamps runs `tailscale/tailscale` as a host-network container, matching Chef +on rupik, calculon, and boxy. Its state persists under +`/var/lib/data/tailscale`. Skillet reads the workstation OAuth client from +KeePassXC, mints a one-use enrollment key, and sends only that key to the host +as an encrypted systemd credential and Podman secret. Smoke VM destruction +removes the matching `tag:skillet-smoke` device before deleting the VM. This +requires `curl` on the workstation. See the +[private UI design](../design/private-ui-access.md). 1. In the Tailscale admin console, open **Access controls** and ensure the - policy defines separate tags for production servers and smoke VMs, such as - `tag:skillet-server` and `tag:skillet-smoke`. Give each tag only the access - that role needs. Make a provisioning tag the owner of those tags, following - the existing `tagOwners` policy pattern; do not give smoke VMs production - server permissions. -2. Open **Trust credentials** and create an **OAuth** credential for Skillet. - Grant **Keys > Auth Keys: Write** (`auth_keys`) and restrict it to the - provisioning tag from step 1. This lets Skillet mint a tagged enrollment - key per target. Keys should be one-use, preauthorized, and non-ephemeral so - a retained smoke VM stays registered through shutdowns. Until automated - device cleanup is implemented, remove a destroyed VM from **Machines** in - the admin console; revoking its auth key does not remove an enrolled device. - Automated cleanup will require a separately reviewed `devices:core` scope. + policy defines `tag:skillet-provisioner`, `tag:skillet-server`, and + `tag:skillet-smoke`. Set `tagOwners` so the provisioner tag owns the server + and smoke tags. Give smoke devices only smoke access. +2. In **Trust credentials**, create an OAuth client restricted to + `tag:skillet-provisioner`, with both **Keys > Auth Keys: Write** (`auth_keys`) + and **Devices > Core: Read/Write** (`devices:core`) scopes. The first mints + enrollment keys; the second lets Skillet find and remove its tagged smoke + device. Do not grant DNS-management scopes. Keys are one-use, preauthorized, + non-ephemeral, and expire after one hour if unused. 3. Copy the OAuth **Client ID** and **Client secret** from the creation page. In KeePassXC create two entries, putting each value in its **Password** field: @@ -216,25 +216,35 @@ put the OAuth client secret or a reusable auth key on the host. See the mint new auth keys, so treat it as the workstation-side master credential. Never put it in a VM, Quadlet, environment file, or repository. Close KeePassXC before Skillet edits the database. -4. In the Tailscale admin console, open **DNS** and leave **Override DNS - servers** off. Under **Nameservers**, choose **Add nameserver > Custom**. - Add the numeric Tailscale IPv4 address of each Pi-hole that is reachable - over Tailscale, enable **Restrict to search domain**, and enter the private - UI zone. Add a resolver only after it is reachable and serves the same - records as the others. Clients may query resolvers in any order. -5. Configure production and smoke-test UI zones outside this public repository. - Use a restricted nameserver for each zone only when its Pi-hole resolver has - the matching local records. Once clamps and its Pi-hole are enrolled, add - identical production records to every configured Pi-hole, using the form - `..` and resolving to that host's - Tailscale address. Keep smoke-test records under a separate zone and point - them at the disposable VM's Tailscale address. Caddy obtains trusted - certificates through Cloudflare DNS-01; UI A/AAAA records do not need to be - public. - -The Tailscale vault entries and split DNS are not consumed/configured by -Skillet yet. This guide prepares the tailnet and vault for that milestone; -Skillet support and disposable-VM validation remain required. +4. In each existing Cloudflare zone, create DNS-only (grey cloud) A/AAAA UI + records pointing to the enrolled host's Tailscale address. Use the + production zone for production hosts and the smoke-test zone for disposable + VMs. No delegated subzone or Tailscale split DNS configuration is required. + Test lookup using a tailnet client's normal resolver. Caddy obtains its own + certificate through Cloudflare DNS-01. +5. For a disposable VM, run `cargo run --release -p skillet -- test vm provision clamps smoke`. + Skillet prompts to unlock KeePassXC when its three-hour kernel cache is + empty, enrolls the VM, then provisions Pi-hole. Repeat the command to reuse + the Tailscale identity and Pi-hole credential. Dispose of the VM with + `cargo run --release -p skillet -- test vm destroy clamps smoke`. If + Tailscale cleanup fails, destruction stops and the VM metadata remains for + retry. Keep the OAuth client available for cleanup. +6. For production, deliver the Pi-hole credential first, then Tailscale: + + ```bash + cargo run --release -p skillet -- secret deliver clamps tailscale \ + --target giacomo@clamps --identity /path/to/ssh-key \ + --known-hosts /path/to/known_hosts + ``` + + This mints a production-tagged key; full apply then configures both + services. Preserve the encrypted KeePassXC entries so another workstation + can reprovision credentials. + +Cloudflare DNS record automation and Caddy remain future work. Keep the +Cloudflare token creator on the workstation; Tailscale enrollment needs no +Cloudflare or Tailscale DNS permissions. See the +[VM lifecycle](../design/smoke-vms.md). ## Development checks From 732634fe2e6369fdcf1869a6e7e0545547040747 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 1 Oct 2026 11:54:52 +0200 Subject: [PATCH 099/252] fix(syncthing): keep the GUI on the private container network --- ublue/skillet/crates/syncthing/src/lib.rs | 2 -- ublue/skillet/crates/syncthing/src/tests.rs | 8 ++++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index 77028839..6122cbaf 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -56,8 +56,6 @@ where "ContainerName=syncthing".to_string(), format!("Environment=PGID={}", config.gid), format!("Environment=PUID={}", config.uid), - "PublishPort=[::]:8384:8384/tcp".to_string(), - "PublishPort=0.0.0.0:8384:8384/tcp".to_string(), "PublishPort=[::]:22000:22000/tcp".to_string(), "PublishPort=0.0.0.0:22000:22000/tcp".to_string(), "PublishPort=[::]:22000:22000/udp".to_string(), diff --git a/ublue/skillet/crates/syncthing/src/tests.rs b/ublue/skillet/crates/syncthing/src/tests.rs index ed39b5da..10d6ab1d 100644 --- a/ublue/skillet/crates/syncthing/src/tests.rs +++ b/ublue/skillet/crates/syncthing/src/tests.rs @@ -39,8 +39,6 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { "Environment=PGID=1000", "Environment=PUID=1000", "Network=clamps.network", - "PublishPort=[::]:8384:8384/tcp", - "PublishPort=0.0.0.0:8384:8384/tcp", "PublishPort=[::]:22000:22000/tcp", "PublishPort=0.0.0.0:22000:22000/tcp", "PublishPort=[::]:22000:22000/udp", @@ -49,4 +47,10 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { ] { assert!(quadlet.contains(directive), "missing directive {directive}"); } + assert!( + !quadlet + .lines() + .any(|line| line.starts_with("PublishPort=") && line.contains("8384")), + "the GUI must only be reachable through the private reverse proxy" + ); } From 8ffaf39c0f4bfffed33dedc5ff751b5fa19982c1 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 1 Oct 2026 11:54:58 +0200 Subject: [PATCH 100/252] feat(credentials): allow installation before service activation --- ublue/skillet/crates/cli-common/src/credential.rs | 10 ++++++---- ublue/skillet/crates/cli-common/src/lib.rs | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/ublue/skillet/crates/cli-common/src/credential.rs b/ublue/skillet/crates/cli-common/src/credential.rs index d8122515..bd5c0d8e 100644 --- a/ublue/skillet/crates/cli-common/src/credential.rs +++ b/ublue/skillet/crates/cli-common/src/credential.rs @@ -46,7 +46,7 @@ pub fn state(name: &str) -> Result<&'static str, CredentialError> { } } -pub fn install(name: &str, unit: &str) -> Result<(), CredentialError> { +pub fn install(name: &str, unit: &str, start_unit: bool) -> Result<(), CredentialError> { let path = credential_path(name)?; if !unit.ends_with(".service") || !unit @@ -86,9 +86,11 @@ pub fn install(name: &str, unit: &str) -> Result<(), CredentialError> { } encrypted.persist(path).map_err(|error| error.error)?; fs::File::open(&directory)?.sync_all()?; - let status = Command::new("systemctl").args(["start", unit]).status()?; - if !status.success() { - return Err(CredentialError::Command("systemctl start")); + if start_unit { + let status = Command::new("systemctl").args(["start", unit]).status()?; + if !status.success() { + return Err(CredentialError::Command("systemctl start")); + } } Ok(()) } diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 5cee2385..1abb7ee5 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -61,7 +61,13 @@ pub enum CredentialCommands { /// Print `present` if a host credential or Podman secret exists, otherwise `absent` State { name: String }, /// Encrypt stdin for this host, install it, and start the consuming unit - Install { name: String, unit: String }, + Install { + name: String, + unit: String, + /// Save the encrypted credential without starting its consuming unit. + #[arg(long)] + no_start: bool, + }, } pub fn run_host(hostname: &str, apply_fn: F) -> Result<(), CliCommonError> @@ -89,7 +95,11 @@ where CredentialCommands::State { name } => { println!("{}", credential::state(&name)?); } - CredentialCommands::Install { name, unit } => credential::install(&name, &unit)?, + CredentialCommands::Install { + name, + unit, + no_start, + } => credential::install(&name, &unit, !no_start)?, } Ok(()) } From 875deca47786da04cf9a0606cea030bed17645a4 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 1 Oct 2026 11:55:10 +0200 Subject: [PATCH 101/252] feat(caddy): add private UI configuration and credential delivery --- ublue/butane/includes/skillet.bu | 21 +++ ublue/skillet/Cargo.lock | 13 ++ ublue/skillet/Cargo.toml | 2 + ublue/skillet/README.md | 33 +++- ublue/skillet/crates/caddy/Cargo.toml | 14 ++ ublue/skillet/crates/caddy/src/lib.rs | 171 ++++++++++++++++++ ublue/skillet/crates/caddy/src/tests.rs | 36 ++++ ublue/skillet/crates/cli-common/Cargo.toml | 2 + ublue/skillet/crates/cli-common/src/hosts.rs | 32 ++++ ublue/skillet/crates/cli/src/main.rs | 3 +- .../skillet/crates/cli/src/secret_delivery.rs | 81 ++++++++- 11 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 ublue/skillet/crates/caddy/Cargo.toml create mode 100644 ublue/skillet/crates/caddy/src/lib.rs create mode 100644 ublue/skillet/crates/caddy/src/tests.rs diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index 8604d67f..5036b4dd 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -42,5 +42,26 @@ systemd: LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase full + [Install] + WantedBy=multi-user.target + - name: skillet-caddy-apply.service + enabled: true + contents: | + [Unit] + Description=Apply Skillet Caddy private UI configuration + Requires=skillet-full-apply.service + After=skillet-full-apply.service + Requires=skillet-data-prepare.service + After=skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data + ConditionPathExists=/etc/credstore.encrypted/skillet/caddy_sites.cred + ConditionPathExists=/etc/credstore.encrypted/skillet/cloudflare_acme_token.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=caddy_sites:/etc/credstore.encrypted/skillet/caddy_sites.cred + LoadCredentialEncrypted=cloudflare_acme_token:/etc/credstore.encrypted/skillet/cloudflare_acme_token.cred + ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase caddy + [Install] WantedBy=multi-user.target diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index bba5c081..06d1c2d7 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1559,14 +1559,27 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "skillet_caddy" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "skillet_core", + "skillet_podman", + "thiserror 1.0.69", +] + [[package]] name = "skillet_cli_common" version = "0.1.0" dependencies = [ "clap", "serde", + "serde_json", "serde_yml", "skillet_btrbk", + "skillet_caddy", "skillet_core", "skillet_hardening", "skillet_pihole", diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index aba225c8..0e63cd9c 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/pihole", "crates/syncthing", "crates/btrbk", + "crates/caddy", "crates/podman", "crates/cli", "crates/hosts/beezelbot", @@ -19,6 +20,7 @@ skillet_hardening = { path = "crates/hardening" } skillet_pihole = { path = "crates/pihole" } skillet_syncthing = { path = "crates/syncthing" } skillet_btrbk = { path = "crates/btrbk" } +skillet_caddy = { path = "crates/caddy" } skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } thiserror = "1.0" diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index a794e45f..c1103ac8 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -181,6 +181,33 @@ permissions. Save it as `skillet/cloudflare/token-creator`. If already stored in the desktop keyring, move that value into KeePassXC and verify the saved entry before removing the old copy. The current smoke command does not use it. +### Caddy UI credentials + +Caddy proxies the Pi-hole and Syncthing UIs on port 443 and rejects clients +outside the Tailscale IPv4 and IPv6 ranges. Its credentials are independent of +normal full apply. In KeePassXC, store each value in the Password field: + +| Group path | Entry title | Value | +| --- | --- | --- | +| `skillet/hosts/clamps/caddy` | `pihole-hostname` | Private Pi-hole UI hostname | +| `skillet/hosts/clamps/caddy` | `syncthing-hostname` | Private Syncthing UI hostname | +| `skillet/hosts/clamps/cloudflare` | `acme-token` | Cloudflare API token with Zone Read and DNS Edit, scoped to the production zone | + +Create DNS-only A/AAAA records for those names pointing to the clamps Tailscale +addresses. Then deliver the credentials over a host-key-verified SSH session: + +```bash +cargo run --release -p skillet -- secret deliver clamps caddy \ + --target giacomo@clamps --identity /path/to/ssh-key \ + --known-hosts /path/to/known_hosts +``` + +Skillet keeps the hostname configuration private in the KeePassXC database and +encrypted host credential. The Cloudflare token becomes a Podman secret used +only by Caddy's DNS-01 provider. The command installs both encrypted host +credentials before starting its dedicated apply service. The smoke VM staging +path and automatic DNS/token lifecycle are still being implemented. + ### Tailscale setup for Skillet Clamps runs `tailscale/tailscale` as a host-network container, matching Chef @@ -241,9 +268,9 @@ requires `curl` on the workstation. See the services. Preserve the encrypted KeePassXC entries so another workstation can reprovision credentials. -Cloudflare DNS record automation and Caddy remain future work. Keep the -Cloudflare token creator on the workstation; Tailscale enrollment needs no -Cloudflare or Tailscale DNS permissions. See the +Cloudflare DNS record automation and smoke-specific Caddy staging remain future +work. Keep the Cloudflare token creator on the workstation; Tailscale +enrollment needs no Cloudflare or Tailscale DNS permissions. See the [VM lifecycle](../design/smoke-vms.md). ## Development checks diff --git a/ublue/skillet/crates/caddy/Cargo.toml b/ublue/skillet/crates/caddy/Cargo.toml new file mode 100644 index 00000000..0cb9e891 --- /dev/null +++ b/ublue/skillet/crates/caddy/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "skillet_caddy" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs new file mode 100644 index 00000000..87f94923 --- /dev/null +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -0,0 +1,171 @@ +//! Caddy reverse proxy for administrative interfaces. + +use serde::Deserialize; +use skillet_core::files::{FileError, FileResource}; +use skillet_core::system::{SystemError, SystemResource}; +use skillet_podman::{ + self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, QuadletSecret, SecretTarget, + Volume, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CaddySites { + pub pihole: String, + pub syncthing: String, + #[serde(default)] + pub acme_staging: bool, +} + +#[derive(Debug, Error)] +pub enum CaddyError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), + #[error("invalid Caddy site hostname: {0}")] + InvalidHostname(String), + #[error("invalid Caddy sites credential: {0}")] + InvalidSites(#[from] serde_json::Error), +} + +impl CaddySites { + pub fn parse(contents: &str) -> Result { + let sites: Self = serde_json::from_str(contents)?; + validate_hostname(&sites.pihole)?; + validate_hostname(&sites.syncthing)?; + if sites.pihole == sites.syncthing { + return Err(CaddyError::InvalidHostname(sites.pihole)); + } + Ok(sites) + } + + fn render(&self) -> String { + let acme = if self.acme_staging { + " acme_ca https://acme-staging-v02.api.letsencrypt.org/directory\n" + } else { + "" + }; + format!( + "{{\n admin off\n{acme} acme_dns cloudflare {{env.CF_API_TOKEN}}\n}}\n\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet 403\n reverse_proxy pihole:8088\n}}\n\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet 403\n reverse_proxy syncthing:8384\n}}\n", + self.pihole, self.syncthing + ) + } +} + +fn validate_hostname(hostname: &str) -> Result<(), CaddyError> { + let valid = hostname.len() <= 253 + && hostname.contains('.') + && hostname.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }); + if valid { + Ok(()) + } else { + Err(CaddyError::InvalidHostname(hostname.to_string())) + } +} + +pub fn apply( + system: &S, + files: &F, + sites: &CaddySites, + network: PodmanNetwork, +) -> Result<(), CaddyError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + let caddyfile = sites.render(); + let config_dir = Path::new("/etc/skillet/caddy"); + files.ensure_directory(config_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_file( + &config_dir.join("Caddyfile"), + caddyfile.as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?; + + let mut container = BTreeMap::new(); + container.insert( + "Container".to_string(), + vec![ + "AutoUpdate=registry".to_string(), + "ContainerName=caddy".to_string(), + "PublishPort=[::]:443:443/tcp".to_string(), + "PublishPort=0.0.0.0:443:443/tcp".to_string(), + ], + ); + container.insert("Service".to_string(), vec!["Restart=always".to_string()]); + container.insert( + "Unit".to_string(), + vec![ + "Description=Caddy private UI reverse proxy".to_string(), + "After=network-online.target".to_string(), + "Requires=skillet-data-prepare.service".to_string(), + "After=skillet-data-prepare.service".to_string(), + "BindsTo=var-lib-data.mount".to_string(), + "After=var-lib-data.mount".to_string(), + "AssertPathIsMountPoint=/var/lib/data".to_string(), + ], + ); + + let config = PodmanConfig { + name: "caddy".to_string(), + image: "ghcr.io/caddybuilds/caddy-cloudflare:2".to_string(), + networks: vec![network], + user: ContainerUser { + container_uid: 0, + container_gid: 0, + host_user: None, + }, + create_host_user: false, + volumes: vec![ + Volume { + host_path: "/etc/skillet/caddy/Caddyfile".to_string(), + container_path: "/etc/caddy/Caddyfile".to_string(), + options: Some("ro,Z".to_string()), + }, + Volume { + host_path: "/var/lib/data/caddy/data".to_string(), + container_path: "/data".to_string(), + options: Some("Z".to_string()), + }, + Volume { + host_path: "/var/lib/data/caddy/config".to_string(), + container_path: "/config".to_string(), + options: Some("Z".to_string()), + }, + ], + secrets: vec![QuadletSecret { + secret_name: "cloudflare_acme_token".to_string(), + target: SecretTarget::Environment { + env_var_name: "CF_API_TOKEN".to_string(), + }, + }], + config_revisions: vec![caddyfile.into_bytes()], + extra_config: container, + }; + // Preserve the caller's network configuration and let Podman create its + // persistent directories on the shared data filesystem. + for path in ["/var/lib/data/caddy/data", "/var/lib/data/caddy/config"] { + files.ensure_directory(Path::new(path), Some(0o755), Some("root"), Some("root"))?; + } + skillet_podman::container(system, files, config)?; + Ok(()) +} diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs new file mode 100644 index 00000000..e41b5b39 --- /dev/null +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -0,0 +1,36 @@ +use super::CaddySites; + +#[test] +fn rejects_invalid_duplicate_and_unknown_site_configuration() { + for payload in [ + r#"{"pihole":"pihole.example.invalid\n}","syncthing":"sync.example.invalid"}"#, + r#"{"pihole":"https://pihole.example.invalid","syncthing":"sync.example.invalid"}"#, + r#"{"pihole":"same.example.invalid","syncthing":"same.example.invalid"}"#, + r#"{"pihole":"pihole.example.invalid","syncthing":"sync.example.invalid","unknown":true}"#, + ] { + assert!(CaddySites::parse(payload).is_err()); + } +} + +#[test] +fn staging_routes_use_bridge_dns_and_restrict_both_address_families() { + let sites = CaddySites::parse( + r#"{"pihole":"pihole.example.invalid","syncthing":"sync.example.invalid","acme_staging":true}"#, + ) + .unwrap(); + let config = sites.render(); + assert!(config.contains("https://acme-staging-v02.api.letsencrypt.org/directory")); + assert!(config.contains("acme_dns cloudflare {env.CF_API_TOKEN}")); + assert!(config.contains("reverse_proxy pihole:8088")); + assert!(config.contains("reverse_proxy syncthing:8384")); + assert_eq!(config.matches("respond @outside_tailnet 403").count(), 2); + assert_eq!( + config.matches("100.64.0.0/10 fd7a:115c:a1e0::/48").count(), + 2 + ); + let production = CaddySites::parse( + r#"{"pihole":"pihole.example.invalid","syncthing":"sync.example.invalid"}"#, + ) + .unwrap(); + assert!(!production.render().contains("acme_ca")); +} diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index a19656ea..3dcd19e8 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -12,6 +12,7 @@ skillet_hardening.workspace = true skillet_pihole.workspace = true skillet_syncthing.workspace = true skillet_btrbk.workspace = true +skillet_caddy.workspace = true skillet_podman.workspace = true users.workspace = true clap.workspace = true @@ -20,6 +21,7 @@ tracing-subscriber.workspace = true thiserror.workspace = true serde.workspace = true serde_yml.workspace = true +serde_json.workspace = true tempfile.workspace = true [dev-dependencies] diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index e83aed78..380b7dce 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -20,6 +20,7 @@ use thiserror::Error; pub enum ApplyPhase { Base, Full, + Caddy, } #[derive(Error, Debug)] @@ -38,6 +39,8 @@ pub enum ApplyError { Syncthing(#[from] skillet_syncthing::SyncthingError), #[error("Btrbk apply error: {0}")] Btrbk(#[from] skillet_btrbk::BtrbkError), + #[error("Caddy apply error: {0}")] + Caddy(#[from] skillet_caddy::CaddyError), #[error("Podman error: {0}")] Podman(#[from] skillet_podman::PodmanError), #[error("Fixture input error: {0}")] @@ -93,6 +96,10 @@ pub const PIHOLE_WEB_PASSWORD_CREDENTIAL: &str = "pihole_web_password"; /// Name of the systemd credential (and Podman secret) holding the one-use /// Tailscale enrollment key used when the node has not joined yet. pub const TAILSCALE_AUTH_KEY_CREDENTIAL: &str = "tailscale_auth_key"; +/// Credential containing private Caddy hostnames and the ACME environment. +pub const CADDY_SITES_CREDENTIAL: &str = "caddy_sites"; +/// Cloudflare token used only by Caddy's DNS-01 challenge provider. +pub const CLOUDFLARE_ACME_TOKEN_CREDENTIAL: &str = "cloudflare_acme_token"; /// Custom DNS records for the clamps Pi-hole (`ip -> fqdn`). // TODO: replace with the real LAN IP and domain before the production @@ -307,5 +314,30 @@ pub fn apply_host_phase( match phase { ApplyPhase::Base => apply_base(system, files), ApplyPhase::Full => apply_host(hostname, system, files), + ApplyPhase::Caddy => apply_caddy_host(hostname, system, files), } } + +fn apply_caddy_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, +) -> Result<(), ApplyError> { + if hostname != "clamps" { + return Err(ApplyError::FixtureInput( + "Caddy UI configuration is currently defined only for clamps".to_string(), + )); + } + files.require_btrfs_subvolume_mount( + std::path::Path::new("/var/lib/data"), + std::path::Path::new("/var"), + "/data", + )?; + let credentials = CredentialManager::new()?; + let sites = + skillet_caddy::CaddySites::parse(&credentials.read_secret(CADDY_SITES_CREDENTIAL)?)?; + let token = credentials.read_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL)?; + system.ensure_podman_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL, &token)?; + skillet_caddy::apply(system, files, &sites, clamps_service_network())?; + Ok(()) +} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index f6520a06..4eeef9f1 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -65,7 +65,7 @@ struct SecretLockArgs { struct SecretDeliverArgs { #[arg(value_parser = ["clamps"])] hostname: String, - #[arg(value_parser = ["pihole", "tailscale"])] + #[arg(value_parser = ["pihole", "tailscale", "caddy"])] service: String, #[arg(long)] database: Option, @@ -524,6 +524,7 @@ fn run_twice_and_check(name: &str, phase: ApplyPhase, inspect: bool) -> Result<( let phase = match phase { ApplyPhase::Base => "base", ApplyPhase::Full => "full", + ApplyPhase::Caddy => "caddy", }; for round in ["first", "second"] { let status = Command::new("podman") diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 8f296e81..75998728 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -56,6 +56,52 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { let mut command = ssh_command(args); install(&mut command, "tailscale_auth_key", &auth_key.key) } + "caddy" => { + let pihole = lookup( + &vault.database, + &format!("skillet/hosts/{}/caddy/pihole-hostname", args.hostname), + )? + .ok_or_else(|| anyhow!("KeePassXC Pi-hole Caddy hostname entry is missing"))?; + let syncthing = lookup( + &vault.database, + &format!("skillet/hosts/{}/caddy/syncthing-hostname", args.hostname), + )? + .ok_or_else(|| anyhow!("KeePassXC Syncthing Caddy hostname entry is missing"))?; + let token = lookup( + &vault.database, + &format!("skillet/hosts/{}/cloudflare/acme-token", args.hostname), + )? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare ACME token entry is missing"))?; + ensure_vault_unchanged(&vault)?; + let sites = serde_json::json!({ + "pihole": pihole.trim(), + "syncthing": syncthing.trim(), + "acme_staging": false, + }) + .to_string(); + let mut command = ssh_command(args); + install_deferred_for_unit( + &mut command, + "caddy_sites", + "skillet-caddy-apply.service", + &sites, + )?; + let mut command = ssh_command(args); + install_deferred_for_unit( + &mut command, + "cloudflare_acme_token", + "skillet-caddy-apply.service", + &token, + )?; + let status = ssh_command(args) + .arg("sudo -n systemctl start skillet-caddy-apply.service") + .status() + .context("starting Caddy apply after both credentials were delivered")?; + if !status.success() { + return Err(anyhow!("Caddy apply failed with status {status}")); + } + Ok(()) + } _ => Err(anyhow!("unsupported secret service {}", args.service)), } } @@ -709,11 +755,44 @@ fn save_vault( } fn install(command: &mut Command, credential: &str, secret: &str) -> Result<()> { + install_for_unit(command, credential, "skillet-full-apply.service", secret) +} + +fn install_for_unit( + command: &mut Command, + credential: &str, + unit: &str, + secret: &str, +) -> Result<()> { + install_for_unit_inner(command, credential, unit, secret, false) +} + +fn install_deferred_for_unit( + command: &mut Command, + credential: &str, + unit: &str, + secret: &str, +) -> Result<()> { + install_for_unit_inner(command, credential, unit, secret, true) +} + +fn install_for_unit_inner( + command: &mut Command, + credential: &str, + unit: &str, + secret: &str, + defer_start: bool, +) -> Result<()> { if secret.is_empty() { return Err(anyhow!("refusing to deliver an empty credential")); } + let mut remote = + format!("sudo -n /var/usrlocal/bin/skillet-clamps credential install {credential} {unit}"); + if defer_start { + remote.push_str(" --no-start"); + } let mut child = command - .arg(format!("sudo -n /var/usrlocal/bin/skillet-clamps credential install {credential} skillet-full-apply.service")) + .arg(remote) .stdin(Stdio::piped()) .spawn() .context("opening SSH credential delivery")?; From 5d4e5d0e04af0cc81a2f7d4f284c5a57a527cdcc Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 1 Oct 2026 11:55:22 +0200 Subject: [PATCH 102/252] docs(ublue): plan generic UI provisioning and record validation --- ublue/butane/ACCEPTANCE.md | 25 ++++ ublue/design/private-ui-access.md | 22 +++- ublue/design/secrets.md | 19 ++- ublue/plan/CLAMPS-MIGRATION.md | 11 +- ublue/plan/GENERIC-PRIVATE-UIS.md | 184 ++++++++++++++++++++++++++++++ 5 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 ublue/plan/GENERIC-PRIVATE-UIS.md diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 3ccc5355..5a8b7614 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -310,3 +310,28 @@ host tool; it still uses normal command approvals. subvolume. The user confirms Syncthing data is designed without nested subvolumes; the btrbk design treats selected sources as leaf subvolumes and does not support nested subvolumes. + +## Private UI preparation, 2026-10-01 + +- Earlier in this session, a fresh clamps smoke VM completed Ignition, + unsigned then signed uCore rebase, user environment setup, application + provisioning, and Tailscale enrollment. `skillet test smoke clamps` passed + the real-runtime fixture checks, including interrupted activation and reboot. +- Pi-hole, Syncthing, and Tailscale were active. Container DNS resolved Pi-hole + from Syncthing; both web interfaces returned HTML through the shared bridge. + Pi-hole published TCP/UDP port 53 on both address families. Syncthing did + not publish its GUI port 8384 on the host. Repeated full apply preserved + the Pi-hole and Syncthing container IDs. +- The selected Caddy image was pulled and `caddy list-modules` confirmed + `dns.providers.cloudflare`. The dedicated apply unit skipped activation + without its two encrypted credentials. Caddy certificate issuance, DNS + record lifecycle, proxy access restrictions, and renewal were **not tested**. + The generic UI plan tracks the missing shared delivery and live acceptance. +- Before committing, local formatting, pedantic Clippy, workspace tests, and + the CI command `skillet test run beezelbot --phase base --image fedora:latest` + were run. The first test run caught stale Syncthing GUI publication assertions; + they now assert GUI isolation while retaining transfer-port checks. Caddy + tests cover rejected input, staging selection, bridge upstreams, and both + tailnet address families. These tests do not establish live ACME acceptance. +- The prior VM is no longer present when listing runs before this commit; + no new live Caddy acceptance is claimed from the local checks. diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index f91b876b..022c9f89 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -67,12 +67,22 @@ planes. The names and tailnet IPs are public DNS data. ## Status and references -Skillet now configures the host-network Tailscale container and manages tagged -enrollment for clamps smoke VMs, including device removal during VM disposal. -This has passed workspace checks but still needs live tailnet acceptance. Caddy, -Cloudflare UI records, and UI isolation have not been implemented or accepted -on clamps. The current Pi-hole UI is private to the container bridge; -Syncthing's GUI is still published on the host. +Planned generalization: host callers declare their UI services, and a shared +provisioner derives `..` from an environment's domain +in KeePassXC. Production and test select different configuration and credential +lifetimes; Caddy and delivery share one implementation. This supersedes the +current per-service hostname entries and clamps-only restrictions when +implemented. See the [implementation plan](../plan/GENERIC-PRIVATE-UIS.md). + +Skillet configures host-network Tailscale and manages tagged enrollment and +removal for clamps smoke VMs; live smoke enrollment and cleanup were accepted +on 2026-09-30. Caddy now has a Quadlet configuration for Pi-hole +and Syncthing using bridge DNS, persistent certificate storage, Cloudflare +DNS-01, and source-address restrictions for Tailscale's IPv4 and IPv6 ranges. +Its hostname and token credentials are separate from normal full apply. +Syncthing no longer publishes its GUI port on the host. Caddy, ACME staging, +DNS record lifecycle, client reachability, certificate renewal, and interrupted +recovery still need live VM acceptance. References: [Tailscale DNS](https://tailscale.com/docs/reference/dns-in-tailscale/), [Cloudflare DNS-only records](https://developers.cloudflare.com/dns/proxy-status/), diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index aaff623f..00b1d71c 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -1,5 +1,12 @@ # Secret storage and delivery +Planned UI generalization: select a base domain and Cloudflare zone from +environment entries in KeePassXC, derive service names from the host caller's +declarations, and share delivery across persistent and disposable targets. +The [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md) defines the proposed vault +paths and migration. Existing per-service hostname entries and clamps-only +Caddy delivery below describe current code, not the intended shared interface. + Decision: use KeePassXC for durable secrets and read and write its KDBX database directly from workstation Skillet through a Rust library. Pi-hole delivery uses an existing entry or creates one for a fresh host. Tailscale OAuth credentials @@ -39,6 +46,8 @@ The prefix is consistently singular, `skillet`: | Vault entry | Host credential and Podman secret name | | --- | --- | | `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | +| `skillet/hosts/clamps/caddy/pihole-hostname` | Included in encrypted `caddy_sites` config | +| `skillet/hosts/clamps/caddy/syncthing-hostname` | Included in encrypted `caddy_sites` config | | `skillet/hosts/clamps/cloudflare/acme-token` | `cloudflare_acme_token` | | `skillet/cloudflare/token-creator` | Workstation only | | `skillet/tailscale/provisioner-client-id` | Workstation only; OAuth Client ID | @@ -77,7 +86,11 @@ Skillet reads `$CREDENTIALS_DIRECTORY/` and sends the value through stdin to `podman secret create`. Quadlets reference secret names with `Secret=`: Pi-hole receives a file under `/run/secrets/` through `WEBPASSWORD_FILE`; Caddy receives `cloudflare_acme_token` through `type=env,target=CF_API_TOKEN`. -Rotation recreates the affected container to consume the new secret. +The `caddy_sites` credential contains the two private UI hostnames and the +ACME staging flag. The generated Caddyfile is persisted under `/etc/skillet` +and contains no token. A dedicated systemd apply unit runs only after both +Caddy credentials are installed. Rotation recreates the affected container to +consume the new secret. Podman's default secret storage persists a copy on the guest disk; systemd's credential encryption does not encrypt that copy. @@ -105,7 +118,9 @@ UI records belong in the existing production zone and use a separate scoped credential. Keep literal zone names and records outside this public repository. The token creator remains on the workstation. Cloudflare token issuance and record management are planned; current smoke provisioning only enrolls -Tailscale and provisions Pi-hole. +Tailscale and provisions Pi-hole. Production Caddy credentials can be delivered +with `secret deliver clamps caddy`; smoke-specific staging and delivery are +not yet wired into VM provisioning. VM Pi-hole secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, expiry, and owned record IDs in run metadata, without token values. Existing diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 081e7287..10561cd3 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -17,9 +17,10 @@ Pi-hole container runs in the VM with data and Podman storage under `/var/lib/data`; service networking, live ACME, final disk selection, and post-provision rebase checks remain open. -Next: validate live Tailscale enrollment and cleanup on the clamps smoke VM, -then implement Caddy with Cloudflare DNS-01 staging for private -Pi-hole and Syncthing UIs. The private-UI design requires a tailnet-only +Next: implement [generic private UI provisioning](GENERIC-PRIVATE-UIS.md), +using host-declared services and environment domains from KeePassXC, then +validate Cloudflare DNS-01 staging for private Pi-hole and Syncthing UIs. +The private-UI design requires a tailnet-only listener and DNS-only Cloudflare records pointing to tailnet addresses, so certificate issuance alone is not the acceptance target. Pi-hole LAN-client reachability and final custom DNS records also remain open; @@ -91,8 +92,8 @@ Each row is a separate implementation and validation step. Adjust order for depe | 1 | Syncthing (implemented; disposable VM service and repeat-apply checks passed) | Data, device identity, folder configuration, UID/GID, required ports | | 2 | btrbk (implemented and verified) | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | | 3 | UniFi | Controller data or supported backup restore, version compatibility, adoption and ownership | -| 4 | Tailscale (implemented; live VM acceptance next) | Host-network container, persistent state, KeePassXC OAuth client, one-use tagged key delivery, and smoke device cleanup are implemented. Validate OAuth scopes, tailnet enrollment, removal, re-provisioning, and forwarding. UI names use Cloudflare DNS-only records; Tailscale DNS management is not required. This is a prerequisite for accepting tailnet-only Caddy UI access. | -| 5 | Private UI access with Caddy + ACME | Implement the [private UI design](../design/private-ui-access.md): host-specific names, DNS-only Cloudflare A/AAAA records pointing to tailnet addresses, tailnet-only access, Syncthing GUI isolation, and DNS recovery. Mint per-VM zone-scoped tokens from the workstation creator; after enrollment, create test records and check client resolution, including DNS rebinding filtering. Validate DNS-01 with staging, certificate persistence, record/token cleanup, repeated runs and interruption recovery. Add public OAuth2 proxying only for an explicitly required public UI. | +| 4 | Tailscale (implemented; smoke enrollment and cleanup accepted 2026-09-30) | Host-network container, persistent state, KeePassXC OAuth client, one-use tagged key delivery, and smoke device cleanup are implemented. Production forwarding remains to validate. UI names use Cloudflare DNS-only records; Tailscale DNS management is not required. | +| 5 | Private UI access with Caddy + ACME (implementation started) | Caddy's credential-gated Quadlet config proxies the Pi-hole and Syncthing UIs by bridge DNS name; Syncthing's host-published GUI port is removed. Next wire smoke-specific KeePassXC values, mint per-VM zone-scoped tokens and manage DNS-only A/AAAA records through the workstation Cloudflare token creator. Validate tailnet-only access, ACME staging, certificate persistence/renewal, record/token cleanup, repeated runs and interruption recovery. | | 6 | Cloudflare DDNS | Required records and token delivery; reconcile the legacy updater before enabling competing writers | | 7 | Monitoring and remaining host baseline | Explicit keep/drop decision for Datadog; required hardening, users, SSH/sudo, ET and WOL behavior | diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md new file mode 100644 index 00000000..27998caf --- /dev/null +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -0,0 +1,184 @@ +# Generic private UI provisioning + +Status: ready to implement, 2026-10-01. Planning only; existing Caddy code is +uncommitted and still restricts delivery and apply to clamps. + +## Goal and fixed decisions + +Every host declares its UI services. Shared provisioning derives each public +DNS name as `..` and delivers the configuration and +Cloudflare credential through the existing encrypted SSH delivery mechanism. +Syncthing is intended on every host; Pi-hole is included only where configured. +The caller selects the environment and declares the services. Caddy, DNS name +derivation, token issuance, and credential delivery must contain no clamps or +smoke special cases. + +`host` means the host configuration name, such as clamps. It does not mean a +libvirt domain, Tailscale device name, or VM instance. Environment selects +configuration and policy; SSH target selects where to deliver. These are +separate fields. VM commands select the test environment; ordinary delivery +defaults to production. Never infer environment from hostname substrings. + +Keep every real UI domain, zone identifier, and credential in KeePassXC, never +in code, committed examples, plans, Ignition, or images. Values are stored in +the Password field, following the existing exact-lookup convention: + +| Entry | Purpose | +| --- | --- | +| `skillet/environments//ui/domain` | Base UI domain | +| `skillet/environments//cloudflare/zone-id` | Authorized existing zone | +| `skillet/environments//hosts//cloudflare/acme-token` | Durable token for a persistent host | +| `skillet/cloudflare/token-creator` | Workstation-only token issuer | + +Production and test are environment names, not distinct implementations. +Locate the existing domain entries privately before migration; reuse or move +their values, never invent or overwrite them. Read-only config migration +must not expose their values. Keep unrelated host secret paths unchanged. + +The shared environment policy selects ACME staging for test and the normal +issuer for production. Token scope is Zone Read plus DNS Edit for the selected +zone. This permits editing the whole zone, not just the derived names. The +master token stays on the workstation. Persistent hosts use-or-create their +token in KDBX; disposable deployments use a temporary token and recorded +ownership, with no disposable token value persisted in run metadata. + +## Read before implementation + +- `AGENTS.md`, `skillet/AGENTS.md`, `design/secrets.md`, + `design/private-ui-access.md`, and `design/smoke-vms.md`. +- `skillet/crates/cli/src/{main,secret_delivery,tailscale}.rs` and the vault + cache and secret-delivery tests. Reuse the current KDBX writer, conflict + checks, three-hour kernel cache, SSH host-key verification, and stdin delivery. +- `skillet/crates/cli-common/src/hosts.rs`, `skillet/crates/caddy/src/lib.rs`, + the Syncthing/Pi-hole resources, and `butane/includes/skillet.bu`. +- Current CI commands in `.github/workflows/ci.yml` and the real VM smoke + harness. Preserve the pending work; do not reset unrelated changes. + +## Implement in this order + +### 1. Shared host UI declarations and environment configuration + +Introduce a typed UI declaration containing a stable service name and its +bridge upstream (container DNS name and port). Each host caller supplies its +enabled services and Podman network through one reusable host definition. +Use that same declaration for Caddy and workstation DNS planning; do not +maintain two lists that can drift. Clamps declares Syncthing and Pi-hole; +other hosts can declare Syncthing without acquiring a Pi-hole dependency. +Declaring future services must not require editing Caddy's renderer. + +Add a validated environment configuration and one hostname derivation helper. +Validate DNS labels, domain length, duplicates, empty service lists, and +upstream inputs before SSH delivery or remote API mutations. Reject schemes, +paths, ports, whitespace, and configuration injection in the base domain. +Validate the selected zone actually contains the base domain; never broaden +scope to another zone automatically. + +Exit: a non-clamps host with only Syncthing derives exactly one route; the +same host definition works with either environment. Use reserved example +domains in tests. Missing config fails with the entry path, without values. + +### 2. Generic Caddy configuration and delivery + +Replace `CaddySites { pihole, syncthing, ... }` with a versioned service-list +payload and explicit issuer policy. Keep the host credential name `caddy_sites` +unless changing it is necessary. Derive hostnames on the workstation from +the shared declaration; validate the payload again on the receiving host. + +Remove the clamps guard in Caddy apply. Pass the caller's network and services +into shared apply. Remove hardcoded `/var/usrlocal/bin/skillet-clamps` from +the delivery helpers; resolve the target binary from the validated host +definition. Remove the CLI's clamps-only delivery parser/guard, but reject +unsupported hosts and undeclared services before unlocking/mutating resources. +Do not pretend unfinished host templates are ready for VM provisioning. + +Preserve deferred installation of both Caddy credentials followed by one +service start. Preserve recovery after partial delivery, idempotent Podman +secrets, tailnet access restrictions, persistent certificates, and secret-free +Caddyfiles. Use normal blocking systemctl starts, without `--wait`. + +CLI target: `skillet secret deliver caddy --environment production` +with the existing SSH and vault options. The shared delivery function accepts +host definition, environment config, credentials, and transport; VM provisioning +calls this same function with its recorded transport and test environment. + +Exit: rerunning unchanged delivery preserves container identity. A host with +Syncthing only has no Pi-hole route. Existing old `caddy_sites` credentials +receive a documented explicit redelivery/migration path; do not silently +interpret the old payload as the new schema. + +### 3. Shared Cloudflare token and DNS reconciliation + +Add a Rust HTTP client module with typed errors and injectable API boundary. +Reuse project HTTP dependencies; do not use a Cloudflare CLI or shell program. +Discover required permission IDs through the API; do not guess or bake IDs in. +Persistent token creation must use the existing atomic KDBX writer and conflict +protection. If saving fails after issuance, revoke the newly issued token or +retain recoverable ownership before returning an error. Never replace an +existing host token implicitly; rotation is explicit. + +After Tailscale enrollment, reconcile DNS-only A/AAAA records for every declared +UI to the target's actual tailnet addresses. Reuse matching owned records; +refuse unrelated conflicting records. Do not create a new zone or configure +Tailscale split DNS. No record deletion by name alone. + +Separate credential lifetime from UI configuration: a persistent deployment +reuses its vault token; a disposable deployment records token ID, expiry, +record IDs, environment identity, and target ownership using existing run +metadata. Both use the same issuance, DNS reconciliation, and delivery functions. +No token value, domain value, or secret appears in routine logs or diagnostics. +Local ignored mode-0600 metadata may retain private configuration needed for +recovery, but never token values. + +Journal external ownership immediately after each successful mutation. Use +deterministic ownership markers to recover a response-loss/crash window; refuse +ambiguous resources. Disposable cleanup removes only recorded/verified records +and revokes only its token. Failures retain enough state for retry and prevent +discarding the run directory. Persistent cleanup/rotation requires an explicit +operation and is never triggered by a VM disposal command. + +Exit: both environments use the same reconciler; a second apply creates no +duplicate token or records; failures at each external mutation can resume or +clean up. Concurrent deployments claiming the same host/environment names +are refused. Do not append VM instance names to URLs silently; supporting +parallel instances requires separately selected base domains/environments. + +### 4. Wire VM lifecycle and verify live ACME + +Extend `test vm provision ` with explicit live-UI opt-in, +for example `--with-ui`. Standard fixture smoke remains offline from Cloudflare. +The caller supplies test environment and disposable ownership; the shared UI +pipeline handles token, records, config delivery, and Caddy activation after +Tailscale is connected. Destroy cleans Cloudflare resources and Tailscale +identity before deleting local ownership metadata. Missing vault entries fail +before external mutations, naming only the required paths. + +Reuse the retained VM for development, then run one fresh destroy/create/ +provision cycle for final acceptance. User unlocks the KDBX in their own terminal; +subsequent commands reuse the three-hour cache. Do not print secret values. + +Exit: staging DNS-01 succeeds, certificates have the derived service names, +and HTTPS reaches both enabled upstreams from an authorized tailnet client. +Staging certificates are untrusted: verify staging trust/identity explicitly; +do not claim trusted-browser acceptance or use unchecked `curl -k` as proof. +Verify actual client DNS resolution, denial outside tailnet, absence of direct +Syncthing GUI host publication, unchanged repeat apply, reboot persistence, +interrupted two-credential delivery recovery, and cleanup on destroy. Production +values and DNS records remain untouched. Verify renewal using a bounded explicit +scenario; if not exercised, record it as outstanding rather than passed. + +## Required validation and documentation + +Add meaningful separate test modules covering hostname/config validation, +single/multiple service rendering, host/environment selection, API ownership, +idempotency, cleanup, and interrupted issuance/delivery. Mock HTTP for CI so +routine CI needs no vault, Cloudflare token, or real DNS zone. Cover a second +host definition to catch clamps dependencies; do not require unfinished hosts +to boot before their templates exist. + +Run workspace formatting, pedantic Clippy, tests, the workflow's integration +command, and affected real VM checks. Record exact commands/results and remaining +limits in `butane/ACCEPTANCE.md`. Update README command/vault setup instructions +and the three design documents as implemented behavior lands. Link this plan +from the migration plan; remove it only after preserving all remaining tasks. +Make focused commits only after required checks pass; do not install workstation +packages or push without the user's instruction. From 4391246c2754eeb1248828a082b82c54d0254e23 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 1 Oct 2026 13:58:44 +0200 Subject: [PATCH 103/252] feat(skillet): generalize private UI provisioning --- ublue/butane/bin/butane | 24 ++- ublue/butane/bin/coreos-install | 30 ++- ublue/butane/bin/test-vm | 11 +- ublue/butane/bin/test-vm-ready | 11 +- ublue/butane/clamps.bu | 5 + ublue/butane/includes/skillet-clamps.bu | 31 +++ ublue/butane/includes/skillet.bu | 37 +--- ublue/skillet/Cargo.lock | 1 + ublue/skillet/crates/caddy/src/lib.rs | 176 ++++++++++++++--- ublue/skillet/crates/caddy/src/tests.rs | 127 ++++++++++--- ublue/skillet/crates/cli-common/src/hosts.rs | 97 +++++++--- .../crates/cli-common/src/hosts_tests.rs | 22 ++- ublue/skillet/crates/cli/Cargo.toml | 1 + ublue/skillet/crates/cli/src/main.rs | 25 ++- .../skillet/crates/cli/src/secret_delivery.rs | 178 ++++++++++++------ 15 files changed, 597 insertions(+), 179 deletions(-) create mode 100644 ublue/butane/includes/skillet-clamps.bu diff --git a/ublue/butane/bin/butane b/ublue/butane/bin/butane index 7b2d73c5..c1a046c0 100755 --- a/ublue/butane/bin/butane +++ b/ublue/butane/bin/butane @@ -1,12 +1,16 @@ #!/usr/bin/env bash set -euo pipefail +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +butane_dir="$(realpath "$script_dir/..")" +repo_dir="$(realpath "$butane_dir/..")" + usage() { echo "Usage: $0 [-s] [-o OUTPUT_DIR] CONFIG.bu [CONFIG.ign]" >&2 } save=false -output_dir="$(realpath -m "$(dirname "${BASH_SOURCE[0]}")/../ignition")" +output_dir="$(realpath -m "$butane_dir/ignition")" while getopts ':hso:' option; do case "$option" in h) usage; exit 0 ;; @@ -40,6 +44,24 @@ if [[ -d "$(dirname "$input")/files" ]]; then cp -a "$(dirname "$input")/files" "$work_dir/files" fi +# Host configs include the generic CLI and a host-specific credential CLI. +# coreos-install stages freshly built artifacts for VM-only Ignition; physical +# host compilation builds them here when callers have not staged binaries. +if rg -q -F 'includes/skillet.bu' "$input"; then + host="$(basename "$input" .bu)" + generic_artifact="$work_dir/files/skillet" + host_artifact="$work_dir/files/skillet-$host" + if [[ ! -x "$generic_artifact" || ! -x "$host_artifact" ]]; then + artifact_target_dir="${CARGO_TARGET_DIR:-$repo_dir/skillet/target}" + if [[ "$artifact_target_dir" != /* ]]; then artifact_target_dir="$repo_dir/skillet/$artifact_target_dir"; fi + (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p skillet -p "skillet-$host") + mkdir -p "$work_dir/files" + cp "$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet" "$generic_artifact" + cp "$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet-$host" "$host_artifact" + chmod 0755 "$generic_artifact" "$host_artifact" + fi +fi + run_butane() { podman run --interactive --rm --security-opt label=disable \ --volume "$work_dir:/work" --workdir /work quay.io/coreos/butane:release \ diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 6af556c9..85ad25fb 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -18,6 +18,8 @@ vm_name= output_dir= ssh_port=2222 artifact= +generic_artifact= +custom_artifact=false image= libvirt_uri=qemu:///session vm_command() { @@ -35,7 +37,7 @@ while (($#)); do --name) vm_name="$2" ;; --output-dir) output_dir="$2" ;; --ssh-port) ssh_port="$2" ;; - --artifact) artifact="$2" ;; + --artifact) artifact="$2"; custom_artifact=true ;; --image) image="$2" ;; --libvirt-uri) libvirt_uri="$2" ;; esac @@ -73,10 +75,21 @@ if [[ -z "$artifact" ]]; then artifact_target_dir="$repo_dir/skillet/$artifact_target_dir" fi artifact="$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet-$host" - (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p "skillet-$host") + generic_artifact="$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet" + (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p skillet -p "skillet-$host") fi [[ -f "$artifact" ]] || { echo "Missing Skillet artifact: $artifact" >&2; exit 1; } artifact="$(realpath "$artifact")" +if [[ -z "$generic_artifact" ]]; then + artifact_target_dir="${CARGO_TARGET_DIR:-$repo_dir/skillet/target}" + if [[ "$artifact_target_dir" != /* ]]; then artifact_target_dir="$repo_dir/skillet/$artifact_target_dir"; fi + generic_artifact="$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet" +fi +if [[ "$custom_artifact" == true || ! -f "$generic_artifact" ]]; then + (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p skillet) +fi +[[ -f "$generic_artifact" ]] || { echo "Missing generic Skillet artifact: $generic_artifact" >&2; exit 1; } +generic_artifact="$(realpath "$generic_artifact")" if [[ -z "$image" ]]; then mapfile -t images < <(find "$butane_dir/images" -maxdepth 1 -name 'fedora-coreos-*-qemu.x86_64.qcow2' -type f 2>/dev/null | sort) if ((${#images[@]} == 0)); then @@ -99,6 +112,8 @@ cp "$input" "$output_dir/source/$host.bu" cp "$butane_dir"/includes/*.bu "$output_dir/source/includes/" cp "$artifact" "$output_dir/source/files/skillet-$host" chmod 755 "$output_dir/source/files/skillet-$host" +cp "$generic_artifact" "$output_dir/source/files/skillet" +chmod 755 "$output_dir/source/files/skillet" # Give each test VM its own hostname so Tailscale device cleanup can verify # the exact node belonging to this run, even when several VMs share a host OS. TEST_VM_HOSTNAME="$vm_name" yq -i \ @@ -107,10 +122,14 @@ TEST_VM_HOSTNAME="$vm_name" yq -i \ # Keep the multi-megabyte static binary out of QEMU fw_cfg. Ignition reads # fw_cfg very slowly for large configs; test-vm-ready installs this artifact # over SSH after the guest has completed its first Ignition boot. -mapfile -t skillet_includes < <(rg -l -F "/var/usrlocal/bin/skillet-$host" "$output_dir/source/includes" --glob '*.bu') -((${#skillet_includes[@]} == 1)) || { echo "Expected one Butane include with skillet-$host" >&2; exit 1; } +skillet_include="$output_dir/source/includes/skillet.bu" +[[ -f "$skillet_include" ]] || { echo "Missing shared Skillet Butane include" >&2; exit 1; } +yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet"))' \ + "$skillet_include" +skillet_host_include="$output_dir/source/includes/skillet-$host.bu" +[[ -f "$skillet_host_include" ]] || { echo "Missing host Skillet Butane include: $skillet_host_include" >&2; exit 1; } HOST="$host" yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet-" + strenv(HOST)))' \ - "${skillet_includes[0]}" + "$skillet_host_include" ssh-keygen -q -t ed25519 -N '' -f "$output_dir/ssh/id_ed25519" chmod 600 "$output_dir/ssh/id_ed25519" public_key="$(cat "$output_dir/ssh/id_ed25519.pub")" @@ -130,6 +149,7 @@ if ((ignition_bytes > 1048576)); then fi cp --reflink=auto "$image" "$output_dir/$vm_name.qcow2" sha256sum "$output_dir/source/files/skillet-$host" > "$output_dir/skillet.sha256" +sha256sum "$output_dir/source/files/skillet" > "$output_dir/skillet-generic.sha256" sha256sum "$image" > "$output_dir/fcos-image.sha256" printf 'vm=%s\nhost=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir=%s\nbackend=%s\nsource_commit=%s\n' \ "$vm_name" "$host" "$libvirt_uri" "$ssh_port" "$image" "$artifact" "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" "${TEST_VM_BACKEND:-native}" \ diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index baa1c211..4865de94 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -263,16 +263,21 @@ case "$operation" in artifact_target_dir="$script_dir/../../skillet/$artifact_target_dir" fi artifact="$(realpath -e "$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet-$host")" - [[ -f "$artifact" && ! -L "$artifact" ]] || { echo "Build skillet-$host first" >&2; exit 1; } + generic_artifact="$(realpath -e "$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet")" + [[ -f "$artifact" && ! -L "$artifact" && -f "$generic_artifact" && ! -L "$generic_artifact" ]] || { echo "Build skillet and skillet-$host first" >&2; exit 1; } ssh_args scp -O -i "$run_dir/ssh/id_ed25519" -P "$port" -o BatchMode=yes \ -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$run_dir/ssh/known_hosts" \ "$artifact" giacomo@127.0.0.1:/var/tmp/skillet-update + scp -O -i "$run_dir/ssh/id_ed25519" -P "$port" -o BatchMode=yes \ + -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$run_dir/ssh/known_hosts" \ + "$generic_artifact" giacomo@127.0.0.1:/var/tmp/skillet-generic-update # shellcheck disable=SC2029 ssh "${args[@]}" giacomo@127.0.0.1 \ - "sudo -n install -m 0755 /var/tmp/skillet-update /var/usrlocal/bin/skillet-$host && rm -f /var/tmp/skillet-update" + "sudo -n install -m 0755 /var/tmp/skillet-update /var/usrlocal/bin/skillet-$host && sudo -n install -m 0755 /var/tmp/skillet-generic-update /var/usrlocal/bin/skillet && rm -f /var/tmp/skillet-update /var/tmp/skillet-generic-update" sha256sum "$artifact" > "$run_dir/deployed-skillet.sha256" - echo "Updated skillet-$host in $name" ;; + sha256sum "$generic_artifact" > "$run_dir/deployed-skillet-generic.sha256" + echo "Updated skillet and skillet-$host in $name" ;; status) (($# == 0)) || usage inspect_owned diff --git a/ublue/butane/bin/test-vm-ready b/ublue/butane/bin/test-vm-ready index eb09d4f9..4bf08ec1 100755 --- a/ublue/butane/bin/test-vm-ready +++ b/ublue/butane/bin/test-vm-ready @@ -44,6 +44,8 @@ fi artifact="$run_dir/source/files/skillet-$host" [[ -x "$artifact" ]] || { echo "Missing host artifact: $artifact" >&2; exit 1; } +generic_artifact="$run_dir/source/files/skillet" +[[ -x "$generic_artifact" ]] || { echo "Missing generic artifact: $generic_artifact" >&2; exit 1; } deadline=$((SECONDS + 2700)) next_report=$((SECONDS + 60)) echo "Waiting for SSH on giacomo@127.0.0.1:$ssh_port before delivering Skillet..." @@ -62,7 +64,8 @@ if ((SECONDS >= deadline)); then exit 1 fi scp -O "${scp_args[@]}" "$artifact" "giacomo@127.0.0.1:/var/tmp/skillet-$host" -ssh_guest "sudo -n install -m 0755 /var/tmp/skillet-$host /var/usrlocal/bin/skillet-$host && rm -f /var/tmp/skillet-$host" +scp -O "${scp_args[@]}" "$generic_artifact" giacomo@127.0.0.1:/var/tmp/skillet +ssh_guest "sudo -n install -m 0755 /var/tmp/skillet-$host /var/usrlocal/bin/skillet-$host && sudo -n install -m 0755 /var/tmp/skillet /var/usrlocal/bin/skillet && rm -f /var/tmp/skillet-$host /var/tmp/skillet" echo "Installed Skillet artifact; waiting for the signed $host deployment..." deadline=$((SECONDS + 2700)) @@ -103,11 +106,15 @@ fi ssh_guest 'sudo -n rpm-ostree status --json' > "$run_dir/final-status.json" ssh_guest 'cat /proc/sys/kernel/random/boot_id' > "$run_dir/final-boot-id" ssh_guest "sha256sum /var/usrlocal/bin/skillet-$host" > "$run_dir/guest-skillet.sha256" +ssh_guest 'sha256sum /var/usrlocal/bin/skillet' > "$run_dir/guest-skillet-generic.sha256" ssh_guest 'getenforce; readlink -f /etc/resolv.conf; cat /etc/resolv.conf; getent hosts ghcr.io; sudo -n systemctl is-enabled systemd-resolved.service; sudo -n systemctl status --no-pager skillet-apply.service' > "$run_dir/readiness.log" 2>&1 || true ssh_guest 'systemctl show brew-install.service dotfiles-install.service -p Id -p Result -p ExecMainStatus; readlink -f /home/giacomo/.config/Brewfile; readlink -f /home/giacomo/.ssh/authorized_keys; /home/linuxbrew/.linuxbrew/bin/brew --version' > "$run_dir/user-environment.log" 2>&1 || true expected="$(cut -d ' ' -f 1 "$run_dir/skillet.sha256")" actual="$(cut -d ' ' -f 1 "$run_dir/guest-skillet.sha256")" [[ "$expected" == "$actual" ]] || { echo "Guest Skillet SHA mismatch" >&2; exit 1; } +expected_generic="$(cut -d ' ' -f 1 "$run_dir/skillet-generic.sha256")" +actual_generic="$(cut -d ' ' -f 1 "$run_dir/guest-skillet-generic.sha256")" +[[ "$expected_generic" == "$actual_generic" ]] || { echo "Guest generic Skillet SHA mismatch" >&2; exit 1; } if ! jq -e --arg image "ostree-image-signed:docker://ghcr.io/gbagnoli/ucore-$host" \ '.deployments[] | select(.booted) | .["container-image-reference"] | . == ($image + ":latest") or (startswith($image + "@sha256:") and (split("@sha256:")[1] | test("^[0-9a-f]{64}$")))' \ "$run_dir/final-status.json" >/dev/null; then @@ -117,6 +124,8 @@ fi # The substitutions run on the guest. # shellcheck disable=SC2016 ssh_guest 'test "$(systemctl show -p Result --value skillet-apply.service)" = success && test "$(systemctl show -p ExecMainStatus --value skillet-apply.service)" = 0 && test "$(getenforce)" = Enforcing && test "$(readlink -f /etc/resolv.conf)" = /run/NetworkManager/resolv.conf && getent hosts ghcr.io >/dev/null' +# shellcheck disable=SC2016 +ssh_guest 'test "$(cat /etc/skillet/host)" = clamps && systemctl cat skillet-apply.service | grep -Fq "ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base"' if [[ "$host" == clamps ]]; then # The substitution runs on the guest. # shellcheck disable=SC2016 diff --git a/ublue/butane/clamps.bu b/ublue/butane/clamps.bu index ffed8692..0e24042d 100644 --- a/ublue/butane/clamps.bu +++ b/ublue/butane/clamps.bu @@ -11,6 +11,7 @@ ignition: - local: includes/data-storage.bu - local: includes/brew.bu - local: includes/skillet.bu + - local: includes/skillet-clamps.bu - local: includes/ucore-bootstrap.bu - local: includes/ucore-clamps.bu storage: @@ -34,6 +35,10 @@ storage: - path: /etc/localtime target: ../usr/share/zoneinfo/Europe/Madrid files: + - path: /etc/skillet/host + mode: 0644 + contents: + inline: clamps - path: /etc/hostname mode: 0644 contents: diff --git a/ublue/butane/includes/skillet-clamps.bu b/ublue/butane/includes/skillet-clamps.bu new file mode 100644 index 00000000..0901c657 --- /dev/null +++ b/ublue/butane/includes/skillet-clamps.bu @@ -0,0 +1,31 @@ +variant: fcos +version: 1.6.0 +storage: + files: + - path: /var/usrlocal/bin/skillet-clamps + mode: 0755 + contents: + local: files/skillet-clamps +systemd: + units: + - name: skillet-full-apply.service + enabled: true + contents: | + [Unit] + Description=Apply clamps application configuration + Requires=skillet-apply.service + After=skillet-apply.service + Requires=skillet-data-prepare.service + After=skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data + ConditionPathExists=/etc/credstore.encrypted/skillet/pihole_web_password.cred + ConditionPathExists=/etc/credstore.encrypted/skillet/tailscale_auth_key.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred + LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase full + + [Install] + WantedBy=multi-user.target diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index 5036b4dd..ef17747e 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -2,45 +2,24 @@ variant: fcos version: 1.6.0 storage: files: - - path: /var/usrlocal/bin/skillet-clamps + - path: /var/usrlocal/bin/skillet mode: 0755 contents: - local: files/skillet-clamps + local: files/skillet systemd: units: - name: skillet-apply.service enabled: true contents: | [Unit] - Description=Apply Skillet clamps base configuration + Description=Apply Skillet base configuration Requires=ucore-bootstrap.service After=ucore-bootstrap.service ConditionPathExists=/run/ucore-bootstrap-ready [Service] Type=oneshot - ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase base - - [Install] - WantedBy=multi-user.target - - name: skillet-full-apply.service - enabled: true - contents: | - [Unit] - Description=Apply Skillet clamps application configuration - Requires=skillet-apply.service - After=skillet-apply.service - Requires=skillet-data-prepare.service - After=skillet-data-prepare.service - AssertPathIsMountPoint=/var/lib/data - ConditionPathExists=/etc/credstore.encrypted/skillet/pihole_web_password.cred - ConditionPathExists=/etc/credstore.encrypted/skillet/tailscale_auth_key.cred - - [Service] - Type=oneshot - LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred - LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred - ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase full + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base [Install] WantedBy=multi-user.target @@ -49,10 +28,8 @@ systemd: contents: | [Unit] Description=Apply Skillet Caddy private UI configuration - Requires=skillet-full-apply.service - After=skillet-full-apply.service - Requires=skillet-data-prepare.service - After=skillet-data-prepare.service + Requires=skillet-apply.service + After=skillet-apply.service AssertPathIsMountPoint=/var/lib/data ConditionPathExists=/etc/credstore.encrypted/skillet/caddy_sites.cred ConditionPathExists=/etc/credstore.encrypted/skillet/cloudflare_acme_token.cred @@ -61,7 +38,7 @@ systemd: Type=oneshot LoadCredentialEncrypted=caddy_sites:/etc/credstore.encrypted/skillet/caddy_sites.cred LoadCredentialEncrypted=cloudflare_acme_token:/etc/credstore.encrypted/skillet/cloudflare_acme_token.cred - ExecStart=/var/usrlocal/bin/skillet-clamps apply --phase caddy + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase caddy [Install] WantedBy=multi-user.target diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 06d1c2d7..1d4673ea 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1524,6 +1524,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "skillet_caddy", "skillet_cli_common", "tempfile", "tracing", diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index 87f94923..a396d43c 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -1,6 +1,6 @@ //! Caddy reverse proxy for administrative interfaces. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; use skillet_podman::{ @@ -14,13 +14,36 @@ use thiserror::Error; #[path = "tests.rs"] mod tests; -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct UiService { + pub name: String, + pub upstream: String, + pub port: u16, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct UiEnvironment { + pub ui_domain: String, + pub acme_staging: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct CaddySites { - pub pihole: String, - pub syncthing: String, - #[serde(default)] + pub version: u8, + pub host: String, + pub ui_domain: String, pub acme_staging: bool, + pub services: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CaddySite { + pub name: String, + pub hostname: String, + pub upstream: String, + pub port: u16, } #[derive(Debug, Error)] @@ -31,53 +54,144 @@ pub enum CaddyError { File(#[from] FileError), #[error("Podman error: {0}")] Podman(#[from] PodmanError), - #[error("invalid Caddy site hostname: {0}")] - InvalidHostname(String), + #[error("invalid Caddy configuration: {0}")] + InvalidConfiguration(String), #[error("invalid Caddy sites credential: {0}")] InvalidSites(#[from] serde_json::Error), } impl CaddySites { - pub fn parse(contents: &str) -> Result { + pub fn from_host( + host: &str, + environment: &UiEnvironment, + services: &[UiService], + ) -> Result { + validate_label(host, "host")?; + validate_domain(&environment.ui_domain)?; + if services.is_empty() { + return Err(CaddyError::InvalidConfiguration( + "host declares no UI services".to_string(), + )); + } + let mut sites = Vec::with_capacity(services.len()); + for service in services { + validate_label(&service.name, "service name")?; + validate_label(&service.upstream, "upstream name")?; + if service.port == 0 { + return Err(CaddyError::InvalidConfiguration(format!( + "service {} has an invalid upstream port", + service.name + ))); + } + if sites + .iter() + .any(|site: &CaddySite| site.name == service.name) + { + return Err(CaddyError::InvalidConfiguration(format!( + "duplicate UI service {}", + service.name + ))); + } + let hostname = format!("{}.{}.{}", service.name, host, environment.ui_domain); + validate_domain(&hostname)?; + sites.push(CaddySite { + name: service.name.clone(), + hostname, + upstream: service.upstream.clone(), + port: service.port, + }); + } + Ok(Self { + version: 1, + host: host.to_string(), + ui_domain: environment.ui_domain.clone(), + acme_staging: environment.acme_staging, + services: sites, + }) + } + + pub fn parse(contents: &str, host: &str, services: &[UiService]) -> Result { let sites: Self = serde_json::from_str(contents)?; - validate_hostname(&sites.pihole)?; - validate_hostname(&sites.syncthing)?; - if sites.pihole == sites.syncthing { - return Err(CaddyError::InvalidHostname(sites.pihole)); + if sites.version != 1 { + return Err(CaddyError::InvalidConfiguration(format!( + "unsupported payload version {}", + sites.version + ))); } - Ok(sites) + let expected = Self::from_host( + host, + &UiEnvironment { + ui_domain: sites.ui_domain.clone(), + acme_staging: sites.acme_staging, + }, + services, + )?; + if sites != expected { + return Err(CaddyError::InvalidConfiguration( + "payload does not match this host's declared UI services".to_string(), + )); + } + Ok(expected) } - fn render(&self) -> String { + pub fn render(&self) -> String { let acme = if self.acme_staging { " acme_ca https://acme-staging-v02.api.letsencrypt.org/directory\n" } else { "" }; - format!( - "{{\n admin off\n{acme} acme_dns cloudflare {{env.CF_API_TOKEN}}\n}}\n\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet 403\n reverse_proxy pihole:8088\n}}\n\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet 403\n reverse_proxy syncthing:8384\n}}\n", - self.pihole, self.syncthing - ) + let mut config = + format!("{{\n admin off\n{acme} acme_dns cloudflare {{env.CF_API_TOKEN}}\n}}\n"); + for service in &self.services { + use std::fmt::Write as _; + let _ = writeln!( + config, + "\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet 403\n reverse_proxy {}:{}\n}}", + service.hostname, service.upstream, service.port + ); + } + config } } -fn validate_hostname(hostname: &str) -> Result<(), CaddyError> { - let valid = hostname.len() <= 253 - && hostname.contains('.') - && hostname.split('.').all(|label| { - !label.is_empty() - && label.len() <= 63 - && label.as_bytes()[0].is_ascii_alphanumeric() - && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() - && label - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') - }); +fn validate_label(label: &str, field: &str) -> Result<(), CaddyError> { + let valid = !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); if valid { Ok(()) } else { - Err(CaddyError::InvalidHostname(hostname.to_string())) + Err(CaddyError::InvalidConfiguration(format!( + "invalid {field} DNS label" + ))) + } +} + +pub fn validate_domain(domain: &str) -> Result<(), CaddyError> { + if domain.len() > 253 || domain.split('.').count() < 2 { + return Err(CaddyError::InvalidConfiguration( + "UI domain must be a fully qualified DNS domain".to_string(), + )); } + for label in domain.split('.') { + validate_label(label, "UI domain")?; + } + Ok(()) +} + +pub fn validate_domain_in_zone(domain: &str, zone: &str) -> Result<(), CaddyError> { + validate_domain(domain)?; + validate_domain(zone)?; + if domain != zone && !domain.ends_with(&format!(".{zone}")) { + return Err(CaddyError::InvalidConfiguration( + "UI domain is outside the selected Cloudflare zone".to_string(), + )); + } + Ok(()) } pub fn apply( diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index e41b5b39..8665cdad 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -1,36 +1,117 @@ -use super::CaddySites; +use super::{CaddySites, UiEnvironment, UiService}; + +fn syncthing_only() -> Vec { + vec![UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + }] +} #[test] -fn rejects_invalid_duplicate_and_unknown_site_configuration() { - for payload in [ - r#"{"pihole":"pihole.example.invalid\n}","syncthing":"sync.example.invalid"}"#, - r#"{"pihole":"https://pihole.example.invalid","syncthing":"sync.example.invalid"}"#, - r#"{"pihole":"same.example.invalid","syncthing":"same.example.invalid"}"#, - r#"{"pihole":"pihole.example.invalid","syncthing":"sync.example.invalid","unknown":true}"#, +fn derives_a_single_non_clamps_service_hostname_for_either_environment() { + let services = syncthing_only(); + for (domain, staging) in [ + ("test.example.invalid", true), + ("prod.example.invalid", false), ] { - assert!(CaddySites::parse(payload).is_err()); + let sites = CaddySites::from_host( + "beezelbot", + &UiEnvironment { + ui_domain: domain.to_string(), + acme_staging: staging, + }, + &services, + ) + .unwrap(); + assert_eq!(sites.services.len(), 1); + assert_eq!( + sites.services[0].hostname, + format!("syncthing.beezelbot.{domain}") + ); + assert_eq!(sites.acme_staging, staging); } } #[test] -fn staging_routes_use_bridge_dns_and_restrict_both_address_families() { - let sites = CaddySites::parse( - r#"{"pihole":"pihole.example.invalid","syncthing":"sync.example.invalid","acme_staging":true}"#, +fn renders_each_declared_service_with_tailnet_filter_and_container_dns() { + let services = vec![ + UiService { + name: "pihole".to_string(), + upstream: "pihole".to_string(), + port: 8088, + }, + syncthing_only().remove(0), + ]; + let sites = CaddySites::from_host( + "clamps", + &UiEnvironment { + ui_domain: "private.example.invalid".to_string(), + acme_staging: true, + }, + &services, + ) + .unwrap(); + let rendered = sites.render(); + assert!(rendered.contains("pihole.clamps.private.example.invalid")); + assert!(rendered.contains("syncthing.clamps.private.example.invalid")); + assert!(rendered.contains("reverse_proxy pihole:8088")); + assert!(rendered.contains("reverse_proxy syncthing:8384")); + assert_eq!(rendered.matches("respond @outside_tailnet 403").count(), 2); + assert!(rendered.contains("https://acme-staging-v02.api.letsencrypt.org/directory")); +} + +#[test] +fn guest_payload_must_match_the_callers_declared_services() { + let services = syncthing_only(); + let expected = CaddySites::from_host( + "beezelbot", + &UiEnvironment { + ui_domain: "test.example.invalid".to_string(), + acme_staging: true, + }, + &services, ) .unwrap(); - let config = sites.render(); - assert!(config.contains("https://acme-staging-v02.api.letsencrypt.org/directory")); - assert!(config.contains("acme_dns cloudflare {env.CF_API_TOKEN}")); - assert!(config.contains("reverse_proxy pihole:8088")); - assert!(config.contains("reverse_proxy syncthing:8384")); - assert_eq!(config.matches("respond @outside_tailnet 403").count(), 2); + let payload = serde_json::to_string(&expected).unwrap(); assert_eq!( - config.matches("100.64.0.0/10 fd7a:115c:a1e0::/48").count(), - 2 + CaddySites::parse(&payload, "beezelbot", &services).unwrap(), + expected ); - let production = CaddySites::parse( - r#"{"pihole":"pihole.example.invalid","syncthing":"sync.example.invalid"}"#, + assert!(CaddySites::parse(&payload, "clamps", &services).is_err()); + assert!(CaddySites::parse(&payload, "beezelbot", &[]).is_err()); +} + +#[test] +fn rejects_domain_injection_duplicates_and_empty_service_sets() { + for domain in [ + "https://example.invalid", + "example.invalid/path", + "example.invalid:443", + "bad..invalid", + "bad\n.invalid", + ] { + assert!(super::validate_domain(domain).is_err()); + } + let duplicate = vec![syncthing_only()[0].clone(), syncthing_only()[0].clone()]; + assert!(CaddySites::from_host( + "host", + &UiEnvironment { + ui_domain: "example.invalid".to_string(), + acme_staging: false + }, + &duplicate ) - .unwrap(); - assert!(!production.render().contains("acme_ca")); + .is_err()); + assert!(CaddySites::from_host( + "host", + &UiEnvironment { + ui_domain: "example.invalid".to_string(), + acme_staging: false + }, + &[] + ) + .is_err()); + assert!(super::validate_domain_in_zone("ui.other.invalid", "example.invalid").is_err()); + assert!(super::validate_domain_in_zone("ui.example.invalid", "example.invalid").is_ok()); } diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 380b7dce..ee11ca26 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -61,12 +61,13 @@ mod user_lookup { } } -/// Apply the beezelbot host configuration (hardening baseline). +/// Apply the beezelbot host configuration (hardening + Syncthing). pub fn apply_beezelbot( system: &dyn SystemResource, files: &dyn FileResource, ) -> Result<(), ApplyError> { - apply_full_base(system, files) + apply_full_base(system, files)?; + apply_syncthing(system, files, host_service_network("beezelbot")) } /// Shared host baseline used by both CLI entry points. @@ -101,21 +102,58 @@ pub const CADDY_SITES_CREDENTIAL: &str = "caddy_sites"; /// Cloudflare token used only by Caddy's DNS-01 challenge provider. pub const CLOUDFLARE_ACME_TOKEN_CREDENTIAL: &str = "cloudflare_acme_token"; +/// UI services declared by a host. This is the canonical input used by Caddy +/// both on the workstation (to build delivery payloads) and on the host (to +/// validate and apply them). +pub struct HostUiConfig { + pub network: PodmanNetwork, + pub services: Vec, +} + +pub fn ui_config_for_host(hostname: &str) -> Option { + match hostname { + "clamps" => Some(HostUiConfig { + network: host_service_network("clamps"), + services: vec![ + skillet_caddy::UiService { + name: "pihole".to_string(), + upstream: "pihole".to_string(), + port: 8088, + }, + skillet_caddy::UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + }, + ], + }), + "beezelbot" => Some(HostUiConfig { + network: host_service_network("beezelbot"), + services: vec![skillet_caddy::UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + }], + }), + _ => None, + } +} + /// Custom DNS records for the clamps Pi-hole (`ip -> fqdn`). // TODO: replace with the real LAN IP and domain before the production // cutover; these are still the old placeholder values. const CLAMPS_CUSTOM_DNS_RECORDS: &[(&str, &str)] = &[("192.168.1.100", "my.custom.domain")]; -fn clamps_service_network() -> PodmanNetwork { +fn host_service_network(host: &str) -> PodmanNetwork { PodmanNetwork { - unit_name: "clamps".to_string(), + unit_name: host.to_string(), options: vec![ "DisableDNS=false".to_string(), "Driver=bridge".to_string(), "Gateway=172.26.26.1".to_string(), "Gateway=fd59:4e23:2950:11f5::1".to_string(), "IPv6=true".to_string(), - "NetworkName=clamps".to_string(), + format!("NetworkName={host}"), "Subnet=172.26.26.0/24".to_string(), "Subnet=fd59:4e23:2950:11f5::/64".to_string(), ], @@ -255,9 +293,25 @@ pub fn apply_clamps( }, secrets, custom_records, - clamps_service_network(), + host_service_network("clamps"), + )?; + + apply_syncthing(system, files, host_service_network("clamps"))?; + skillet_btrbk::apply( + system, + files, + &skillet_btrbk::BtrbkConfig { + snapshot_subvolumes: vec![std::path::PathBuf::from("syncthing")], + }, )?; + Ok(()) +} +fn apply_syncthing( + system: &dyn SystemResource, + files: &dyn FileResource, + network: PodmanNetwork, +) -> Result<(), ApplyError> { skillet_syncthing::apply( system, files, @@ -267,14 +321,7 @@ pub fn apply_clamps( data_group: "giacomo".to_string(), uid: 1000, gid: 1000, - network: clamps_service_network(), - }, - )?; - skillet_btrbk::apply( - system, - files, - &skillet_btrbk::BtrbkConfig { - snapshot_subvolumes: vec![std::path::PathBuf::from("syncthing")], + network, }, )?; Ok(()) @@ -292,9 +339,8 @@ pub fn apply_host( match hostname { "clamps" => apply_clamps(system, files), "skillet-smoke" => fixture::apply(system, files), - // beezelbot and unknown hostnames fall back to the hardening-only - // baseline, matching the previous "(Agent Mode)" default behaviour. - _ => apply_beezelbot(system, files), + "beezelbot" => apply_beezelbot(system, files), + _ => apply_full_base(system, files), } } @@ -323,21 +369,22 @@ fn apply_caddy_host( system: &dyn SystemResource, files: &dyn FileResource, ) -> Result<(), ApplyError> { - if hostname != "clamps" { - return Err(ApplyError::FixtureInput( - "Caddy UI configuration is currently defined only for clamps".to_string(), - )); - } + let ui_config = ui_config_for_host(hostname).ok_or_else(|| { + ApplyError::FixtureInput(format!("host {hostname} declares no UI services")) + })?; files.require_btrfs_subvolume_mount( std::path::Path::new("/var/lib/data"), std::path::Path::new("/var"), "/data", )?; let credentials = CredentialManager::new()?; - let sites = - skillet_caddy::CaddySites::parse(&credentials.read_secret(CADDY_SITES_CREDENTIAL)?)?; + let sites = skillet_caddy::CaddySites::parse( + &credentials.read_secret(CADDY_SITES_CREDENTIAL)?, + hostname, + &ui_config.services, + )?; let token = credentials.read_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL)?; system.ensure_podman_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL, &token)?; - skillet_caddy::apply(system, files, &sites, clamps_service_network())?; + skillet_caddy::apply(system, files, &sites, ui_config.network)?; Ok(()) } diff --git a/ublue/skillet/crates/cli-common/src/hosts_tests.rs b/ublue/skillet/crates/cli-common/src/hosts_tests.rs index 86614b74..5f58b8df 100644 --- a/ublue/skillet/crates/cli-common/src/hosts_tests.rs +++ b/ublue/skillet/crates/cli-common/src/hosts_tests.rs @@ -1,4 +1,4 @@ -use super::{clamps_tailscale_config, TAILSCALE_AUTH_KEY_CREDENTIAL}; +use super::{clamps_tailscale_config, ui_config_for_host, TAILSCALE_AUTH_KEY_CREDENTIAL}; use skillet_podman::SecretTarget; #[test] @@ -28,3 +28,23 @@ fn clamps_tailscale_uses_host_network_and_persistent_state() { ) })); } + +#[test] +fn host_ui_declarations_include_only_the_services_each_host_runs() { + let clamps = ui_config_for_host("clamps").expect("clamps UI declaration"); + assert_eq!( + clamps + .services + .iter() + .map(|service| service.name.as_str()) + .collect::>(), + ["pihole", "syncthing"] + ); + assert_eq!(clamps.network.unit_name, "clamps"); + + let beezelbot = ui_config_for_host("beezelbot").expect("beezelbot UI declaration"); + assert_eq!(beezelbot.services.len(), 1); + assert_eq!(beezelbot.services[0].name, "syncthing"); + assert_eq!(beezelbot.network.unit_name, "beezelbot"); + assert!(ui_config_for_host("unknown-host").is_none()); +} diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 93aa54cb..181ec5b5 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -8,6 +8,7 @@ workspace = true [dependencies] skillet_cli_common.workspace = true +skillet_caddy.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 4eeef9f1..bcd7d40d 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -49,7 +49,7 @@ enum Commands { #[derive(clap::Subcommand, Debug)] enum SecretCommands { - /// Deliver a clamps Pi-hole credential or enroll its Tailscale client + /// Deliver credentials for a configured host service Deliver(SecretDeliverArgs), /// Remove the cached vault password from the kernel keyring Lock(SecretLockArgs), @@ -63,7 +63,6 @@ struct SecretLockArgs { #[derive(clap::Args, Debug)] struct SecretDeliverArgs { - #[arg(value_parser = ["clamps"])] hostname: String, #[arg(value_parser = ["pihole", "tailscale", "caddy"])] service: String, @@ -71,6 +70,9 @@ struct SecretDeliverArgs { database: Option, #[arg(long)] key_file: Option, + /// `KeePassXC` environment to use for private UI configuration + #[arg(long, value_enum, default_value_t = UiEnvironmentName::Production)] + environment: UiEnvironmentName, #[arg(long)] target: String, #[arg(long, default_value_t = 22)] @@ -81,6 +83,25 @@ struct SecretDeliverArgs { known_hosts: PathBuf, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] +enum UiEnvironmentName { + Production, + Test, +} + +impl UiEnvironmentName { + fn as_str(self) -> &'static str { + match self { + Self::Production => "production", + Self::Test => "test", + } + } + + fn acme_staging(self) -> bool { + matches!(self, Self::Test) + } +} + #[derive(clap::Subcommand, Debug)] enum TestCommands { /// Apply a selected configuration phase twice in a disposable Podman container diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 75998728..587fa3d1 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -1,4 +1,7 @@ -use super::{butane_root, tailscale, vm_name, SecretDeliverArgs, VmDestroyArgs, VmProvisionArgs}; +use super::{ + butane_root, tailscale, vm_name, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, + VmProvisionArgs, +}; use anyhow::{anyhow, Context, Result}; use keepass::{Database, DatabaseKey}; use std::{ @@ -13,9 +16,14 @@ use std::{ mod vault_cache; pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { - if args.hostname != "clamps" { - return Err(anyhow!("secret delivery currently supports clamps only")); - } + let ui_config = if args.service == "caddy" { + Some( + skillet_cli_common::hosts::ui_config_for_host(&args.hostname) + .ok_or_else(|| anyhow!("host {} has no declared UI services", args.hostname))?, + ) + } else { + None + }; let database = match &args.database { Some(path) => path.clone(), None => default_database_path()?, @@ -28,6 +36,19 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { let mut vault = open_vault(&database, args.key_file.as_deref())?; match args.service.as_str() { "pihole" => { + let declared = skillet_cli_common::hosts::ui_config_for_host(&args.hostname) + .is_some_and(|config| { + config + .services + .iter() + .any(|service| service.name == "pihole") + }); + if !declared { + return Err(anyhow!( + "host {} does not declare a Pi-hole UI", + args.hostname + )); + } let path = format!("skillet/hosts/{}/pihole/web-password", args.hostname); let secret = if let Some(secret) = lookup(&vault.database, &path)? { ensure_vault_unchanged(&vault)?; @@ -44,9 +65,15 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { secret }; let mut command = ssh_command(args); - install(&mut command, "pihole_web_password", &secret) + install(&mut command, &args.hostname, "pihole_web_password", &secret) } "tailscale" => { + if args.hostname != "clamps" { + return Err(anyhow!( + "host {} does not declare Tailscale credential delivery", + args.hostname + )); + } let credentials = tailscale_credentials(&vault)?; let auth_key = tailscale::create_auth_key( &credentials, @@ -54,58 +81,80 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { "Skillet clamps production host", )?; let mut command = ssh_command(args); - install(&mut command, "tailscale_auth_key", &auth_key.key) - } - "caddy" => { - let pihole = lookup( - &vault.database, - &format!("skillet/hosts/{}/caddy/pihole-hostname", args.hostname), - )? - .ok_or_else(|| anyhow!("KeePassXC Pi-hole Caddy hostname entry is missing"))?; - let syncthing = lookup( - &vault.database, - &format!("skillet/hosts/{}/caddy/syncthing-hostname", args.hostname), - )? - .ok_or_else(|| anyhow!("KeePassXC Syncthing Caddy hostname entry is missing"))?; - let token = lookup( - &vault.database, - &format!("skillet/hosts/{}/cloudflare/acme-token", args.hostname), - )? - .ok_or_else(|| anyhow!("KeePassXC Cloudflare ACME token entry is missing"))?; - ensure_vault_unchanged(&vault)?; - let sites = serde_json::json!({ - "pihole": pihole.trim(), - "syncthing": syncthing.trim(), - "acme_staging": false, - }) - .to_string(); - let mut command = ssh_command(args); - install_deferred_for_unit( - &mut command, - "caddy_sites", - "skillet-caddy-apply.service", - &sites, - )?; - let mut command = ssh_command(args); - install_deferred_for_unit( + install( &mut command, - "cloudflare_acme_token", - "skillet-caddy-apply.service", - &token, - )?; - let status = ssh_command(args) - .arg("sudo -n systemctl start skillet-caddy-apply.service") - .status() - .context("starting Caddy apply after both credentials were delivered")?; - if !status.success() { - return Err(anyhow!("Caddy apply failed with status {status}")); - } - Ok(()) + &args.hostname, + "tailscale_auth_key", + &auth_key.key, + ) } + "caddy" => deliver_caddy_from_vault(args, &mut vault, ui_config.as_ref()), _ => Err(anyhow!("unsupported secret service {}", args.service)), } } +fn deliver_caddy_from_vault( + args: &SecretDeliverArgs, + vault: &mut OpenVault, + ui_config: Option<&skillet_cli_common::hosts::HostUiConfig>, +) -> Result<()> { + let ui_config = ui_config.ok_or_else(|| anyhow!("missing host UI declaration"))?; + let environment = args.environment.as_str(); + let domain_path = format!("skillet/environments/{environment}/ui/domain"); + let domain = lookup(&vault.database, &domain_path)? + .ok_or_else(|| anyhow!("KeePassXC UI domain entry is missing: {domain_path}"))?; + let zone_path = format!("skillet/environments/{environment}/cloudflare/zone"); + let zone = lookup(&vault.database, &zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let domain = domain.trim(); + let zone = zone.trim(); + skillet_caddy::validate_domain_in_zone(domain, zone) + .context("validating KeePassXC UI domain against its Cloudflare zone")?; + let token_path = format!( + "skillet/environments/{environment}/hosts/{}/cloudflare/acme-token", + args.hostname + ); + let mut token = lookup(&vault.database, &token_path)?; + if token.is_none() && args.environment == UiEnvironmentName::Production { + token = lookup( + &vault.database, + &format!("skillet/hosts/{}/cloudflare/acme-token", args.hostname), + )?; + } + let token = token + .ok_or_else(|| anyhow!("KeePassXC Cloudflare ACME token entry is missing: {token_path}"))?; + let sites = skillet_caddy::CaddySites::from_host( + &args.hostname, + &skillet_caddy::UiEnvironment { + ui_domain: domain.to_string(), + acme_staging: args.environment.acme_staging(), + }, + &ui_config.services, + )?; + ensure_vault_unchanged(vault)?; + let sites = serde_json::to_string(&sites)?; + for (credential, value) in [ + ("caddy_sites", sites.as_str()), + ("cloudflare_acme_token", token.as_str()), + ] { + install_deferred_for_unit( + &mut ssh_command(args), + &args.hostname, + credential, + "skillet-caddy-apply.service", + value, + )?; + } + let status = ssh_command(args) + .arg("sudo -n systemctl start skillet-caddy-apply.service") + .status() + .context("starting Caddy apply after both credentials were delivered")?; + if !status.success() { + return Err(anyhow!("Caddy apply failed with status {status}")); + } + Ok(()) +} + pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { let path = match path { Some(path) => path.to_path_buf(), @@ -754,30 +803,39 @@ fn save_vault( Ok(()) } -fn install(command: &mut Command, credential: &str, secret: &str) -> Result<()> { - install_for_unit(command, credential, "skillet-full-apply.service", secret) +fn install(command: &mut Command, host: &str, credential: &str, secret: &str) -> Result<()> { + install_for_unit( + command, + host, + credential, + "skillet-full-apply.service", + secret, + ) } fn install_for_unit( command: &mut Command, + host: &str, credential: &str, unit: &str, secret: &str, ) -> Result<()> { - install_for_unit_inner(command, credential, unit, secret, false) + install_for_unit_inner(command, host, credential, unit, secret, false) } fn install_deferred_for_unit( command: &mut Command, + host: &str, credential: &str, unit: &str, secret: &str, ) -> Result<()> { - install_for_unit_inner(command, credential, unit, secret, true) + install_for_unit_inner(command, host, credential, unit, secret, true) } fn install_for_unit_inner( command: &mut Command, + host: &str, credential: &str, unit: &str, secret: &str, @@ -786,8 +844,14 @@ fn install_for_unit_inner( if secret.is_empty() { return Err(anyhow!("refusing to deliver an empty credential")); } - let mut remote = - format!("sudo -n /var/usrlocal/bin/skillet-clamps credential install {credential} {unit}"); + if !host + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(anyhow!("invalid host identifier")); + } + let binary = format!("/var/usrlocal/bin/skillet-{host}"); + let mut remote = format!("sudo -n {binary} credential install {credential} {unit}"); if defer_start { remote.push_str(" --no-start"); } From a0fb99112afa83b681101d7897cc253ffcecc3dd Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 1 Oct 2026 14:07:20 +0200 Subject: [PATCH 104/252] test(caddy): verify idempotent generic apply --- ublue/skillet/crates/caddy/Cargo.toml | 3 ++ ublue/skillet/crates/caddy/src/tests.rs | 58 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/ublue/skillet/crates/caddy/Cargo.toml b/ublue/skillet/crates/caddy/Cargo.toml index 0cb9e891..fe00f271 100644 --- a/ublue/skillet/crates/caddy/Cargo.toml +++ b/ublue/skillet/crates/caddy/Cargo.toml @@ -12,3 +12,6 @@ skillet_podman.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index 8665cdad..5eea8e6e 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -1,4 +1,10 @@ use super::{CaddySites, UiEnvironment, UiService}; +use skillet_core::{ + system::SystemResource, + test_utils::{MockFiles, MockSystem}, +}; +use skillet_podman::PodmanNetwork; +use std::sync::atomic::Ordering; fn syncthing_only() -> Vec { vec![UiService { @@ -8,6 +14,58 @@ fn syncthing_only() -> Vec { }] } +fn test_network() -> PodmanNetwork { + PodmanNetwork { + unit_name: "beezelbot".to_string(), + options: vec![ + "NetworkName=beezelbot".to_string(), + "Driver=bridge".to_string(), + ], + } +} + +#[test] +fn applying_unchanged_caddy_sites_preserves_the_container() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system + .ensure_podman_secret("cloudflare_acme_token", "dummy-token") + .unwrap(); + let sites = CaddySites::from_host( + "beezelbot", + &UiEnvironment { + ui_domain: "test.example.invalid".to_string(), + acme_staging: true, + }, + &syncthing_only(), + ) + .unwrap(); + super::apply(&system, &files, &sites, test_network()).unwrap(); + let initial_restart_count = system.restart_count.load(Ordering::SeqCst); + let initial_config = files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/caddy.container") + .cloned() + .unwrap(); + + super::apply(&system, &files, &sites, test_network()).unwrap(); + + assert_eq!( + system.restart_count.load(Ordering::SeqCst), + initial_restart_count + ); + assert_eq!( + files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/caddy.container"), + Some(&initial_config) + ); +} + #[test] fn derives_a_single_non_clamps_service_hostname_for_either_environment() { let services = syncthing_only(); From eb2bc4e18074c927aae47adb146431b4915ebc64 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 1 Oct 2026 14:07:39 +0200 Subject: [PATCH 105/252] docs: record generic private UI provisioning --- ublue/butane/ACCEPTANCE.md | 62 +++++++++++++++++++------------ ublue/butane/README.md | 35 +++++++++++------ ublue/design/private-ui-access.md | 20 ++++++---- ublue/design/secrets.md | 39 ++++++++++--------- ublue/design/smoke-vms.md | 14 ++++--- ublue/plan/CLAMPS-MIGRATION.md | 6 +-- ublue/plan/GENERIC-PRIVATE-UIS.md | 33 ++++++++++------ ublue/skillet/README.md | 45 +++++++++++++--------- 8 files changed, 156 insertions(+), 98 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 5a8b7614..60b7a098 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -311,27 +311,43 @@ host tool; it still uses normal command approvals. subvolumes; the btrbk design treats selected sources as leaf subvolumes and does not support nested subvolumes. -## Private UI preparation, 2026-10-01 +## Generic private UI slice, 2026-10-01 -- Earlier in this session, a fresh clamps smoke VM completed Ignition, - unsigned then signed uCore rebase, user environment setup, application - provisioning, and Tailscale enrollment. `skillet test smoke clamps` passed - the real-runtime fixture checks, including interrupted activation and reboot. -- Pi-hole, Syncthing, and Tailscale were active. Container DNS resolved Pi-hole - from Syncthing; both web interfaces returned HTML through the shared bridge. - Pi-hole published TCP/UDP port 53 on both address families. Syncthing did - not publish its GUI port 8384 on the host. Repeated full apply preserved - the Pi-hole and Syncthing container IDs. -- The selected Caddy image was pulled and `caddy list-modules` confirmed - `dns.providers.cloudflare`. The dedicated apply unit skipped activation - without its two encrypted credentials. Caddy certificate issuance, DNS - record lifecycle, proxy access restrictions, and renewal were **not tested**. - The generic UI plan tracks the missing shared delivery and live acceptance. -- Before committing, local formatting, pedantic Clippy, workspace tests, and - the CI command `skillet test run beezelbot --phase base --image fedora:latest` - were run. The first test run caught stale Syncthing GUI publication assertions; - they now assert GUI isolation while retaining transfer-port checks. Caddy - tests cover rejected input, staging selection, bridge upstreams, and both - tailnet address families. These tests do not establish live ACME acceptance. -- The prior VM is no longer present when listing runs before this commit; - no new live Caddy acceptance is claimed from the local checks. +- `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test`, `cargo + build`, and the CI command `skillet test run beezelbot --phase base --image + fedora:latest` passed. New tests cover a non-clamps Syncthing-only declaration + in production and test, derived names, multi-service rendering, zone suffix + validation, rejection of invalid/duplicate services, and receiver-side + payload matching. A repeated Caddy apply test confirms unchanged generated + config does not restart the proxy container. Existing container tests still + pass. +- Fresh `clamps-test-generic-ui` completed Ignition, signed uCore rebase, + base apply, Homebrew, and dotfiles installation using the pre-correction + static host-binary unit. A `%H` experiment failed because systemd expands it + to the full VM hostname, which is distinct from the host profile. The shared + unit now invokes the generic CLI with `/etc/skillet/host`; clamps writes the + stable profile `clamps`, and its credential-gated full-apply unit is in a + clamps-specific include. +- On the existing `clamps-test-generic-ui` disposable VM, installed the current + generic and clamps binaries, wrote the stable host profile, and used a + temporary systemd drop-in to run the base unit through the generic + `--host-file` invocation. The unit reported success while the VM hostname + remained `clamps-test-generic-ui`. The real `skillet test smoke clamps + --port 2202 --identity /ssh/id_ed25519` passed through reboot. This + verifies the generic runtime path; a fresh VM from the updated Ignition and + binary-staging flow is still pending. The prior guest evidence remains in its + ignored run directory. +- Strict Butane compilation passed for the updated clamps config and both + shared and clamps-specific unit fragments. Running the normal wrapper built + and staged both current static binaries; decoded merged Ignition confirmed + `/etc/skillet/host=clamps`, generic `skillet` commands for base/full/Caddy, + and the host binary at `/var/usrlocal/bin/skillet-clamps`. + `bash -n`, ShellCheck, and `git diff --check` also passed for the changed + launchers. The exact CI integration command passed in a writable temporary + checkout after the workspace mount rejected Cargo output; that run used the + default CI test image and reported repeat apply with no extra service starts. +- The earlier Caddy gate check had no credentials and confirmed only that the + unit remained skipped. No domain/token was delivered; + Caddy startup, ACME issuance, HTTPS proxy access, renewal, and DNS lifecycle + remain unverified. `beezelbot` has no Butane template, so its real-runtime + service behavior remains unverified. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 95dc8753..7149d157 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -18,6 +18,13 @@ The shared [data storage design](../design/storage.md) is implemented by `includes/data-storage.bu`. On a fresh VM it mounts the Btrfs `data` subvolume at `/var/lib/data` and sets rootful Podman's graphroot there. Recreate a disposable VM made before this include to check the install-time layout. +`bin/butane` builds and stages the generic `skillet` CLI and the host-specific +`skillet-HOST` credential CLI when the input includes `includes/skillet.bu` and +the binaries were not explicitly staged under `files/`. Set `CARGO_TARGET_DIR` +to redirect Cargo output. The generated Ignition therefore contains both +executables for a normal host install. The VM launcher removes the two large +file entries from its temporary Ignition copy and delivers those same binaries +over SSH after the first boot. On Bazzite, the helper uses the existing Flatpak virt-manager installation when native libvirt is absent. The `bin/virsh` and `bin/virt-install` wrappers @@ -80,14 +87,18 @@ cargo run --release -p skillet -- test vm provision clamps smoke cargo run --release -p skillet -- test vm destroy clamps smoke ``` -The helper calls `coreos-install`, which builds the current static -`skillet-clamps` host binary. The lower-level launcher accepts `--artifact PATH` -to use a specific binary. It records its SHA256 in -`runs/NAME/skillet.sha256`. `test-vm ready` transfers the binary -over SSH after first boot and installs it at `/var/usrlocal/bin/skillet-clamps`. -`ready` uses that captured binary on every run. After editing Skillet, use -`cargo run --release -p skillet -- test vm update clamps smoke` to rebuild and -install the current binary on a retained VM. `provision` delivers a disposable +The helper calls `coreos-install`, which builds the generic `skillet` CLI and +the `skillet-clamps` host binary. The lower-level launcher accepts `--artifact PATH` +to use a specific host binary; the generic CLI is built from the workspace. +Both artifact hashes are recorded in `runs/NAME/`. `test-vm ready` transfers +both binaries over SSH after first boot and installs them at +`/var/usrlocal/bin/skillet` and `/var/usrlocal/bin/skillet-clamps`. `ready` +uses those captured binaries on every run. The shared base unit reads the +stable host profile from `/etc/skillet/host`, so a test VM hostname can differ +from its host profile. After editing Skillet, build both binaries with +`cargo build --release --target x86_64-unknown-linux-musl -p skillet -p skillet-clamps` +and use `cargo run --release -p skillet -- test vm update clamps smoke` to +install them on a retained VM. `provision` delivers a disposable Pi-hole password and runs the credential-loaded full apply; `--rotate` replaces that disposable password. The shared uCore bootstrap script also lives in `/var/usrlocal/bin`. @@ -123,15 +134,17 @@ The VM staging step grants `giacomo` passwordless sudo. The guest starts and then to the signed image. It checks the *booted* rpm-ostree deployment on every boot. It never starts the base apply until the signed deployment boots. A failed rebase stops without rebooting; an already pending deployment gets at -most two reboot attempts. The base unit runs -`/var/usrlocal/bin/skillet-clamps apply --phase base` with no app credentials. +most two reboot attempts. The shared base unit runs +`/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base` +with no app credentials. Clamps adds a separate full-apply unit gated on its +Pi-hole and Tailscale credentials. `./bin/test-vm HOST ready INSTANCE` waits up to 45 minutes, checks the expected domain/disk, noninteractive SSH and sudo, signed booted deployment, successful Skillet unit, guest artifact SHA, enforcing SELinux, DNS, masked resolved, Homebrew, dotfiles links, and the complete `core` Brewfile bundle. It writes `domain.txt`, `disks.txt`, `final-status.json`, `final-boot-id`, -`guest-skillet.sha256`, `readiness.log`, and `user-environment.log` into the run +`guest-skillet*.sha256`, `readiness.log`, and `user-environment.log` into the run directory. On timeout, it writes `failure.log` or `user-environment-failure.log` with relevant journals. For manual diagnostics, use `test-vm HOST status INSTANCE` and diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index 022c9f89..915f73f3 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -67,17 +67,21 @@ planes. The names and tailnet IPs are public DNS data. ## Status and references -Planned generalization: host callers declare their UI services, and a shared -provisioner derives `..` from an environment's domain -in KeePassXC. Production and test select different configuration and credential -lifetimes; Caddy and delivery share one implementation. This supersedes the -current per-service hostname entries and clamps-only restrictions when -implemented. See the [implementation plan](../plan/GENERIC-PRIVATE-UIS.md). +Host UI service declarations now drive a shared versioned Caddy payload. +Provisioning derives `..` from the selected +environment's KeePassXC domain. Caddy validates the payload against the host's +declaration and renders each route through bridge DNS with tailnet source +restrictions. Shared Butane units call the generic `skillet` CLI and read the +stable host profile from `/etc/skillet/host`; VM hostnames remain independent. +Host-specific apply units and credential gates live in host-specific Butane +includes. Manual delivery accepts production or test environments. +Cloudflare token/DNS lifecycle and automatic VM provisioning remain planned; +see the [implementation plan](../plan/GENERIC-PRIVATE-UIS.md). Skillet configures host-network Tailscale and manages tagged enrollment and removal for clamps smoke VMs; live smoke enrollment and cleanup were accepted -on 2026-09-30. Caddy now has a Quadlet configuration for Pi-hole -and Syncthing using bridge DNS, persistent certificate storage, Cloudflare +on 2026-09-30. Caddy now has a generic Quadlet configuration for the UI +services declared by the host, using bridge DNS, persistent certificate storage, Cloudflare DNS-01, and source-address restrictions for Tailscale's IPv4 and IPv6 ranges. Its hostname and token credentials are separate from normal full apply. Syncthing no longer publishes its GUI port on the host. Caddy, ACME staging, diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 00b1d71c..ac97a87c 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -1,11 +1,10 @@ # Secret storage and delivery -Planned UI generalization: select a base domain and Cloudflare zone from -environment entries in KeePassXC, derive service names from the host caller's -declarations, and share delivery across persistent and disposable targets. -The [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md) defines the proposed vault -paths and migration. Existing per-service hostname entries and clamps-only -Caddy delivery below describe current code, not the intended shared interface. +UI provisioning reads a base domain and Cloudflare zone from the selected +environment in KeePassXC, derives service names from the host's declarations, +and uses shared credential delivery. Cloudflare token issuance, DNS lifecycle, +and automatic smoke VM wiring remain planned in the +[generic UI plan](../plan/GENERIC-PRIVATE-UIS.md). Decision: use KeePassXC for durable secrets and read and write its KDBX database directly from workstation Skillet through a Rust library. Pi-hole delivery uses @@ -46,9 +45,10 @@ The prefix is consistently singular, `skillet`: | Vault entry | Host credential and Podman secret name | | --- | --- | | `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | -| `skillet/hosts/clamps/caddy/pihole-hostname` | Included in encrypted `caddy_sites` config | -| `skillet/hosts/clamps/caddy/syncthing-hostname` | Included in encrypted `caddy_sites` config | -| `skillet/hosts/clamps/cloudflare/acme-token` | `cloudflare_acme_token` | +| `skillet/environments//ui/domain` | Base for derived Caddy hostnames | +| `skillet/environments//cloudflare/zone` | Zone containing the UI domain | +| `skillet/environments//hosts//cloudflare/acme-token` | `cloudflare_acme_token` | +| `skillet/hosts/clamps/cloudflare/acme-token` | Legacy production token fallback | | `skillet/cloudflare/token-creator` | Workstation only | | `skillet/tailscale/provisioner-client-id` | Workstation only; OAuth Client ID | | `skillet/tailscale/provisioner-client-secret` | Workstation only; OAuth Client secret | @@ -81,16 +81,19 @@ after confirming hardware support and rebase behavior. Host-key encryption is recoverable by someone with the guest disk and its key; these files are runtime copies, while KeePassXC supplies portable recovery. -The full-apply systemd unit loads each file with `LoadCredentialEncrypted=`. +The host-specific full-apply systemd unit loads its files with +`LoadCredentialEncrypted=`. Skillet reads `$CREDENTIALS_DIRECTORY/` and sends the value through stdin to `podman secret create`. Quadlets reference secret names with `Secret=`: Pi-hole receives a file under `/run/secrets/` through `WEBPASSWORD_FILE`; Caddy receives `cloudflare_acme_token` through `type=env,target=CF_API_TOKEN`. -The `caddy_sites` credential contains the two private UI hostnames and the -ACME staging flag. The generated Caddyfile is persisted under `/etc/skillet` -and contains no token. A dedicated systemd apply unit runs only after both -Caddy credentials are installed. Rotation recreates the affected container to -consume the new secret. +The versioned `caddy_sites` credential contains host identity, selected UI +domain, staging policy, and the declared service/upstream list. The receiving +host checks it against its local declaration. The generated Caddyfile is +persisted under `/etc/skillet` and contains no token. A dedicated systemd apply +unit runs only after both Caddy credentials are installed. Old payloads are +rejected; redeliver after updating the environment entries. Production token +lookup temporarily falls back to the existing per-host token path. Podman's default secret storage persists a copy on the guest disk; systemd's credential encryption does not encrypt that copy. @@ -118,9 +121,9 @@ UI records belong in the existing production zone and use a separate scoped credential. Keep literal zone names and records outside this public repository. The token creator remains on the workstation. Cloudflare token issuance and record management are planned; current smoke provisioning only enrolls -Tailscale and provisions Pi-hole. Production Caddy credentials can be delivered -with `secret deliver clamps caddy`; smoke-specific staging and delivery are -not yet wired into VM provisioning. +Tailscale and provisions Pi-hole. Caddy can be delivered manually to a host +with `secret deliver caddy --environment `; smoke VM +provisioning does not invoke this path automatically yet. VM Pi-hole secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, expiry, and owned record IDs in run metadata, without token values. Existing diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 4a23cc84..b654b27d 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -4,8 +4,9 @@ Decision: keep VM creation, readiness, repeatable smoke checks, and destruction separate. A retained VM supports inspection and repeated convergence checks; a fresh VM establishes that bootstrap is reproducible. The existing fixture tests real systemd/Podman convergence. Pi-hole credential delivery and -Tailscale enrollment/device cleanup are implemented. Cloudflare DNS and live -ACME scenarios remain planned. +Tailscale enrollment/device cleanup are implemented. Generic manual Caddy +delivery is available for declared host UIs; Cloudflare DNS lifecycle, automatic +smoke provisioning, and live ACME scenarios remain planned. ## Identity and ownership @@ -25,17 +26,18 @@ the test artifacts and uses QEMU user networking. ## States and recovery -1. **Create:** build the host binary, allocate the run directory and SSH key, +1. **Create:** build the generic CLI and host binary, allocate the run directory and SSH key, and provision a fresh VM disk. Ignition contains the public SSH key and base configuration. Large binaries travel over SSH because embedding them in QEMU `fw_cfg` previously caused long boot delays. -2. **Ready:** wait for SSH, deliver the binary, complete unsigned then signed +2. **Ready:** wait for SSH, deliver both binaries, complete unsigned then signed rebase, and check the booted deployment, base apply, resolver, SELinux, and user environment. Persistent bootstrap state tolerates reboots. Executable staging uses `/var/usrlocal/bin`, whose FCOS labeling permits execution. Ignition's authorized-key file remains configured alongside dotfiles keys. - `ready` uses the artifact captured at creation. Explicit `update` builds - and installs current host code on a retained VM, recording the deployed hash + `/etc/skillet/host` keeps host profile identity independent from a VM's + hostname. `ready` uses the artifacts captured at creation. Explicit `update` + builds and installs current shared and host code on a retained VM, recording deployed hashes separately while preserving the original creation snapshot. 3. **Provision applications:** `test vm provision` unlocks the workstation KeePassXC database, reads the Tailscale OAuth client, mints a one-use smoke diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 10561cd3..18b4dd2b 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -17,9 +17,9 @@ Pi-hole container runs in the VM with data and Podman storage under `/var/lib/data`; service networking, live ACME, final disk selection, and post-provision rebase checks remain open. -Next: implement [generic private UI provisioning](GENERIC-PRIVATE-UIS.md), -using host-declared services and environment domains from KeePassXC, then -validate Cloudflare DNS-01 staging for private Pi-hole and Syncthing UIs. +Next: implement Cloudflare token/DNS lifecycle and automatic smoke VM delivery +from [generic private UI provisioning](GENERIC-PRIVATE-UIS.md), then validate +DNS-01 staging for private Pi-hole and Syncthing UIs. The private-UI design requires a tailnet-only listener and DNS-only Cloudflare records pointing to tailnet addresses, so certificate issuance alone is not the acceptance target. diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 27998caf..974e0dd7 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -1,7 +1,6 @@ # Generic private UI provisioning -Status: ready to implement, 2026-10-01. Planning only; existing Caddy code is -uncommitted and still restricts delivery and apply to clamps. +Status: steps 1 and 2 implemented locally, 2026-10-01. Steps 3 and 4 remain. ## Goal and fixed decisions @@ -26,7 +25,7 @@ the Password field, following the existing exact-lookup convention: | Entry | Purpose | | --- | --- | | `skillet/environments//ui/domain` | Base UI domain | -| `skillet/environments//cloudflare/zone-id` | Authorized existing zone | +| `skillet/environments//cloudflare/zone` | DNS name of the authorized existing zone | | `skillet/environments//hosts//cloudflare/acme-token` | Durable token for a persistent host | | `skillet/cloudflare/token-creator` | Workstation-only token issuer | @@ -56,14 +55,16 @@ ownership, with no disposable token value persisted in run metadata. ## Implement in this order -### 1. Shared host UI declarations and environment configuration +### 1. Shared host UI declarations and environment configuration (implemented) Introduce a typed UI declaration containing a stable service name and its bridge upstream (container DNS name and port). Each host caller supplies its enabled services and Podman network through one reusable host definition. Use that same declaration for Caddy and workstation DNS planning; do not maintain two lists that can drift. Clamps declares Syncthing and Pi-hole; -other hosts can declare Syncthing without acquiring a Pi-hole dependency. +beezelbot declares Syncthing only and now applies that service on its own +bridge network. Future hosts can declare services without acquiring a Pi-hole +dependency. Declaring future services must not require editing Caddy's renderer. Add a validated environment configuration and one hostname derivation helper. @@ -77,7 +78,7 @@ Exit: a non-clamps host with only Syncthing derives exactly one route; the same host definition works with either environment. Use reserved example domains in tests. Missing config fails with the entry path, without values. -### 2. Generic Caddy configuration and delivery +### 2. Generic Caddy configuration and delivery (implemented) Replace `CaddySites { pihole, syncthing, ... }` with a versioned service-list payload and explicit issuer policy. Keep the host credential name `caddy_sites` @@ -87,7 +88,13 @@ the shared declaration; validate the payload again on the receiving host. Remove the clamps guard in Caddy apply. Pass the caller's network and services into shared apply. Remove hardcoded `/var/usrlocal/bin/skillet-clamps` from the delivery helpers; resolve the target binary from the validated host -definition. Remove the CLI's clamps-only delivery parser/guard, but reject +definition. Shared Butane units invoke the generic `skillet` CLI with the +stable host profile in `/etc/skillet/host`; they must not derive host identity +from the VM hostname or bake a host binary name into shared configuration. +Host-specific apply units and credential gates belong in host-specific Butane +includes. The host include also stages the host credential CLI; the shared +Butane compiler builds and stages both static binaries when they are not +provided. Remove the CLI's clamps-only delivery parser/guard, but reject unsupported hosts and undeclared services before unlocking/mutating resources. Do not pretend unfinished host templates are ready for VM provisioning. @@ -98,13 +105,15 @@ Caddyfiles. Use normal blocking systemctl starts, without `--wait`. CLI target: `skillet secret deliver caddy --environment production` with the existing SSH and vault options. The shared delivery function accepts -host definition, environment config, credentials, and transport; VM provisioning -calls this same function with its recorded transport and test environment. +host definition, environment config, credentials, and transport. VM provisioning +does not call it yet; wiring it with its recorded transport and test environment +is step 4. Exit: rerunning unchanged delivery preserves container identity. A host with -Syncthing only has no Pi-hole route. Existing old `caddy_sites` credentials -receive a documented explicit redelivery/migration path; do not silently -interpret the old payload as the new schema. +Syncthing only has no Pi-hole route. Existing old `caddy_sites` payloads and +per-service hostname entries require explicit redelivery; old payloads are +rejected rather than silently reinterpreted. Production token lookup temporarily +falls back to the existing per-host token path. ### 3. Shared Cloudflare token and DNS reconciliation diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index c1103ac8..72c692c2 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -74,7 +74,9 @@ cargo run --release -p skillet -- test smoke clamps The smoke command defaults to that target, port, and generated key. Override them with `--target`, `--port`, and `--identity` when using a separately provisioned VM. It takes the generic binary from the running executable and -uses the `skillet-clamps` binary installed by VM creation. Repeated smoke runs +uses the `skillet-clamps` binary installed by VM creation. Shared systemd +apply units use the generic binary and the stable host profile in +`/etc/skillet/host`. Repeated smoke runs reset only the namespaced `/var/lib/skillet-smoke` fixture and its managed files. The VM remains available for inspection and reruns. After editing host code, use @@ -96,12 +98,13 @@ Build both static binaries from this directory if you want to install the host binary separately: ```bash -cargo build --release -p skillet-clamps -p skillet -sha256sum target/x86_64-unknown-linux-musl/release/skillet-clamps +cargo build --release --target x86_64-unknown-linux-musl -p skillet -p skillet-clamps +sha256sum target/x86_64-unknown-linux-musl/release/skillet{,-clamps} ``` -The host artifact is `target/x86_64-unknown-linux-musl/release/skillet-clamps`. -Supply that artifact to the guest, then invoke `skillet-clamps apply --phase base`. +The generic artifact is `target/x86_64-unknown-linux-musl/release/skillet`; +the host artifact is `target/x86_64-unknown-linux-musl/release/skillet-clamps`. +The host artifact handles host-specific credential commands. The smoke scenario checks baseline/full separation, container startup and idempotency, configuration and dummy secret rotation, failed startup recovery, @@ -183,18 +186,21 @@ entry before removing the old copy. The current smoke command does not use it. ### Caddy UI credentials -Caddy proxies the Pi-hole and Syncthing UIs on port 443 and rejects clients -outside the Tailscale IPv4 and IPv6 ranges. Its credentials are independent of -normal full apply. In KeePassXC, store each value in the Password field: +Caddy proxies each host-declared UI on port 443 and rejects clients outside +the Tailscale IPv4 and IPv6 ranges. Its credentials are independent of normal +full apply. In KeePassXC, store each value in the Password field: | Group path | Entry title | Value | | --- | --- | --- | -| `skillet/hosts/clamps/caddy` | `pihole-hostname` | Private Pi-hole UI hostname | -| `skillet/hosts/clamps/caddy` | `syncthing-hostname` | Private Syncthing UI hostname | -| `skillet/hosts/clamps/cloudflare` | `acme-token` | Cloudflare API token with Zone Read and DNS Edit, scoped to the production zone | +| `skillet/environments/production/ui` | `domain` | Base domain for derived host UI names | +| `skillet/environments/production/cloudflare` | `zone` | Cloudflare zone containing the UI domain | +| `skillet/environments/production/hosts//cloudflare` | `acme-token` | Existing token with Zone Read and DNS Edit | +| `skillet/environments/test/ui` | `domain` | Test base domain | +| `skillet/environments/test/cloudflare` | `zone` | Cloudflare zone containing the test UI domain | +| `skillet/environments/test/hosts//cloudflare` | `acme-token` | Test token with Zone Read and DNS Edit | -Create DNS-only A/AAAA records for those names pointing to the clamps Tailscale -addresses. Then deliver the credentials over a host-key-verified SSH session: +Create DNS-only A/AAAA records for derived names pointing to that host's +Tailscale addresses. Then deliver the credentials over a host-key-verified SSH session: ```bash cargo run --release -p skillet -- secret deliver clamps caddy \ @@ -202,11 +208,16 @@ cargo run --release -p skillet -- secret deliver clamps caddy \ --known-hosts /path/to/known_hosts ``` -Skillet keeps the hostname configuration private in the KeePassXC database and -encrypted host credential. The Cloudflare token becomes a Podman secret used +Skillet derives each name as `..` from that host's +declared UI services and keeps the versioned configuration in an encrypted +host credential. The Cloudflare token becomes a Podman secret used only by Caddy's DNS-01 provider. The command installs both encrypted host -credentials before starting its dedicated apply service. The smoke VM staging -path and automatic DNS/token lifecycle are still being implemented. +credentials before starting its dedicated apply service. For a test VM, select +`--environment test`; this uses the staging ACME directory. Automatic VM token +creation, DNS record lifecycle, and credential delivery during `vm provision` +remain future work. Existing per-service hostname entries are no longer read; +redeliver using the environment entries. Production token lookup temporarily +supports the previous `skillet/hosts//cloudflare/acme-token` path. ### Tailscale setup for Skillet From bb35651b61550d08dd486ae65d7bc5ead4439b6f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 1 Oct 2026 17:22:20 +0200 Subject: [PATCH 106/252] docs(ublue): define Cloudflare UI lifecycle and aliases --- ublue/design/cloudflare-ui-lifecycle.md | 98 +++++++++++++++++++++++++ ublue/design/private-ui-access.md | 18 ++++- ublue/design/secrets.md | 18 +++-- ublue/design/smoke-vms.md | 7 +- ublue/plan/GENERIC-PRIVATE-UIS.md | 90 +++++++++++++++++++---- ublue/skillet/README.md | 46 +++++------- 6 files changed, 223 insertions(+), 54 deletions(-) create mode 100644 ublue/design/cloudflare-ui-lifecycle.md diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md new file mode 100644 index 00000000..9c210416 --- /dev/null +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -0,0 +1,98 @@ +# Cloudflare UI credentials and DNS lifecycle + +Decision (planned): implement automated token issuance and cleanup before live +Caddy ACME acceptance. Do not require manually created smoke VM ACME tokens. +The workstation keeps `skillet/cloudflare/token-creator` in KeePassXC and mints +zone-scoped child tokens; only a child token is delivered to Caddy. + +## Configuration and access + +Each environment supplies `skillet/environments//dns/ui-domain` +and `skillet/environments//dns/cloudflare-zone-id` in the vault's +Password fields. The latter is a Cloudflare Zone ID. Resolve that exact ID +through the API and validate its returned zone name contains the UI domain +before mutations. Current code reads older paths and a zone name; migrating +those lookups is required, and a zone name must never be treated as an ID. +Production and test retain separate configuration. + +Host declarations derive a machine name `.` with one A +record for its verified Tailscale IPv4 address and one AAAA for its verified, +usable Tailscale IPv6 address. A missing address family is reported explicitly; +do not invent an address or publish an unusable AAAA record. Each canonical UI +name `..` is a CNAME to that machine name. Keep all +records DNS-only with a short TTL. Address changes update the machine records +without rewriting every UI record. DNS-01 can issue certificates before +address publication, but working UI URLs require this record chain. + +Each host caller may declare zero or more aliases per UI in the same service +declaration that supplies its upstream. Aliases are domain-free relative DNS +names beneath the selected environment's UI domain, optionally containing an +explicit `{host}` label placeholder. This keeps real domains in KeePassXC +and gives test and production the same declaration. Absolute names and aliases +outside this namespace require a later explicit vault-backed design; never +broaden token scope automatically. Validate expanded names and reject wildcard, +cyclic, conflicting, or duplicate names across services before mutations. + +Each alias is a CNAME to its canonical UI name. Caddy serves the canonical +name and every alias through the same upstream and tailnet restrictions; +aliases are additional routes, not redirects. Caddy must obtain and renew +certificate coverage for every served name, whether certificates are separate +or contain multiple names. A CNAME does not give its source name the target's +TLS identity. Include the alias set in the versioned encrypted Caddy payload +and validate it against the receiving host's declaration. Verify DNS, TLS +hostname identity, and upstream responses for canonical names and aliases. +Publish no LAN or public host addresses. + +Child tokens have Zone Read and DNS Edit restricted to the selected zone. +DNS Edit covers A, AAAA, CNAME, and ACME TXT records, so address publication +needs no additional permission. The scope covers the whole zone, not an individual +host's records. Separate tokens allow independent revocation, while the issuer +stays on the workstation. Public DNS exposes the published names and tailnet +addresses; keep those values out of the public repository. + +## Provision, reuse, and recovery + +Persistent deployments use-or-create a token at +`skillet/environments//hosts//cloudflare/acme-token` through +the atomic KDBX writer. Reuse existing tokens; rotation is explicit. If saving +an issued token fails, revoke it or retain ownership sufficient for recovery. +Never silently replace an existing credential. + +Disposable deployments mint a bounded-lifetime token for the recorded VM +owner, using no manually populated ACME entry. Journal token ID, expiry, +environment, and target ownership in ignored mode-0600 run metadata. Keep the +token value only in memory during delivery and in the existing encrypted host +credential and Podman secret path, never in metadata or logs. Record issuance +intent before API calls and ownership immediately afterward. Deterministic +ownership markers must allow recovery after lost responses; ambiguity fails +without deleting resources. If a token value cannot be recovered after an +interruption, revoke the owned token and issue a replacement explicitly. + +Reconcile the machine A/AAAA records, canonical UI CNAMEs, and all alias +CNAMEs with the enrolled device and host declarations. Reuse matching owned +records, reject unrelated conflicts, and journal record IDs and expected type/value/ownership. Concurrent instances +claiming the same environment/host names must be refused; never append an +instance to a UI hostname silently. Install both Caddy credentials before +activation. Repeat provisioning reuses tokens, records, and certificate state. + +## Disposal and expiry + +Delete verified owned alias CNAMEs first, then canonical UI CNAMEs, then +machine A/AAAA records, and revoke the disposable token. Removing an alias +from a host declaration reconciles its owned DNS record and Caddy route; do +not delete shared machine records while other declared UIs still use them. +Caddy manages its own challenge TXT lifecycle; +never sweep other TXT records or delete records solely by hostname. Clean up +the Tailscale device and local VM only after external ownership is resolved. +Failures retain metadata for retry, including partial or already-gone guests. + +Token expiry does not remove DNS records. If the VM token is expired or +revoked, mint and journal a temporary zone-scoped cleanup token using the +workstation issuer, delete the verified records, and revoke the cleanup token. +Retained VMs need explicit token renewal for later ACME operations. Production +credentials and records are never disposed by test VM commands. + +Status: design only. Implement and mock-test issuance, recovery, DNS ownership, +expiry cleanup, and disposal before a fresh staging ACME/HTTPS VM acceptance. +See [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md), +[secret delivery](secrets.md), and [VM lifecycle](smoke-vms.md). diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index 915f73f3..2482a7a6 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -19,8 +19,13 @@ client secret. Use host-specific names in existing Cloudflare-managed production and smoke-test zones rather than `*.ts.net`, so UI URLs and Caddy routes can survive a move -between hosted Tailscale and Headscale. Publish DNS-only A/AAAA records pointing -to each host's tailnet address. Do not enable Cloudflare proxying. Keep literal +between hosted Tailscale and Headscale. Publish DNS-only A/AAAA records for +`.` pointing to its tailnet addresses. Canonical UI names CNAME to that machine name; +caller-declared aliases CNAME to their canonical UI names. Caddy serves every +name with matching certificate coverage and the same upstream/access policy. +The machine/CNAME/alias model is planned; see the +[Cloudflare lifecycle](cloudflare-ui-lifecycle.md). Do not enable Cloudflare +proxying. Keep literal zones and records outside this public repository; public DNS still exposes the names and addresses. Some resolvers block public answers containing tailnet addresses as DNS rebinding, so check resolution from real clients before @@ -30,8 +35,9 @@ records: Caddy serves the certificate. Do not configure Tailscale split DNS for the UI names. A VM can enroll before its DNS record exists. After enrollment, the workstation reads its tailnet -address and creates a DNS-only record with a short-lived token scoped to the -smoke-test zone. VM destruction deletes only its recorded DNS records and +addresses and reconciles the machine address records and UI/alias CNAMEs +with a short-lived token scoped to the smoke-test zone. VM destruction deletes +only its recorded DNS records and revokes that token. Production records use a separately scoped credential; update them if a host's tailnet address changes. Tailscale enrollment needs no DNS-management OAuth scope. Keep the Cloudflare token creator on the workstation. @@ -67,6 +73,10 @@ planes. The names and tailnet IPs are public DNS data. ## Status and references +Implement the [Cloudflare ownership lifecycle](cloudflare-ui-lifecycle.md) +before live ACME acceptance. Issue credentials automatically and verify DNS +cleanup after expiry; do not require a manually created VM token. + Host UI service declarations now drive a shared versioned Caddy payload. Provisioning derives `..` from the selected environment's KeePassXC domain. Caddy validates the payload against the host's diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index ac97a87c..4f61d546 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -37,7 +37,7 @@ permissions needed to set its expiry. If the kernel denies expiry, Skillet removes the new key and continues without a cache. `skillet secret lock` removes a cached key early. A new workstation can unlock the copied database without the old one's keyring. -The existing `cloudflare-token-creator` keyring item still needs migration. +The Cloudflare token creator is stored at `skillet/cloudflare/token-creator`. Vault paths are group paths plus an entry title; values use the Password field. The prefix is consistently singular, `skillet`: @@ -45,8 +45,8 @@ The prefix is consistently singular, `skillet`: | Vault entry | Host credential and Podman secret name | | --- | --- | | `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | -| `skillet/environments//ui/domain` | Base for derived Caddy hostnames | -| `skillet/environments//cloudflare/zone` | Zone containing the UI domain | +| `skillet/environments//dns/ui-domain` | Base for derived Caddy hostnames | +| `skillet/environments//dns/cloudflare-zone-id` | Planned Cloudflare Zone ID lookup | | `skillet/environments//hosts//cloudflare/acme-token` | `cloudflare_acme_token` | | `skillet/hosts/clamps/cloudflare/acme-token` | Legacy production token fallback | | `skillet/cloudflare/token-creator` | Workstation only | @@ -99,6 +99,12 @@ credential encryption does not encrypt that copy. ## Disposable credentials +The [Cloudflare lifecycle decision](cloudflare-ui-lifecycle.md) must be +implemented before live Caddy acceptance. New DNS vault paths and Zone ID +lookup in the table above are planned; current manual delivery reads the +previous `ui/domain` and `cloudflare/zone` paths and expects a zone name. +Do not create a manual VM ACME token to bypass lifecycle implementation. + Smoke VMs get generated test passwords, never production host entries. Tailscale enrollment reads `skillet/tailscale/provisioner-client-id` and `skillet/tailscale/provisioner-client-secret` locally, then mints a one-use, @@ -114,8 +120,10 @@ uses the server tag and the same one-use-key path. Live UI/ACME testing will read `skillet/cloudflare/token-creator` locally and mint one short-lived Cloudflare token per VM, with Zone Read and DNS Edit restricted to the existing smoke-test zone. Once a VM joins the tailnet, the workstation -will create DNS-only A/AAAA records pointing at its tailnet address; the VM -token also supports Caddy's DNS-01 challenge. DNS Edit covers the whole zone; +will create machine A/AAAA records pointing at its tailnet addresses, UI +CNAMEs to that machine, and caller-declared alias CNAMEs to canonical UIs. +The Caddy payload will include all served names for TLS coverage and proxying. +The VM token also supports DNS-01. DNS Edit covers the whole zone; unique test names prevent collisions, not access to other records. Production UI records belong in the existing production zone and use a separate scoped credential. Keep literal zone names and records outside this public repository. diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index b654b27d..0a1ab3cb 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -58,9 +58,14 @@ the test artifacts and uses QEMU user networking. ## Live ACME boundary +Implement the [Cloudflare ownership lifecycle](cloudflare-ui-lifecycle.md) +before live ACME acceptance. Issue credentials automatically and verify DNS +cleanup after expiry; do not require a manually created VM token. + Routine smoke checks need no Cloudflare access. The optional live scenario enrolls the VM in Tailscale, obtains its tailnet IP, and creates a DNS-only -A/AAAA record under the existing smoke-test Cloudflare zone using a per-VM +machine A/AAAA record set and UI/alias CNAMEs under the existing smoke-test +Cloudflare zone using a per-VM token minted by the workstation token creator. It uses Let's Encrypt staging and a unique hostname, preserving the zone's other records. Keep the literal zone outside this public repository. DNS-01 needs no publicly reachable VM. diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 974e0dd7..3b3778fe 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -1,6 +1,9 @@ # Generic private UI provisioning -Status: steps 1 and 2 implemented locally, 2026-10-01. Steps 3 and 4 remain. +Status: steps 1 and 2 implemented locally, 2026-10-01. Step 3 is the next +prerequisite for live Caddy testing. The new DNS vault paths and Zone ID +lookup below are not implemented yet. Machine records, UI CNAMEs, and caller +alias declarations are new pending extensions to steps 1 and 2. ## Goal and fixed decisions @@ -10,7 +13,9 @@ Cloudflare credential through the existing encrypted SSH delivery mechanism. Syncthing is intended on every host; Pi-hole is included only where configured. The caller selects the environment and declares the services. Caddy, DNS name derivation, token issuance, and credential delivery must contain no clamps or -smoke special cases. +smoke special cases. Machine A/AAAA records are shared by all UIs; canonical +UI names and caller-declared aliases use CNAMEs. Every served name needs +certificate coverage and the same Caddy upstream/access policy. `host` means the host configuration name, such as clamps. It does not mean a libvirt domain, Tailscale device name, or VM instance. Environment selects @@ -24,8 +29,8 @@ the Password field, following the existing exact-lookup convention: | Entry | Purpose | | --- | --- | -| `skillet/environments//ui/domain` | Base UI domain | -| `skillet/environments//cloudflare/zone` | DNS name of the authorized existing zone | +| `skillet/environments//dns/ui-domain` | Base UI domain | +| `skillet/environments//dns/cloudflare-zone-id` | Cloudflare Zone ID of the authorized existing zone | | `skillet/environments//hosts//cloudflare/acme-token` | Durable token for a persistent host | | `skillet/cloudflare/token-creator` | Workstation-only token issuer | @@ -78,6 +83,34 @@ Exit: a non-clamps host with only Syncthing derives exactly one route; the same host definition works with either environment. Use reserved example domains in tests. Missing config fails with the entry path, without values. +### 1a. Caller-declared UI aliases (pending; before Cloudflare mutations) + +Extend each shared UI service declaration with a list of relative alias names +beneath the environment's UI domain; an empty list preserves current behavior. +Allow a `{host}` placeholder only as a complete DNS label. For example, +`sync.{host}` derives `sync..`; `sync` derives +`sync.`. Keep real domains out of caller code. Reject absolute +names, other placeholders, wildcards, and names outside the selected namespace. +Validate all expanded names globally: aliases cannot equal the machine name, +collide with another UI/canonical/alias name, or create loops. Validate before +unlocking for mutations or issuing tokens; environment values require an +unlocked vault before their validation. + +Derive one shared DNS plan containing the machine name, canonical UI names, +aliases, and CNAME targets; use the same resolved names for Caddy delivery. +Extend and version the Caddy payload for aliases, validate it against the +receiving caller declaration, and require explicit redelivery for old payloads. +Render each service's canonical name and aliases with the same upstream and +access restrictions. Caddy manages TLS coverage/renewal for every name; aliases +must serve the application directly rather than redirect to a canonical URL. +Certificate file/count layout is not an acceptance requirement. + +Exit: a host with zero aliases behaves as before; a host with multiple aliases +serves every name on its declared upstream. Separate tests cover name/target +collisions, invalid expansions, payload mismatch, and alias isolation across +environments. Use only reserved domains in tests. Document application host or +origin allowlists when an upstream requires changes to accept its aliases. + ### 2. Generic Caddy configuration and delivery (implemented) Replace `CaddySites { pihole, syncthing, ... }` with a versioned service-list @@ -115,7 +148,17 @@ per-service hostname entries require explicit redelivery; old payloads are rejected rather than silently reinterpreted. Production token lookup temporarily falls back to the existing per-host token path. -### 3. Shared Cloudflare token and DNS reconciliation +### 3. Shared Cloudflare token and DNS reconciliation (next) + +Follow [Cloudflare UI lifecycle](../design/cloudflare-ui-lifecycle.md). Do not +use manually created VM ACME tokens as an interim acceptance path. + +First migrate environment lookups to the DNS paths above. Previous `ui/domain` +and `cloudflare/zone` lookups are superseded. The old zone value was a DNS name; +never reinterpret it as an ID. Fetch the exact zone by its configured ID and +validate the returned name contains the UI domain before issuance or record +changes. Missing entries report paths without values. Update lookup tests and +setup instructions with the migration. Add a Rust HTTP client module with typed errors and injectable API boundary. Reuse project HTTP dependencies; do not use a Cloudflare CLI or shell program. @@ -125,10 +168,18 @@ protection. If saving fails after issuance, revoke the newly issued token or retain recoverable ownership before returning an error. Never replace an existing host token implicitly; rotation is explicit. -After Tailscale enrollment, reconcile DNS-only A/AAAA records for every declared -UI to the target's actual tailnet addresses. Reuse matching owned records; -refuse unrelated conflicting records. Do not create a new zone or configure -Tailscale split DNS. No record deletion by name alone. +After Tailscale enrollment, reconcile one machine name `.` +with A and AAAA records for its verified Tailscale IPv4 and usable IPv6. +Report a missing family explicitly rather than creating an invalid record. +Create each `..` as a CNAME to the machine name; +create each caller-declared alias as a CNAME to its canonical service name. +Use short TTLs and `proxied=false` for every managed record. Zone DNS Edit +covers A, AAAA, CNAME, and ACME TXT records with no extra permissions. +Reuse matching owned records; refuse unrelated conflicts, including existing +A/AAAA data at a desired CNAME name. Do not create a zone or configure +Tailscale split DNS. No deletion by name alone. Update machine addresses +without duplicating address records per UI. Reconcile removed aliases without +disrupting other routes or services; remove only owned obsolete records. Separate credential lifetime from UI configuration: a persistent deployment reuses its vault token; a disposable deployment records token ID, expiry, @@ -140,12 +191,18 @@ recovery, but never token values. Journal external ownership immediately after each successful mutation. Use deterministic ownership markers to recover a response-loss/crash window; refuse -ambiguous resources. Disposable cleanup removes only recorded/verified records -and revokes only its token. Failures retain enough state for retry and prevent -discarding the run directory. Persistent cleanup/rotation requires an explicit +ambiguous resources. Disposable cleanup removes only recorded/verified alias +CNAMEs, canonical CNAMEs, and then machine A/AAAA records before revoking its +token. If the token expired, mint and journal a temporary scoped +cleanup token through the issuer and revoke it afterward. Token expiry does +not delete DNS records. Do not sweep Caddy challenge TXT or unrelated records. +Failures retain enough state for retry and prevent discarding the run +directory. Persistent cleanup/rotation requires an explicit operation and is never triggered by a VM disposal command. -Exit: both environments use the same reconciler; a second apply creates no +Exit: new DNS paths and Zone ID validation are covered; issuance requires no +manually populated VM ACME entry; cleanup works after token expiry; both +environments use the same reconciler; a second apply creates no duplicate token or records; failures at each external mutation can resume or clean up. Concurrent deployments claiming the same host/environment names are refused. Do not append VM instance names to URLs silently; supporting @@ -166,7 +223,9 @@ provision cycle for final acceptance. User unlocks the KDBX in their own termina subsequent commands reuse the three-hour cache. Do not print secret values. Exit: staging DNS-01 succeeds, certificates have the derived service names, -and HTTPS reaches both enabled upstreams from an authorized tailnet client. +and HTTPS reaches every enabled upstream through its canonical name and all +aliases from an authorized tailnet client. Verify the machine A/AAAA and UI +CNAME chains through the real client resolver, including IPv6 when available. Staging certificates are untrusted: verify staging trust/identity explicitly; do not claim trusted-browser acceptance or use unchecked `curl -k` as proof. Verify actual client DNS resolution, denial outside tailnet, absence of direct @@ -178,7 +237,8 @@ scenario; if not exercised, record it as outstanding rather than passed. ## Required validation and documentation Add meaningful separate test modules covering hostname/config validation, -single/multiple service rendering, host/environment selection, API ownership, +single/multiple service and alias rendering, host/environment selection, +machine A/AAAA and CNAME reconciliation, alias removal, API ownership, idempotency, cleanup, and interrupted issuance/delivery. Mock HTTP for CI so routine CI needs no vault, Cloudflare token, or real DNS zone. Cover a second host definition to catch clamps dependencies; do not require unfinished hosts diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 72c692c2..43e2985f 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -186,38 +186,26 @@ entry before removing the old copy. The current smoke command does not use it. ### Caddy UI credentials -Caddy proxies each host-declared UI on port 443 and rejects clients outside -the Tailscale IPv4 and IPv6 ranges. Its credentials are independent of normal -full apply. In KeePassXC, store each value in the Password field: +Caddy proxies host-declared UIs on port 443 over Tailscale. Prepare these +KeePassXC entries in their Password fields: | Group path | Entry title | Value | | --- | --- | --- | -| `skillet/environments/production/ui` | `domain` | Base domain for derived host UI names | -| `skillet/environments/production/cloudflare` | `zone` | Cloudflare zone containing the UI domain | -| `skillet/environments/production/hosts//cloudflare` | `acme-token` | Existing token with Zone Read and DNS Edit | -| `skillet/environments/test/ui` | `domain` | Test base domain | -| `skillet/environments/test/cloudflare` | `zone` | Cloudflare zone containing the test UI domain | -| `skillet/environments/test/hosts//cloudflare` | `acme-token` | Test token with Zone Read and DNS Edit | - -Create DNS-only A/AAAA records for derived names pointing to that host's -Tailscale addresses. Then deliver the credentials over a host-key-verified SSH session: - -```bash -cargo run --release -p skillet -- secret deliver clamps caddy \ - --target giacomo@clamps --identity /path/to/ssh-key \ - --known-hosts /path/to/known_hosts -``` - -Skillet derives each name as `..` from that host's -declared UI services and keeps the versioned configuration in an encrypted -host credential. The Cloudflare token becomes a Podman secret used -only by Caddy's DNS-01 provider. The command installs both encrypted host -credentials before starting its dedicated apply service. For a test VM, select -`--environment test`; this uses the staging ACME directory. Automatic VM token -creation, DNS record lifecycle, and credential delivery during `vm provision` -remain future work. Existing per-service hostname entries are no longer read; -redeliver using the environment entries. Production token lookup temporarily -supports the previous `skillet/hosts//cloudflare/acme-token` path. +| `skillet/environments//dns` | `ui-domain` | Base domain for derived host UI names | +| `skillet/environments//dns` | `cloudflare-zone-id` | Cloudflare Zone ID containing the UI domain | +| `skillet/cloudflare` | `token-creator` | Workstation token issuer | + +Select `production` or `test` for ``. Copy the Zone ID from the +selected zone's Cloudflare Overview page. Move the base-domain value from +`skillet/dns/smoke-ui-zone` to the test `dns/ui-domain` entry before removing +the old entry. The previous `cloudflare/zone` entry contained a zone name; +populate `dns/cloudflare-zone-id` with the actual ID. + +These are the agreed target paths; current delivery still reads older paths. +Token issuance, DNS reconciliation, path migration, and cleanup must be +implemented before the next live ACME acceptance run. Do not create a manual +smoke VM ACME token. See the [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md) +and [Cloudflare lifecycle design](../design/cloudflare-ui-lifecycle.md). ### Tailscale setup for Skillet From 3af993e958703c8ad1c1cc1b3935aac92c68efb1 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 11:04:55 +0200 Subject: [PATCH 107/252] feat(skillet): manage Cloudflare UI credentials and DNS --- ublue/skillet/Cargo.lock | 1114 ++++++++++++++++- ublue/skillet/Cargo.toml | 1 + ublue/skillet/crates/caddy/src/lib.rs | 72 +- ublue/skillet/crates/caddy/src/tests.rs | 49 + ublue/skillet/crates/cli-common/src/hosts.rs | 3 + ublue/skillet/crates/cli/Cargo.toml | 1 + ublue/skillet/crates/cli/src/cloudflare.rs | 634 ++++++++++ .../crates/cli/src/cloudflare_tests.rs | 181 +++ ublue/skillet/crates/cli/src/main.rs | 4 + .../skillet/crates/cli/src/secret_delivery.rs | 347 ++++- .../crates/cli/src/secret_delivery/tests.rs | 2 +- ublue/skillet/crates/cli/src/tailscale.rs | 36 + 12 files changed, 2399 insertions(+), 45 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/cloudflare.rs create mode 100644 ublue/skillet/crates/cli/src/cloudflare_tests.rs diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 1d4673ea..ea320422 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -377,6 +377,15 @@ dependencies = [ "piper", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -389,6 +398,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cbc" version = "0.2.1" @@ -429,6 +444,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -438,8 +454,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -494,6 +512,25 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cloudflare" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1678b3295890df5895480a7c080430e73df2b7101f1763f62a3b32dd532f1d37" +dependencies = [ + "chrono", + "http", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with", + "thiserror 2.0.21", + "url", + "urlencoding", + "uuid", +] + [[package]] name = "cmov" version = "0.5.4" @@ -600,6 +637,80 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + [[package]] name = "digest" version = "0.10.7" @@ -622,6 +733,23 @@ dependencies = [ "ctutils", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "endi" version = "1.1.1" @@ -715,6 +843,25 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -784,8 +931,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -798,11 +947,18 @@ dependencies = [ "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -851,6 +1007,45 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "humansize" version = "2.1.3" @@ -869,6 +1064,66 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -893,12 +1148,133 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -921,6 +1297,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -933,6 +1315,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -964,7 +1400,7 @@ dependencies = [ "hex-literal", "hmac", "hybrid-array", - "indexmap", + "indexmap 2.13.0", "js-sys", "quick-xml", "rust-argon2", @@ -1044,12 +1480,24 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "memchr" version = "2.8.0" @@ -1097,6 +1545,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.29.0" @@ -1129,6 +1588,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1203,6 +1668,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1250,6 +1745,62 @@ dependencies = [ "serde", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1273,7 +1824,18 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1283,7 +1845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1295,6 +1857,96 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rpassword" version = "7.5.4" @@ -1328,6 +1980,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.4" @@ -1341,6 +1999,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1363,6 +2056,30 @@ dependencies = [ "cipher", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -1410,9 +2127,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1432,13 +2149,58 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_yml" version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ - "indexmap", + "indexmap 2.13.0", "itoa", "libyml", "memchr", @@ -1517,6 +2279,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "cloudflare", "hex", "keepass", "keyutils", @@ -1669,6 +2432,22 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1681,6 +2460,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1703,6 +2488,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1765,6 +2570,76 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -1780,7 +2655,7 @@ version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ - "indexmap", + "indexmap 2.13.0", "toml_datetime", "toml_parser", "winnow", @@ -1795,6 +2670,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1852,6 +2772,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "twofish" version = "0.8.0" @@ -1902,6 +2828,30 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac20c07bcac4f505f1e4fbd2e55b54f6297a35a27538f107cb3d4254b4514146" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "users" version = "0.11.0" @@ -1912,6 +2862,12 @@ dependencies = [ "log", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1942,6 +2898,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1979,6 +2944,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -2028,7 +3004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -2041,10 +3017,39 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2232,7 +3237,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -2263,7 +3268,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -2282,7 +3287,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", @@ -2292,6 +3297,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xdg-home" version = "1.3.0" @@ -2302,6 +3313,29 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + [[package]] name = "zbus" version = "4.4.0" @@ -2326,7 +3360,7 @@ dependencies = [ "hex", "nix", "ordered-stream", - "rand", + "rand 0.8.5", "serde", "serde_repr", "sha1", @@ -2384,6 +3418,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" @@ -2404,6 +3459,39 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 0e63cd9c..a6da088d 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -39,6 +39,7 @@ zbus = { version = "4.4", features = ["blocking"] } askama = "0.12" keepass = { version = "0.13.25", features = ["save_kdbx4"] } rpassword = "7.5" +cloudflare = { version = "0.14.0", default-features = false, features = ["blocking", "rustls-tls"] } keyutils = "0.4" [workspace.lints.rust] diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index a396d43c..387784b6 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -19,6 +19,8 @@ pub struct UiService { pub name: String, pub upstream: String, pub port: u16, + #[serde(default)] + pub aliases: Vec, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -34,6 +36,7 @@ pub struct CaddySites { pub host: String, pub ui_domain: String, pub acme_staging: bool, + pub machine_hostname: String, pub services: Vec, } @@ -42,6 +45,7 @@ pub struct CaddySites { pub struct CaddySite { pub name: String, pub hostname: String, + pub aliases: Vec, pub upstream: String, pub port: u16, } @@ -73,7 +77,10 @@ impl CaddySites { "host declares no UI services".to_string(), )); } + let machine_hostname = format!("{host}.{}", environment.ui_domain); + validate_domain(&machine_hostname)?; let mut sites = Vec::with_capacity(services.len()); + let mut all_hostnames = BTreeMap::from([(machine_hostname.clone(), "machine")]); for service in services { validate_label(&service.name, "service name")?; validate_label(&service.upstream, "upstream name")?; @@ -94,25 +101,51 @@ impl CaddySites { } let hostname = format!("{}.{}.{}", service.name, host, environment.ui_domain); validate_domain(&hostname)?; + if all_hostnames + .insert(hostname.clone(), "canonical UI") + .is_some() + { + return Err(CaddyError::InvalidConfiguration( + "duplicate UI hostname".to_string(), + )); + } + let mut aliases = Vec::with_capacity(service.aliases.len()); + for alias in &service.aliases { + let expanded = expand_alias(alias, host)?; + let alias_hostname = format!("{expanded}.{}", environment.ui_domain); + validate_domain(&alias_hostname)?; + if all_hostnames + .insert(alias_hostname.clone(), "UI alias") + .is_some() + { + return Err(CaddyError::InvalidConfiguration(format!( + "duplicate UI hostname for alias on service {}", + service.name + ))); + } + aliases.push(alias_hostname); + } sites.push(CaddySite { name: service.name.clone(), hostname, + aliases, upstream: service.upstream.clone(), port: service.port, }); } Ok(Self { - version: 1, + version: 2, host: host.to_string(), ui_domain: environment.ui_domain.clone(), acme_staging: environment.acme_staging, + machine_hostname, services: sites, }) } pub fn parse(contents: &str, host: &str, services: &[UiService]) -> Result { let sites: Self = serde_json::from_str(contents)?; - if sites.version != 1 { + if sites.version != 2 { return Err(CaddyError::InvalidConfiguration(format!( "unsupported payload version {}", sites.version @@ -144,16 +177,41 @@ impl CaddySites { format!("{{\n admin off\n{acme} acme_dns cloudflare {{env.CF_API_TOKEN}}\n}}\n"); for service in &self.services { use std::fmt::Write as _; - let _ = writeln!( - config, - "\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet 403\n reverse_proxy {}:{}\n}}", - service.hostname, service.upstream, service.port - ); + for hostname in std::iter::once(&service.hostname).chain(&service.aliases) { + let _ = writeln!( + config, + "\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet 403\n reverse_proxy {}:{}\n}}", + hostname, service.upstream, service.port + ); + } } config } } +fn expand_alias(alias: &str, host: &str) -> Result { + if alias.is_empty() || alias.len() > 253 || alias.starts_with('.') || alias.ends_with('.') { + return Err(CaddyError::InvalidConfiguration( + "invalid relative UI alias".to_string(), + )); + } + let mut labels = Vec::new(); + for label in alias.split('.') { + if label == "{host}" { + labels.push(host); + } else { + if label.contains('{') || label.contains('}') { + return Err(CaddyError::InvalidConfiguration( + "the {host} alias placeholder must be a complete DNS label".to_string(), + )); + } + validate_label(label, "UI alias")?; + labels.push(label); + } + } + Ok(labels.join(".")) +} + fn validate_label(label: &str, field: &str) -> Result<(), CaddyError> { let valid = !label.is_empty() && label.len() <= 63 diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index 5eea8e6e..f95f0d91 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -11,6 +11,7 @@ fn syncthing_only() -> Vec { name: "syncthing".to_string(), upstream: "syncthing".to_string(), port: 8384, + aliases: Vec::new(), }] } @@ -98,6 +99,7 @@ fn renders_each_declared_service_with_tailnet_filter_and_container_dns() { name: "pihole".to_string(), upstream: "pihole".to_string(), port: 8088, + aliases: Vec::new(), }, syncthing_only().remove(0), ]; @@ -173,3 +175,50 @@ fn rejects_domain_injection_duplicates_and_empty_service_sets() { assert!(super::validate_domain_in_zone("ui.other.invalid", "example.invalid").is_err()); assert!(super::validate_domain_in_zone("ui.example.invalid", "example.invalid").is_ok()); } + +#[test] +fn renders_declared_aliases_and_rejects_conflicting_names() { + let services = vec![UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + aliases: vec!["sync".to_string(), "sync.{host}".to_string()], + }]; + let sites = CaddySites::from_host( + "clamps", + &UiEnvironment { + ui_domain: "test.example.invalid".to_string(), + acme_staging: true, + }, + &services, + ) + .unwrap(); + assert_eq!(sites.machine_hostname, "clamps.test.example.invalid"); + assert_eq!( + sites.services[0].aliases, + vec![ + "sync.test.example.invalid", + "sync.clamps.test.example.invalid", + ] + ); + let rendered = sites.render(); + assert!(rendered.contains("sync.test.example.invalid {")); + assert!(rendered.contains("sync.clamps.test.example.invalid {")); + assert_eq!(rendered.matches("reverse_proxy syncthing:8384").count(), 3); + + for alias in ["", ".sync", "sync.", "*.sync", "bad{host}", "clamps"] { + let invalid = vec![UiService { + aliases: vec![alias.to_string()], + ..services[0].clone() + }]; + assert!(CaddySites::from_host( + "clamps", + &UiEnvironment { + ui_domain: "test.example.invalid".to_string(), + acme_staging: true, + }, + &invalid, + ) + .is_err()); + } +} diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index ee11ca26..bfacd6fd 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -119,11 +119,13 @@ pub fn ui_config_for_host(hostname: &str) -> Option { name: "pihole".to_string(), upstream: "pihole".to_string(), port: 8088, + aliases: Vec::new(), }, skillet_caddy::UiService { name: "syncthing".to_string(), upstream: "syncthing".to_string(), port: 8384, + aliases: vec!["sync.{host}".to_string()], }, ], }), @@ -133,6 +135,7 @@ pub fn ui_config_for_host(hostname: &str) -> Option { name: "syncthing".to_string(), upstream: "syncthing".to_string(), port: 8384, + aliases: vec!["sync.{host}".to_string()], }], }), _ => None, diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 181ec5b5..4454d2c1 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -15,6 +15,7 @@ tracing-subscriber.workspace = true anyhow = "1.0" keepass.workspace = true rpassword.workspace = true +cloudflare.workspace = true keyutils.workspace = true hex.workspace = true sha2.workspace = true diff --git a/ublue/skillet/crates/cli/src/cloudflare.rs b/ublue/skillet/crates/cli/src/cloudflare.rs new file mode 100644 index 00000000..3f999311 --- /dev/null +++ b/ublue/skillet/crates/cli/src/cloudflare.rs @@ -0,0 +1,634 @@ +use anyhow::{anyhow, Context, Result}; +use cloudflare::framework::{ + auth::Credentials, + client::{blocking_api::HttpApiClient, ClientConfig}, + endpoint::{spec::EndpointSpec, Method, RequestBody}, + response::ApiSuccess, + Environment, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, BTreeSet}, + net::IpAddr, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +const API_BASE: &str = "https://api.cloudflare.com/client/v4/"; +const RECORD_TTL: u32 = 120; + +#[cfg(test)] +#[path = "cloudflare_tests.rs"] +mod tests; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct RecordRef { + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub record_type: String, + pub content: String, + pub comment: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct IssuedToken { + pub id: String, + pub value: String, + pub expires_on: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct Zone { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct OwnedDns { + pub marker: String, + pub records: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DesiredRecord { + pub name: String, + pub record_type: String, + pub content: String, +} + +pub(crate) struct Cloudflare { + base: String, +} + +impl Cloudflare { + pub(crate) fn new() -> Self { + Self { + base: API_BASE.to_string(), + } + } + + #[cfg(test)] + fn with_base(base: &str) -> Self { + Self { + base: base.trim_end_matches('/').to_string() + "/", + } + } + + fn request( + &self, + token: &str, + method: Method, + path: String, + query: Option, + body: Option, + ) -> Result> { + let endpoint = ApiEndpoint { + method, + path, + query, + body: body + .map(|value| serde_json::to_string(&value)) + .transpose() + .context("encoding Cloudflare API request")?, + }; + let environment = if self.base == API_BASE { + Environment::Production + } else { + Environment::Custom(self.base.clone()) + }; + let client = HttpApiClient::new( + Credentials::UserAuthToken { + token: token.to_string(), + }, + ClientConfig::default(), + environment, + ) + .context("building cloudflare-rs client")?; + client + .request(&endpoint) + .map_err(|error| anyhow!("Cloudflare API request failed: {error}")) + } + + fn result( + &self, + token: &str, + method: Method, + path: String, + query: Option, + body: Option, + ) -> Result { + Ok(self.request(token, method, path, query, body)?.result.0) + } + + pub(crate) fn zone(&self, token: &str, zone_id: &str) -> Result { + validate_zone_id(zone_id)?; + let value = self.result(token, Method::GET, format!("zones/{zone_id}"), None, None)?; + serde_json::from_value(value).context("decoding Cloudflare zone") + } + + fn permission_groups(&self, token: &str) -> Result<(String, String)> { + let value = self.result( + token, + Method::GET, + "user/tokens/permission_groups".to_string(), + Some("per_page=500".to_string()), + None, + )?; + let groups = value + .as_array() + .ok_or_else(|| anyhow!("Cloudflare permission group response is malformed"))?; + let mut zone_read = None; + let mut dns_write = None; + for group in groups { + if group.get("is_selectable").and_then(Value::as_bool) == Some(false) { + continue; + } + let Some(id) = group.get("id").and_then(Value::as_str) else { + continue; + }; + let name = group + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + let scopes = group + .get("scopes") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + if name == "Zone Read" && scopes.contains("com.cloudflare.api.account.zone") { + zone_read = Some(id.to_string()); + } + if matches!(name, "DNS Write" | "DNS Edit") + && scopes.contains("com.cloudflare.api.account.zone") + { + dns_write = Some(id.to_string()); + } + } + match (zone_read, dns_write) { + (Some(read), Some(write)) => Ok((read, write)), + _ => Err(anyhow!("Cloudflare did not expose selectable Zone Read and DNS Write permission groups; the token creator needs User > API Tokens > Read and Write")), + } + } + + pub(crate) fn create_zone_token( + &self, + creator_token: &str, + zone_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + validate_zone_id(zone_id)?; + let (zone_read, dns_write) = self.permission_groups(creator_token)?; + let mut payload = json!({ + "name": name, + "policies": [{ + "effect": "allow", + "permission_groups": [{"id": zone_read}, {"id": dns_write}], + "resources": {format!("com.cloudflare.api.account.zone.{zone_id}"): "*"} + }] + }); + if let Some(lifetime) = lifetime { + payload["expires_on"] = json!(expiry_rfc3339(lifetime)?); + } + let value = self.result( + creator_token, + Method::POST, + "user/tokens".to_string(), + None, + Some(payload), + )?; + serde_json::from_value(value).context("decoding newly issued Cloudflare token") + } + + pub(crate) fn token_ids_by_name(&self, creator_token: &str, name: &str) -> Result> { + let mut matches = Vec::new(); + for page in 1..=100_u32 { + let response = self.request( + creator_token, + Method::GET, + "user/tokens".to_string(), + Some(format!("page={page}&per_page=100")), + None, + )?; + let values = response + .result + .0 + .as_array() + .ok_or_else(|| anyhow!("Cloudflare token list response is malformed"))?; + matches.extend(values.iter().filter_map(|token| { + (token.get("name").and_then(Value::as_str) == Some(name)) + .then(|| token.get("id").and_then(Value::as_str)) + .flatten() + .filter(|id| validate_token_id(id).is_ok()) + .map(ToOwned::to_owned) + })); + let total_pages = response + .result_info + .as_ref() + .and_then(|info| info.get("total_pages")) + .and_then(Value::as_u64) + .unwrap_or(u64::from(page)); + if u64::from(page) >= total_pages { + break; + } + } + Ok(matches) + } + + pub(crate) fn revoke_token(&self, creator_token: &str, token_id: &str) -> Result<()> { + validate_token_id(token_id)?; + self.result( + creator_token, + Method::DELETE, + format!("user/tokens/{token_id}"), + None, + None, + )?; + Ok(()) + } + + fn list_records(&self, token: &str, zone_id: &str) -> Result> { + validate_zone_id(zone_id)?; + let mut records = Vec::new(); + for page in 1..=100_u32 { + let response = self.request( + token, + Method::GET, + format!("zones/{zone_id}/dns_records"), + Some(format!("page={page}&per_page=100")), + None, + )?; + let batch: Vec = serde_json::from_value(response.result.0)?; + let count = batch.len(); + records.extend(batch); + let total_pages = response + .result_info + .as_ref() + .and_then(|info| info.get("total_pages")) + .and_then(Value::as_u64) + .unwrap_or(u64::from(page)); + if u64::from(page) >= total_pages || count == 0 { + break; + } + } + Ok(records) + } + + fn create_record( + &self, + token: &str, + zone_id: &str, + name: &str, + record_type: &str, + content: &str, + marker: &str, + ) -> Result { + let value = self.result( + token, + Method::POST, + format!("zones/{zone_id}/dns_records"), + None, + Some(json!({ + "type": record_type, + "name": name, + "content": content, + "ttl": RECORD_TTL, + "proxied": false, + "comment": marker + })), + )?; + serde_json::from_value(value).context("decoding created DNS record") + } + + fn update_record( + &self, + token: &str, + zone_id: &str, + existing: &RecordRef, + content: &str, + marker: &str, + ) -> Result { + validate_record_id(&existing.id)?; + let value = self.result( + token, + Method::PATCH, + format!("zones/{zone_id}/dns_records/{}", existing.id), + None, + Some(json!({ + "type": existing.record_type, + "name": existing.name, + "content": content, + "ttl": RECORD_TTL, + "proxied": false, + "comment": marker + })), + )?; + serde_json::from_value(value).context("decoding updated DNS record") + } + + fn delete_record(&self, token: &str, zone_id: &str, id: &str) -> Result<()> { + validate_record_id(id)?; + self.result( + token, + Method::DELETE, + format!("zones/{zone_id}/dns_records/{id}"), + None, + None, + )?; + Ok(()) + } + + pub(crate) fn reconcile_dns( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + desired: &[DesiredRecord], + ) -> Result { + validate_zone_id(zone_id)?; + validate_fqdn(ui_domain)?; + if marker.is_empty() || marker.len() > 255 { + return Err(anyhow!("invalid Skillet DNS ownership marker")); + } + let mut wanted: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + for record in desired { + validate_fqdn(&record.name)?; + validate_record_content(&record.record_type, &record.content)?; + wanted + .entry((record.name.to_ascii_lowercase(), record.record_type.clone())) + .or_default() + .insert(record.content.clone()); + } + let mut records = self.list_records(token, zone_id)?; + let desired_names = desired + .iter() + .map(|record| record.name.to_ascii_lowercase()) + .collect::>(); + for record in records + .iter() + .filter(|record| desired_names.contains(&record.name.to_ascii_lowercase())) + { + if record.comment.as_deref() != Some(marker) { + return Err(anyhow!( + "DNS name {} has a record not owned by this Skillet deployment", + record.name + )); + } + } + + let mut retained = Vec::new(); + for ((name, record_type), contents) in &wanted { + let mut existing = records + .iter_mut() + .filter(|record| { + record.name.eq_ignore_ascii_case(name) + && record.record_type == *record_type + && record.comment.as_deref() == Some(marker) + }) + .collect::>(); + for content in contents { + if let Some(index) = existing + .iter() + .position(|record| record.content == *content) + { + retained.push(existing.remove(index).clone()); + } else if let Some(record) = existing.pop() { + let updated = self.update_record(token, zone_id, record, content, marker)?; + retained.push(updated); + } else { + retained.push(self.create_record( + token, + zone_id, + name, + record_type, + content, + marker, + )?); + } + } + for duplicate in existing { + self.delete_record(token, zone_id, &duplicate.id)?; + } + } + let retained_ids = retained + .iter() + .map(|record| record.id.as_str()) + .collect::>(); + for stale in records + .drain(..) + .filter(|record| record.comment.as_deref() == Some(marker)) + { + if stale.name != ui_domain && !stale.name.ends_with(&format!(".{ui_domain}")) { + return Err(anyhow!("Skillet ownership marker is present outside the configured UI domain; refusing cleanup")); + } + if !retained_ids.contains(stale.id.as_str()) { + self.delete_record(token, zone_id, &stale.id)?; + } + } + retained.sort_by(|left, right| { + (&left.name, &left.record_type, &left.content).cmp(&( + &right.name, + &right.record_type, + &right.content, + )) + }); + Ok(OwnedDns { + marker: marker.to_string(), + records: retained, + }) + } + + pub(crate) fn remove_dns_marker( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + ) -> Result<()> { + validate_zone_id(zone_id)?; + validate_fqdn(ui_domain)?; + for record in self.list_records(token, zone_id)? { + if record.comment.as_deref() != Some(marker) { + continue; + } + if !matches!(record.record_type.as_str(), "A" | "AAAA" | "CNAME") + || !(record.name == ui_domain || record.name.ends_with(&format!(".{ui_domain}"))) + { + return Err(anyhow!("Skillet ownership marker appears on an unexpected DNS record; refusing cleanup")); + } + self.delete_record(token, zone_id, &record.id)?; + } + Ok(()) + } +} + +pub(crate) fn desired_records( + machine_name: &str, + addresses: &BTreeSet, + sites: &skillet_caddy::CaddySites, +) -> Result> { + validate_fqdn(machine_name)?; + let mut result = Vec::new(); + let mut has_v4 = false; + let mut has_v6 = false; + for address in addresses { + let parsed = address + .parse::() + .context("invalid Tailscale IP address")?; + has_v4 |= parsed.is_ipv4(); + has_v6 |= parsed.is_ipv6(); + result.push(DesiredRecord { + name: machine_name.to_string(), + record_type: if parsed.is_ipv4() { "A" } else { "AAAA" }.to_string(), + content: parsed.to_string(), + }); + } + if !has_v4 || !has_v6 { + return Err(anyhow!( + "Tailscale device must have both IPv4 and IPv6 addresses before publishing DNS" + )); + } + for service in &sites.services { + result.push(DesiredRecord { + name: service.hostname.clone(), + record_type: "CNAME".to_string(), + content: machine_name.to_string(), + }); + for alias in &service.aliases { + result.push(DesiredRecord { + name: alias.clone(), + record_type: "CNAME".to_string(), + content: service.hostname.clone(), + }); + } + } + Ok(result) +} + +pub(crate) fn validate_zone_id(id: &str) -> Result<()> { + if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(anyhow!( + "Cloudflare Zone ID must be 32 hexadecimal characters" + )); + } + Ok(()) +} + +pub(crate) fn validate_token_id(id: &str) -> Result<()> { + if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(anyhow!("Cloudflare token ID is malformed")); + } + Ok(()) +} + +fn validate_record_id(id: &str) -> Result<()> { + if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(anyhow!("Cloudflare DNS record ID is malformed")); + } + Ok(()) +} + +fn validate_record_content(record_type: &str, content: &str) -> Result<()> { + match record_type { + "A" | "AAAA" => { + let address = content + .parse::() + .context("invalid IP in DNS plan")?; + if (record_type == "A") != address.is_ipv4() { + return Err(anyhow!("DNS record address family does not match type")); + } + } + "CNAME" => validate_fqdn(content)?, + _ => return Err(anyhow!("unsupported managed DNS record type")), + } + Ok(()) +} + +fn validate_fqdn(name: &str) -> Result<()> { + skillet_caddy::validate_domain(name).map_err(|error| anyhow!("{error}")) +} + +fn expiry_rfc3339(lifetime: Duration) -> Result { + let seconds = SystemTime::now() + .checked_add(lifetime) + .ok_or_else(|| anyhow!("Cloudflare token expiry overflow"))? + .duration_since(UNIX_EPOCH)? + .as_secs(); + let days = seconds / 86_400; + let time = seconds % 86_400; + let (year, month, day) = civil_from_days(i64::try_from(days)?); + if !(1..=9999).contains(&year) { + return Err(anyhow!("Cloudflare token expiry is outside RFC3339 range")); + } + Ok(format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + time / 3600, + (time % 3600) / 60, + time % 60 + )) +} + +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let days = days + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + (year, month, day) +} + +#[derive(Clone, Debug)] +struct JsonResult(Value); + +impl<'de> Deserialize<'de> for JsonResult { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + Value::deserialize(deserializer).map(Self) + } +} + +impl cloudflare::framework::response::ApiResult for JsonResult {} + +#[derive(Debug)] +struct ApiEndpoint { + method: Method, + path: String, + query: Option, + body: Option, +} + +impl EndpointSpec for ApiEndpoint { + type JsonResponse = JsonResult; + type ResponseType = ApiSuccess; + + fn method(&self) -> Method { + self.method.clone() + } + + fn path(&self) -> String { + self.path.clone() + } + + fn query(&self) -> Option { + self.query.clone() + } + + fn body(&self) -> Option> { + self.body.clone().map(RequestBody::Json) + } +} diff --git a/ublue/skillet/crates/cli/src/cloudflare_tests.rs b/ublue/skillet/crates/cli/src/cloudflare_tests.rs new file mode 100644 index 00000000..f81b5cd2 --- /dev/null +++ b/ublue/skillet/crates/cli/src/cloudflare_tests.rs @@ -0,0 +1,181 @@ +use super::*; +use std::{ + io::{Read, Write}, + net::TcpListener, + thread, +}; + +#[test] +fn desired_dns_contains_dual_stack_machine_ui_and_alias_records() { + let services = vec![skillet_caddy::UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + aliases: vec!["sync.{host}".to_string()], + }]; + let sites = skillet_caddy::CaddySites::from_host( + "clamps", + &skillet_caddy::UiEnvironment { + ui_domain: "example.test".to_string(), + acme_staging: true, + }, + &services, + ) + .expect("valid sites"); + let addresses = BTreeSet::from(["100.64.0.2".to_string(), "fd7a:115c:a1e0::2".to_string()]); + let records = desired_records(&sites.machine_hostname, &addresses, &sites) + .expect("valid dual-stack records"); + assert!(records.contains(&DesiredRecord { + name: "clamps.example.test".to_string(), + record_type: "A".to_string(), + content: "100.64.0.2".to_string(), + })); + assert!(records.contains(&DesiredRecord { + name: "clamps.example.test".to_string(), + record_type: "AAAA".to_string(), + content: "fd7a:115c:a1e0::2".to_string(), + })); + assert!(records.contains(&DesiredRecord { + name: "syncthing.clamps.example.test".to_string(), + record_type: "CNAME".to_string(), + content: "clamps.example.test".to_string(), + })); + assert!(records.contains(&DesiredRecord { + name: "sync.clamps.example.test".to_string(), + record_type: "CNAME".to_string(), + content: "syncthing.clamps.example.test".to_string(), + })); +} + +#[test] +fn desired_dns_rejects_missing_address_family() { + let sites = skillet_caddy::CaddySites::from_host( + "clamps", + &skillet_caddy::UiEnvironment { + ui_domain: "example.test".to_string(), + acme_staging: true, + }, + &[skillet_caddy::UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + aliases: Vec::new(), + }], + ) + .expect("valid sites"); + let addresses = BTreeSet::from(["100.64.0.2".to_string()]); + assert!(desired_records(&sites.machine_hostname, &addresses, &sites).is_err()); +} + +#[test] +fn sdk_transport_calls_cloudflare_with_bearer_token() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("client"); + let mut request = [0_u8; 4096]; + let count = stream.read(&mut request).expect("request"); + let request = String::from_utf8_lossy(&request[..count]).to_ascii_lowercase(); + assert!(request.contains("authorization: bearer test-token")); + assert!(request.contains("/client/v4/zones/0123456789abcdef0123456789abcdef")); + let body = r#"{"success":true,"errors":[],"messages":[],"result":{"id":"0123456789abcdef0123456789abcdef","name":"example.test"}}"#; + write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("response"); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let zone = api + .zone("test-token", "0123456789abcdef0123456789abcdef") + .expect("zone request"); + assert_eq!(zone.name, "example.test"); + server.join().expect("server thread"); +} + +#[test] +fn discovers_permission_group_ids_and_creates_scoped_token_through_sdk() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + for (expected_path, body) in [ + ( + "/client/v4/user/tokens/permission_groups", + r#"{"success":true,"errors":[],"messages":[],"result":[{"id":"zone-read-id","name":"Zone Read","scopes":["com.cloudflare.api.account.zone"],"is_selectable":true},{"id":"dns-write-id","name":"DNS Write","scopes":["com.cloudflare.api.account.zone"],"is_selectable":true}]}"#, + ), + ( + "/client/v4/user/tokens", + r#"{"success":true,"errors":[],"messages":[],"result":{"id":"0123456789abcdef0123456789abcdef","value":"child-secret","expires_on":"2026-10-02T00:00:00Z"}}"#, + ), + ] { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer issuer-token")); + assert!(request.contains(expected_path)); + if expected_path.ends_with("user/tokens") + && !expected_path.ends_with("permission_groups") + { + let (_, request_body) = request.split_once("\r\n\r\n").expect("POST body"); + let payload: Value = serde_json::from_str(request_body).expect("JSON payload"); + assert_eq!( + payload["policies"][0]["permission_groups"][0]["id"], + "zone-read-id" + ); + assert_eq!( + payload["policies"][0]["permission_groups"][1]["id"], + "dns-write-id" + ); + assert_eq!( + payload["policies"][0]["resources"] + ["com.cloudflare.api.account.zone.0123456789abcdef0123456789abcdef"], + "*" + ); + assert_eq!(payload["name"], "skillet:test:clamps-smoke"); + assert!(payload.get("expires_on").is_some()); + } + write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("response"); + } + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let token = api + .create_zone_token( + "issuer-token", + "0123456789abcdef0123456789abcdef", + "skillet:test:clamps-smoke", + Some(Duration::from_mins(30)), + ) + .expect("scoped token"); + assert_eq!(token.value, "child-secret"); + assert_eq!(token.id, "0123456789abcdef0123456789abcdef"); + server.join().expect("server thread"); +} + +fn read_request(stream: &mut std::net::TcpStream) -> String { + let mut request = Vec::new(); + let mut chunk = [0_u8; 2048]; + loop { + let count = stream.read(&mut chunk).expect("request bytes"); + request.extend_from_slice(&chunk[..count]); + let text = String::from_utf8_lossy(&request); + let Some((headers, body)) = text.split_once("\r\n\r\n") else { + continue; + }; + let body_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or_default(); + if body.len() >= body_length { + return text.into_owned(); + } + } +} + +#[test] +fn zone_and_token_identifiers_are_validated() { + assert!(validate_zone_id("0123456789abcdef0123456789abcdef").is_ok()); + assert!(validate_zone_id("example.test").is_err()); + assert!(validate_token_id("0123456789abcdef0123456789abcdef").is_ok()); + assert!(validate_token_id("../token").is_err()); +} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index bcd7d40d..79472f63 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -9,6 +9,7 @@ use std::{ use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; +mod cloudflare; mod secret_delivery; mod tailscale; @@ -151,6 +152,9 @@ struct VmDestroyArgs { struct VmProvisionArgs { hostname: String, instance: String, + /// Also provision private UI DNS, Caddy, and disposable Cloudflare credentials + #[arg(long)] + with_ui: bool, #[arg(long)] database: Option, #[arg(long)] diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 587fa3d1..d7d2eddb 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -4,6 +4,7 @@ use super::{ }; use anyhow::{anyhow, Context, Result}; use keepass::{Database, DatabaseKey}; +use serde::{Deserialize, Serialize}; use std::{ fs, io::{Read as _, Write as _}, @@ -61,7 +62,7 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { } let secret = random_password()?; create_entry(&mut vault.database, &path, &secret)?; - save_vault(&vault, args.key_file.as_deref(), &path, &secret)?; + save_vault(&mut vault, args.key_file.as_deref(), &path, &secret)?; secret }; let mut command = ssh_command(args); @@ -100,29 +101,25 @@ fn deliver_caddy_from_vault( ) -> Result<()> { let ui_config = ui_config.ok_or_else(|| anyhow!("missing host UI declaration"))?; let environment = args.environment.as_str(); - let domain_path = format!("skillet/environments/{environment}/ui/domain"); + let domain_path = format!("skillet/environments/{environment}/dns/ui-domain"); let domain = lookup(&vault.database, &domain_path)? .ok_or_else(|| anyhow!("KeePassXC UI domain entry is missing: {domain_path}"))?; - let zone_path = format!("skillet/environments/{environment}/cloudflare/zone"); - let zone = lookup(&vault.database, &zone_path)? + let zone_path = format!("skillet/environments/{environment}/dns/cloudflare-zone-id"); + let zone_id = lookup(&vault.database, &zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; let domain = domain.trim(); - let zone = zone.trim(); - skillet_caddy::validate_domain_in_zone(domain, zone) + let zone_id = zone_id.trim(); + crate::cloudflare::validate_zone_id(zone_id)?; + let creator = + lookup(&vault.database, "skillet/cloudflare/token-creator")?.ok_or_else(|| { + anyhow!( + "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" + ) + })?; + let cloudflare = crate::cloudflare::Cloudflare::new(); + let zone = cloudflare.zone(&creator, zone_id)?; + skillet_caddy::validate_domain_in_zone(domain, &zone.name) .context("validating KeePassXC UI domain against its Cloudflare zone")?; - let token_path = format!( - "skillet/environments/{environment}/hosts/{}/cloudflare/acme-token", - args.hostname - ); - let mut token = lookup(&vault.database, &token_path)?; - if token.is_none() && args.environment == UiEnvironmentName::Production { - token = lookup( - &vault.database, - &format!("skillet/hosts/{}/cloudflare/acme-token", args.hostname), - )?; - } - let token = token - .ok_or_else(|| anyhow!("KeePassXC Cloudflare ACME token entry is missing: {token_path}"))?; let sites = skillet_caddy::CaddySites::from_host( &args.hostname, &skillet_caddy::UiEnvironment { @@ -131,6 +128,17 @@ fn deliver_caddy_from_vault( }, &ui_config.services, )?; + let tailnet = tailscale_credentials(vault)?; + let device = + tailscale::find_device_by_hostname(&tailnet, &args.hostname, tailscale::SERVER_TAG)?; + let dns_marker = format!("skillet:{environment}:{}", args.hostname); + let dns = + crate::cloudflare::desired_records(&sites.machine_hostname, &device.addresses, &sites)?; + ensure_vault_unchanged(vault)?; + let token = host_acme_token(args, vault, &cloudflare, &creator, zone_id)?; + cloudflare.zone(&token, zone_id)?; + ensure_vault_unchanged(vault)?; + cloudflare.reconcile_dns(&token, zone_id, &dns_marker, domain, &dns)?; ensure_vault_unchanged(vault)?; let sites = serde_json::to_string(&sites)?; for (credential, value) in [ @@ -155,6 +163,55 @@ fn deliver_caddy_from_vault( Ok(()) } +fn host_acme_token( + args: &SecretDeliverArgs, + vault: &mut OpenVault, + api: &crate::cloudflare::Cloudflare, + creator: &str, + zone_id: &str, +) -> Result { + let environment = args.environment.as_str(); + let token_path = format!( + "skillet/environments/{environment}/hosts/{}/cloudflare/acme-token", + args.hostname + ); + if let Some(token) = lookup(&vault.database, &token_path)? { + return Ok(token); + } + let legacy = if args.environment == UiEnvironmentName::Production { + lookup( + &vault.database, + &format!("skillet/hosts/{}/cloudflare/acme-token", args.hostname), + )? + } else { + None + }; + if let Some(token) = legacy { + create_entry(&mut vault.database, &token_path, &token)?; + save_vault(vault, args.key_file.as_deref(), &token_path, &token)?; + return Ok(token); + } + let token_name = format!("skillet:{environment}:{}", args.hostname); + // A previous request may have reached Cloudflare before its response was + // lost. With no vault credential to reuse, clean up only child tokens + // carrying this exact Skillet-owned name. + for orphan in api.token_ids_by_name(creator, &token_name)? { + api.revoke_token(creator, &orphan)?; + } + let issued = api.create_zone_token(creator, zone_id, &token_name, None)?; + if let Err(error) = create_entry(&mut vault.database, &token_path, &issued.value) + .and_then(|()| save_vault(vault, args.key_file.as_deref(), &token_path, &issued.value)) + { + if let Err(revoke_error) = api.revoke_token(creator, &issued.id) { + return Err(anyhow!("saving issued Cloudflare credential failed ({error}); revoking token {} also failed ({revoke_error})", issued.id)); + } + return Err( + error.context("saving new Cloudflare token into KeePassXC; issued token was revoked") + ); + } + Ok(issued.value) +} + pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { let path = match path { Some(path) => path.to_path_buf(), @@ -287,7 +344,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { Some(path) => path.clone(), None => default_database_path()?, }; - let vault = open_vault(&vault_path, args.key_file.as_deref())?; + let mut vault = open_vault(&vault_path, args.key_file.as_deref())?; let credentials = tailscale_credentials(&vault)?; let expected_hostname = name.as_str(); @@ -319,6 +376,9 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { &addresses, )?; save_vm_tailscale_record(&run_dir, &record)?; + if args.with_ui { + provision_vm_ui(args, &run_dir, &mut ssh, &mut vault, &record)?; + } remove_pending_tailscale(&run_dir)?; println!("Tailscale connected VM {expected_hostname}"); Ok(()) @@ -333,6 +393,13 @@ pub(super) fn remove_vm_from_tailscale(args: &VmDestroyArgs) -> Result<()> { let run_dir = butane.join("runs").join(&name); let pending = run_dir.join("tailscale-pending"); let record_path = run_dir.join("tailscale.json"); + let cloudflare_path = run_dir.join("cloudflare.json"); + if !pending.exists() && !record_path.exists() && !cloudflare_path.exists() { + return Ok(()); + } + if cloudflare_path.exists() { + cleanup_vm_cloudflare(args, &cloudflare_path)?; + } if !pending.exists() && !record_path.exists() { return Ok(()); } @@ -364,6 +431,214 @@ pub(super) fn remove_vm_from_tailscale(args: &VmDestroyArgs) -> Result<()> { Ok(()) } +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CloudflareVmOwnership { + environment: String, + marker: String, + zone_id: String, + ui_domain: String, + token_name: String, + token_id: Option, + expires_on: Option, + record_ids: Vec, +} + +fn provision_vm_ui( + args: &VmProvisionArgs, + run_dir: &Path, + ssh: &mut VmSsh<'_>, + vault: &mut OpenVault, + device: &tailscale::DeviceRecord, +) -> Result<()> { + let environment = UiEnvironmentName::Test.as_str(); + let domain_path = format!("skillet/environments/{environment}/dns/ui-domain"); + let zone_path = format!("skillet/environments/{environment}/dns/cloudflare-zone-id"); + let ui_domain = lookup(&vault.database, &domain_path)? + .ok_or_else(|| anyhow!("KeePassXC UI domain entry is missing: {domain_path}"))?; + let zone_id = lookup(&vault.database, &zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let creator = + lookup(&vault.database, "skillet/cloudflare/token-creator")?.ok_or_else(|| { + anyhow!( + "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" + ) + })?; + let ui_domain = ui_domain.trim().to_string(); + let zone_id = zone_id.trim().to_string(); + crate::cloudflare::validate_zone_id(&zone_id)?; + let api = crate::cloudflare::Cloudflare::new(); + let zone = api.zone(&creator, &zone_id)?; + skillet_caddy::validate_domain_in_zone(&ui_domain, &zone.name) + .context("validating test UI domain against its Cloudflare zone")?; + let host_ui = skillet_cli_common::hosts::ui_config_for_host(&args.hostname) + .ok_or_else(|| anyhow!("host {} has no declared UI services", args.hostname))?; + let sites = skillet_caddy::CaddySites::from_host( + &args.hostname, + &skillet_caddy::UiEnvironment { + ui_domain: ui_domain.clone(), + acme_staging: true, + }, + &host_ui.services, + )?; + let dns = + crate::cloudflare::desired_records(&sites.machine_hostname, &device.addresses, &sites)?; + let marker = format!("skillet:test:{}:{}", args.hostname, args.instance); + let token_name = format!("skillet:test:{}-{}", args.hostname, args.instance); + let metadata_path = run_dir.join("cloudflare.json"); + let mut ownership = CloudflareVmOwnership { + environment: environment.to_string(), + marker, + zone_id, + ui_domain, + token_name, + token_id: None, + expires_on: None, + record_ids: Vec::new(), + }; + write_cloudflare_ownership(&metadata_path, &ownership)?; + ensure_vault_unchanged(vault)?; + let issued = api.create_zone_token( + &creator, + &ownership.zone_id, + &ownership.token_name, + Some(std::time::Duration::from_hours(12)), + )?; + ownership.token_id = Some(issued.id.clone()); + ownership.expires_on.clone_from(&issued.expires_on); + write_cloudflare_ownership(&metadata_path, &ownership)?; + let work = (|| { + let owned = api.reconcile_dns( + &issued.value, + &ownership.zone_id, + &ownership.marker, + &ownership.ui_domain, + &dns, + )?; + ownership.record_ids = owned.records.into_iter().map(|record| record.id).collect(); + write_cloudflare_ownership(&metadata_path, &ownership)?; + ssh.install_deferred( + "caddy_sites", + "skillet-caddy-apply.service", + &serde_json::to_string(&sites)?, + )?; + ssh.install_deferred( + "cloudflare_acme_token", + "skillet-caddy-apply.service", + &issued.value, + )?; + ssh.run("sudo -n systemctl start skillet-caddy-apply.service")?; + for old_id in api.token_ids_by_name(&creator, &ownership.token_name)? { + if old_id != issued.id { + api.revoke_token(&creator, &old_id)?; + } + } + Ok::<(), anyhow::Error>(()) + })(); + if let Err(error) = work { + return Err(error.context("provisioning disposable Cloudflare DNS and Caddy")); + } + Ok(()) +} + +fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<()> { + let bytes = fs::read(metadata_path).context("reading Cloudflare VM ownership metadata")?; + let ownership: CloudflareVmOwnership = + serde_json::from_slice(&bytes).context("decoding Cloudflare VM ownership metadata")?; + if ownership.environment != UiEnvironmentName::Test.as_str() { + return Err(anyhow!( + "refusing VM cleanup for a non-test Cloudflare environment" + )); + } + let expected_marker = format!("skillet:test:{}:{}", args.hostname, args.instance); + let expected_token_name = format!("skillet:test:{}-{}", args.hostname, args.instance); + if ownership.marker != expected_marker || ownership.token_name != expected_token_name { + return Err(anyhow!( + "Cloudflare metadata does not match this VM identity; refusing cleanup" + )); + } + crate::cloudflare::validate_zone_id(&ownership.zone_id)?; + skillet_caddy::validate_domain_in_zone(&ownership.ui_domain, &ownership.ui_domain) + .context("validating Cloudflare UI domain in VM metadata")?; + let vault_path = args + .database + .clone() + .map_or_else(default_database_path, Ok)?; + let vault = open_vault(&vault_path, args.key_file.as_deref())?; + let creator = + lookup(&vault.database, "skillet/cloudflare/token-creator")?.ok_or_else(|| { + anyhow!( + "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" + ) + })?; + let configured_zone = lookup( + &vault.database, + "skillet/environments/test/dns/cloudflare-zone-id", + )? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: skillet/environments/test/dns/cloudflare-zone-id"))?; + let configured_domain = lookup(&vault.database, "skillet/environments/test/dns/ui-domain")? + .ok_or_else(|| { + anyhow!("KeePassXC UI domain entry is missing: skillet/environments/test/dns/ui-domain") + })?; + if configured_zone.trim() != ownership.zone_id + || configured_domain.trim() != ownership.ui_domain + { + return Err(anyhow!("test Cloudflare configuration differs from the recorded VM owner; restore the original vault values before cleanup")); + } + let api = crate::cloudflare::Cloudflare::new(); + let zone = api.zone(&creator, &ownership.zone_id)?; + skillet_caddy::validate_domain_in_zone(&ownership.ui_domain, &zone.name) + .context("validating recorded test UI domain against its Cloudflare zone")?; + let cleanup_name = format!("{}:cleanup", ownership.token_name); + let cleanup_token = api.create_zone_token( + &creator, + &ownership.zone_id, + &cleanup_name, + Some(std::time::Duration::from_mins(15)), + )?; + let cleanup = (|| { + api.zone(&cleanup_token.value, &ownership.zone_id)?; + api.remove_dns_marker( + &cleanup_token.value, + &ownership.zone_id, + &ownership.marker, + &ownership.ui_domain, + )?; + for id in api.token_ids_by_name(&creator, &ownership.token_name)? { + api.revoke_token(&creator, &id)?; + } + Ok::<(), anyhow::Error>(()) + })(); + let revoke_cleanup = api + .token_ids_by_name(&creator, &cleanup_name) + .and_then(|ids| { + for id in ids { + api.revoke_token(&creator, &id)?; + } + Ok(()) + }); + cleanup?; + revoke_cleanup.context("revoking temporary Cloudflare cleanup token")?; + fs::remove_file(metadata_path).context("removing Cloudflare VM ownership metadata")?; + Ok(()) +} + +fn write_cloudflare_ownership(path: &Path, ownership: &CloudflareVmOwnership) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("Cloudflare metadata has no parent directory"))?; + let mut file = tempfile::NamedTempFile::new_in(parent) + .context("creating Cloudflare ownership metadata")?; + file.as_file_mut() + .write_all(&serde_json::to_vec(ownership)?)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + file.as_file().sync_all()?; + file.persist(path) + .context("recording Cloudflare ownership metadata")?; + fs::File::open(parent)?.sync_all()?; + Ok(()) +} + struct VmSsh<'a> { identity: &'a Path, known_hosts: &'a Path, @@ -414,13 +689,36 @@ impl VmSsh<'_> { } fn install(&mut self, name: &str, secret: &str) -> Result<()> { + self.install_for_unit(name, "skillet-full-apply.service", secret, false) + } + + fn install_deferred(&mut self, name: &str, unit: &str, secret: &str) -> Result<()> { + self.install_for_unit(name, unit, secret, true) + } + + fn install_for_unit( + &mut self, + name: &str, + unit: &str, + secret: &str, + defer_start: bool, + ) -> Result<()> { if secret.is_empty() { return Err(anyhow!("refusing to deliver an empty credential")); } + if !unit + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'@')) + { + return Err(anyhow!("invalid credential consumer unit")); + } + let mut remote = + format!("sudo -n /var/usrlocal/bin/skillet-clamps credential install {name} {unit}"); + if defer_start { + remote.push_str(" --no-start"); + } let mut child = self - .command(&format!( - "sudo -n /var/usrlocal/bin/skillet-clamps credential install {name} skillet-full-apply.service" - )) + .command(&remote) .stdin(Stdio::piped()) .spawn() .context("opening VM credential delivery")?; @@ -727,7 +1025,7 @@ fn create_entry(database: &mut Database, path: &str, password: &str) -> Result<( } fn save_vault( - vault: &OpenVault, + vault: &mut OpenVault, key_file: Option<&Path>, entry_path: &str, secret: &str, @@ -800,6 +1098,7 @@ fn save_vault( .persist(&vault.path) .context("atomically replacing KeePassXC database")?; fs::File::open(parent)?.sync_all()?; + vault.original = fs::read(&vault.path).context("refreshing vault snapshot after save")?; Ok(()) } diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index edb3a1e8..266b78cf 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -139,7 +139,7 @@ fn creates_and_reopens_encrypted_entry_through_symlink_target() { }; let entry_path = "skillet/hosts/clamps/pihole/web-password"; create_entry(&mut vault.database, entry_path, "generated test value").unwrap(); - save_vault(&vault, None, entry_path, "generated test value").unwrap(); + save_vault(&mut vault, None, entry_path, "generated test value").unwrap(); assert!(link_path.is_symlink()); let changed = std::fs::read(&database_path).unwrap(); let reopened = Database::open( diff --git a/ublue/skillet/crates/cli/src/tailscale.rs b/ublue/skillet/crates/cli/src/tailscale.rs index c8fe96f8..1d7b63e2 100644 --- a/ublue/skillet/crates/cli/src/tailscale.rs +++ b/ublue/skillet/crates/cli/src/tailscale.rs @@ -144,6 +144,42 @@ pub(crate) fn find_device( } } +pub(crate) fn find_device_by_hostname( + credentials: &OAuthCredentials, + expected_hostname: &str, + expected_tag: &str, +) -> Result { + if !matches!(expected_tag, SERVER_TAG | SMOKE_TAG) || expected_hostname.is_empty() { + return Err(anyhow!("invalid Tailscale device lookup constraints")); + } + let token = credentials.access_token("devices:core")?; + let response = request( + "GET", + &format!("{API_BASE}/tailnet/-/devices"), + Some(&token), + None, + )?; + let devices = response + .get("devices") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("Tailscale device list response is malformed"))?; + let matches = devices + .iter() + .filter_map(|device| device_record(device, expected_tag)) + .filter(|device| device.hostname == expected_hostname) + .collect::>(); + match matches.as_slice() { + [device] if !device.addresses.is_empty() => Ok(device.clone()), + [] => Err(anyhow!( + "Tailscale has no device named {expected_hostname} with tag {expected_tag}" + )), + [_] => Err(anyhow!("Tailscale device has no registered addresses")), + _ => Err(anyhow!( + "multiple tagged Tailscale devices have hostname {expected_hostname}; refusing ambiguity" + )), + } +} + pub(crate) fn remove_device_for_hostname( credentials: &OAuthCredentials, hostname: &str, From 1f9f9f589cbcba88ec95e1475ee60a6fa98c266a Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 11:05:11 +0200 Subject: [PATCH 108/252] docs(ublue): record Cloudflare UI lifecycle --- ublue/butane/ACCEPTANCE.md | 21 +++++++++ ublue/design/cloudflare-ui-lifecycle.md | 25 +++++++---- ublue/design/private-ui-access.md | 13 +++--- ublue/design/secrets.md | 48 +++++++++----------- ublue/design/smoke-vms.md | 30 +++++++------ ublue/plan/GENERIC-PRIVATE-UIS.md | 58 ++++++++++++------------- ublue/skillet/README.md | 46 ++++++++++++-------- 7 files changed, 138 insertions(+), 103 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 60b7a098..edcdb2f9 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -351,3 +351,24 @@ host tool; it still uses normal command approvals. Caddy startup, ACME issuance, HTTPS proxy access, renewal, and DNS lifecycle remain unverified. `beezelbot` has no Butane template, so its real-runtime service behavior remains unverified. + +## Cloudflare UI lifecycle implementation, 2026-10-02 + +- `cargo fmt --all` passed. +- `CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target + CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 + cargo test --workspace --target x86_64-unknown-linux-gnu` passed (63 tests). +- `CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target + CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 + cargo clippy --workspace --all-targets --target x86_64-unknown-linux-gnu -- + -D warnings` passed. Cloudflare tests use a local mock HTTP server and need + no KeePassXC unlock, Cloudflare account, or live zone. +- The CI integration command `skillet test run beezelbot --phase base --image + fedora:latest` passed using the GNU-target CLI. Its repeat apply issued no + extra service starts. +- `skillet test vm list clamps` found `generic-ui` and `smoke` running. Updating + `generic-ui` for the integration test was attempted, but `test-vm update` + requires the x86_64 musl artifact and this workstation has no musl target + compiler. No packages or toolchains were installed. The VM smoke script, + `--with-ui` provisioning, real Cloudflare mutations, staging ACME, and HTTPS + acceptance remain unverified. diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index 9c210416..1fead2c1 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -1,7 +1,8 @@ # Cloudflare UI credentials and DNS lifecycle -Decision (planned): implement automated token issuance and cleanup before live -Caddy ACME acceptance. Do not require manually created smoke VM ACME tokens. +Decision: the workstation issuer creates scoped Cloudflare child tokens and +reconciles owned DNS before live Caddy ACME acceptance. Do not require manually +created smoke VM ACME tokens. The workstation keeps `skillet/cloudflare/token-creator` in KeePassXC and mints zone-scoped child tokens; only a child token is delivered to Caddy. @@ -10,10 +11,9 @@ zone-scoped child tokens; only a child token is delivered to Caddy. Each environment supplies `skillet/environments//dns/ui-domain` and `skillet/environments//dns/cloudflare-zone-id` in the vault's Password fields. The latter is a Cloudflare Zone ID. Resolve that exact ID -through the API and validate its returned zone name contains the UI domain -before mutations. Current code reads older paths and a zone name; migrating -those lookups is required, and a zone name must never be treated as an ID. -Production and test retain separate configuration. +through the Cloudflare API and validate its returned zone name contains the UI +domain before token issuance or DNS mutations. Production and test retain +separate configuration. Host declarations derive a machine name `.` with one A record for its verified Tailscale IPv4 address and one AAAA for its verified, @@ -92,7 +92,16 @@ workstation issuer, delete the verified records, and revoke the cleanup token. Retained VMs need explicit token renewal for later ACME operations. Production credentials and records are never disposed by test VM commands. -Status: design only. Implement and mock-test issuance, recovery, DNS ownership, -expiry cleanup, and disposal before a fresh staging ACME/HTTPS VM acceptance. +Implementation status: `cloudflare-rs` supplies authenticated blocking HTTP +transport; custom typed JSON endpoints use that transport for token APIs and +DNS record comments that the crate's DNS models do not expose. Persistent +delivery use-or-creates and saves its scoped token in KeePassXC. Opt-in smoke +provisioning records token ID, expiry, DNS record IDs, and ownership metadata; +destroy mints a short-lived cleanup token, deletes marker-owned DNS records, +and revokes disposable tokens. Local tests cover the SDK transport, permission +group discovery, scoped token payloads, address-family validation, and DNS +planning. DNS mutation fault-injection/recovery tests and live +staging ACME/HTTPS acceptance are still pending. + See [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md), [secret delivery](secrets.md), and [VM lifecycle](smoke-vms.md). diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index 2482a7a6..ec5d958c 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -23,7 +23,7 @@ between hosted Tailscale and Headscale. Publish DNS-only A/AAAA records for `.` pointing to its tailnet addresses. Canonical UI names CNAME to that machine name; caller-declared aliases CNAME to their canonical UI names. Caddy serves every name with matching certificate coverage and the same upstream/access policy. -The machine/CNAME/alias model is planned; see the +The machine/CNAME/alias model is implemented; see the [Cloudflare lifecycle](cloudflare-ui-lifecycle.md). Do not enable Cloudflare proxying. Keep literal zones and records outside this public repository; public DNS still exposes the @@ -73,9 +73,9 @@ planes. The names and tailnet IPs are public DNS data. ## Status and references -Implement the [Cloudflare ownership lifecycle](cloudflare-ui-lifecycle.md) -before live ACME acceptance. Issue credentials automatically and verify DNS -cleanup after expiry; do not require a manually created VM token. +The [Cloudflare ownership lifecycle](cloudflare-ui-lifecycle.md) now issues +credentials automatically and cleans DNS after token expiry. Live ACME +acceptance remains pending; no manually created VM token is required. Host UI service declarations now drive a shared versioned Caddy payload. Provisioning derives `..` from the selected @@ -85,8 +85,9 @@ restrictions. Shared Butane units call the generic `skillet` CLI and read the stable host profile from `/etc/skillet/host`; VM hostnames remain independent. Host-specific apply units and credential gates live in host-specific Butane includes. Manual delivery accepts production or test environments. -Cloudflare token/DNS lifecycle and automatic VM provisioning remain planned; -see the [implementation plan](../plan/GENERIC-PRIVATE-UIS.md). +Cloudflare token/DNS lifecycle and opt-in VM provisioning are implemented; +real ACME and HTTPS acceptance remain pending. See the +[implementation plan](../plan/GENERIC-PRIVATE-UIS.md). Skillet configures host-network Tailscale and manages tagged enrollment and removal for clamps smoke VMs; live smoke enrollment and cleanup were accepted diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 4f61d546..3b42e772 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -3,8 +3,8 @@ UI provisioning reads a base domain and Cloudflare zone from the selected environment in KeePassXC, derives service names from the host's declarations, and uses shared credential delivery. Cloudflare token issuance, DNS lifecycle, -and automatic smoke VM wiring remain planned in the -[generic UI plan](../plan/GENERIC-PRIVATE-UIS.md). +and smoke VM cleanup are implemented; live ACME acceptance remains pending. +See the [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md). Decision: use KeePassXC for durable secrets and read and write its KDBX database directly from workstation Skillet through a Rust library. Pi-hole delivery uses @@ -46,9 +46,9 @@ The prefix is consistently singular, `skillet`: | --- | --- | | `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | | `skillet/environments//dns/ui-domain` | Base for derived Caddy hostnames | -| `skillet/environments//dns/cloudflare-zone-id` | Planned Cloudflare Zone ID lookup | -| `skillet/environments//hosts//cloudflare/acme-token` | `cloudflare_acme_token` | -| `skillet/hosts/clamps/cloudflare/acme-token` | Legacy production token fallback | +| `skillet/environments//dns/cloudflare-zone-id` | Cloudflare Zone ID lookup | +| `skillet/environments//hosts//cloudflare/acme-token` | Persistent `cloudflare_acme_token`, created on first Caddy delivery | +| `skillet/hosts//cloudflare/acme-token` | Legacy production credential migrated on first delivery | | `skillet/cloudflare/token-creator` | Workstation only | | `skillet/tailscale/provisioner-client-id` | Workstation only; OAuth Client ID | | `skillet/tailscale/provisioner-client-secret` | Workstation only; OAuth Client secret | @@ -92,18 +92,16 @@ domain, staging policy, and the declared service/upstream list. The receiving host checks it against its local declaration. The generated Caddyfile is persisted under `/etc/skillet` and contains no token. A dedicated systemd apply unit runs only after both Caddy credentials are installed. Old payloads are -rejected; redeliver after updating the environment entries. Production token -lookup temporarily falls back to the existing per-host token path. +rejected; redeliver after updating the environment entries. The first +production delivery migrates a legacy per-host token into the environment path. Podman's default secret storage persists a copy on the guest disk; systemd's credential encryption does not encrypt that copy. ## Disposable credentials -The [Cloudflare lifecycle decision](cloudflare-ui-lifecycle.md) must be -implemented before live Caddy acceptance. New DNS vault paths and Zone ID -lookup in the table above are planned; current manual delivery reads the -previous `ui/domain` and `cloudflare/zone` paths and expects a zone name. -Do not create a manual VM ACME token to bypass lifecycle implementation. +The [Cloudflare lifecycle decision](cloudflare-ui-lifecycle.md) is implemented +for credential issuance, DNS reconciliation, and disposable cleanup. No manual +smoke VM ACME token is required. Smoke VMs get generated test passwords, never production host entries. Tailscale enrollment reads `skillet/tailscale/provisioner-client-id` and @@ -117,21 +115,17 @@ recorded name, smoke tag, and identity. If enrollment or deletion is interrupted, pending metadata remains so cleanup can be retried. Production uses the server tag and the same one-use-key path. -Live UI/ACME testing will read `skillet/cloudflare/token-creator` locally and mint -one short-lived Cloudflare token per VM, with Zone Read and DNS Edit restricted -to the existing smoke-test zone. Once a VM joins the tailnet, the workstation -will create machine A/AAAA records pointing at its tailnet addresses, UI -CNAMEs to that machine, and caller-declared alias CNAMEs to canonical UIs. -The Caddy payload will include all served names for TLS coverage and proxying. -The VM token also supports DNS-01. DNS Edit covers the whole zone; -unique test names prevent collisions, not access to other records. Production -UI records belong in the existing production zone and use a separate scoped -credential. Keep literal zone names and records outside this public repository. -The token creator remains on the workstation. Cloudflare token issuance and -record management are planned; current smoke provisioning only enrolls -Tailscale and provisions Pi-hole. Caddy can be delivered manually to a host -with `secret deliver caddy --environment `; smoke VM -provisioning does not invoke this path automatically yet. +Opt-in `test vm provision --with-ui` reads +`skillet/cloudflare/token-creator` locally and mints a 12-hour Cloudflare token +per disposable instance, restricted to the selected zone with Zone Read and DNS +Write. After Tailscale enrollment, it reconciles machine A/AAAA records for the +verified tailnet addresses, canonical UI CNAMEs, and declared alias CNAMEs. +The token supports Caddy DNS-01. DNS Write scopes access to the whole zone; +unique test names prevent record collisions, not access to other records. On +destroy, Skillet issues a short-lived cleanup token, removes only records with +the instance ownership marker, and revokes disposable tokens. Keep literal +zone names and records outside this public repository. The token creator +remains on the workstation. Live ACME and HTTPS acceptance is still pending. VM Pi-hole secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, expiry, and owned record IDs in run metadata, without token values. Existing diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 0a1ab3cb..5f3b6714 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -3,10 +3,9 @@ Decision: keep VM creation, readiness, repeatable smoke checks, and destruction separate. A retained VM supports inspection and repeated convergence checks; a fresh VM establishes that bootstrap is reproducible. The existing fixture -tests real systemd/Podman convergence. Pi-hole credential delivery and -Tailscale enrollment/device cleanup are implemented. Generic manual Caddy -delivery is available for declared host UIs; Cloudflare DNS lifecycle, automatic -smoke provisioning, and live ACME scenarios remain planned. +tests real systemd/Podman convergence. Pi-hole credential delivery, +Tailscale enrollment/device cleanup, and opt-in Cloudflare/Caddy provisioning +are implemented. Live ACME scenarios remain unverified. ## Identity and ownership @@ -45,22 +44,27 @@ the test artifacts and uses QEMU user networking. It waits for the VM to join and records its device identity before delivering a generated Pi-hole credential and running full apply. Repeated provisioning reuses the Tailscale identity and installed Pi-hole credential; `--rotate` - replaces the latter. + replaces the latter. `--with-ui` also creates a 12-hour zone-scoped + Cloudflare token, reconciles tailnet A/AAAA and UI/alias CNAME records, + installs both Caddy credentials, and activates the Caddy apply unit. The + ignored mode-0600 Cloudflare metadata records its token ID, expiry, marker, + and DNS record IDs, never the token value. 4. **Smoke:** run assertions against the retained VM. Check repeat apply, configuration/secret changes, interruption recovery, and reboot persistence. The container sandbox uses mocked systemctl/Podman; only VM checks establish real runtime behavior. Failures retain the VM and diagnostics for repair. -5. **Destroy:** validate ownership, remove only the recorded smoke-tagged - Tailscale device, then remove the domain, disk, SSH key, and artifacts. - Failed Tailscale cleanup prevents local destruction and retains metadata for - retry, even if the domain is already gone. Cloudflare records and tokens are - not yet part of the lifecycle. +5. **Destroy:** validate ownership, mint a short-lived scoped cleanup token, + remove only DNS records carrying this instance's ownership marker, revoke + its disposable tokens, remove the recorded smoke-tagged Tailscale device, + then remove the domain, disk, SSH key, and artifacts. Failed external + cleanup prevents local destruction and retains metadata for retry, even if + the domain is already gone. ## Live ACME boundary -Implement the [Cloudflare ownership lifecycle](cloudflare-ui-lifecycle.md) -before live ACME acceptance. Issue credentials automatically and verify DNS -cleanup after expiry; do not require a manually created VM token. +The [Cloudflare ownership lifecycle](cloudflare-ui-lifecycle.md) is implemented +for token issuance and DNS cleanup after expiry. Live ACME acceptance remains +pending; do not require a manually created VM token. Routine smoke checks need no Cloudflare access. The optional live scenario enrolls the VM in Tailscale, obtains its tailnet IP, and creates a DNS-only diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 3b3778fe..97a59e1a 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -1,9 +1,10 @@ # Generic private UI provisioning -Status: steps 1 and 2 implemented locally, 2026-10-01. Step 3 is the next -prerequisite for live Caddy testing. The new DNS vault paths and Zone ID -lookup below are not implemented yet. Machine records, UI CNAMEs, and caller -alias declarations are new pending extensions to steps 1 and 2. +Status: shared UI declarations, aliases, environment lookup, Caddy payloads, +Cloudflare token issuance, DNS reconciliation, and opt-in smoke VM cleanup are +implemented locally, 2026-10-02. Local tests pass for the SDK transport and DNS +planning. Full API mutation fault-injection and live staging ACME/HTTPS +acceptance remain pending. ## Goal and fixed decisions @@ -83,7 +84,7 @@ Exit: a non-clamps host with only Syncthing derives exactly one route; the same host definition works with either environment. Use reserved example domains in tests. Missing config fails with the entry path, without values. -### 1a. Caller-declared UI aliases (pending; before Cloudflare mutations) +### 1a. Caller-declared UI aliases (implemented; environment isolation tests pending) Extend each shared UI service declaration with a list of relative alias names beneath the environment's UI domain; an empty list preserves current behavior. @@ -137,32 +138,30 @@ secrets, tailnet access restrictions, persistent certificates, and secret-free Caddyfiles. Use normal blocking systemctl starts, without `--wait`. CLI target: `skillet secret deliver caddy --environment production` -with the existing SSH and vault options. The shared delivery function accepts -host definition, environment config, credentials, and transport. VM provisioning -does not call it yet; wiring it with its recorded transport and test environment -is step 4. +with the existing SSH and vault options. Production delivery uses the shared +host definition, environment config, credentials, and SSH transport. VM +provisioning reuses the Caddy payload and Cloudflare operations with its +recorded SSH transport and test environment. Exit: rerunning unchanged delivery preserves container identity. A host with Syncthing only has no Pi-hole route. Existing old `caddy_sites` payloads and per-service hostname entries require explicit redelivery; old payloads are -rejected rather than silently reinterpreted. Production token lookup temporarily -falls back to the existing per-host token path. +rejected rather than silently reinterpreted. First production delivery migrates +an existing legacy token into the environment-scoped vault entry. -### 3. Shared Cloudflare token and DNS reconciliation (next) +### 3. Shared Cloudflare token and DNS reconciliation (implemented; expand tests) Follow [Cloudflare UI lifecycle](../design/cloudflare-ui-lifecycle.md). Do not use manually created VM ACME tokens as an interim acceptance path. -First migrate environment lookups to the DNS paths above. Previous `ui/domain` -and `cloudflare/zone` lookups are superseded. The old zone value was a DNS name; -never reinterpret it as an ID. Fetch the exact zone by its configured ID and -validate the returned name contains the UI domain before issuance or record -changes. Missing entries report paths without values. Update lookup tests and -setup instructions with the migration. +Environment lookups use the DNS paths above. The configured Zone ID is fetched +exactly and the returned zone name must contain the UI domain before issuance +or record changes. The former zone-name entry is never treated as an ID. -Add a Rust HTTP client module with typed errors and injectable API boundary. -Reuse project HTTP dependencies; do not use a Cloudflare CLI or shell program. -Discover required permission IDs through the API; do not guess or bake IDs in. +Use `cloudflare-rs` for authenticated blocking HTTP transport and custom typed +JSON endpoint specs for token APIs and DNS comments not represented by its DNS +models. Do not use a Cloudflare CLI or shell program. Discover permission IDs +through the API; do not guess or bake IDs in. Persistent token creation must use the existing atomic KDBX writer and conflict protection. If saving fails after issuance, revoke the newly issued token or retain recoverable ownership before returning an error. Never replace an @@ -204,19 +203,18 @@ Exit: new DNS paths and Zone ID validation are covered; issuance requires no manually populated VM ACME entry; cleanup works after token expiry; both environments use the same reconciler; a second apply creates no duplicate token or records; failures at each external mutation can resume or -clean up. Concurrent deployments claiming the same host/environment names +clean up. Mutation fault-injection coverage remains outstanding. Concurrent deployments claiming the same host/environment names are refused. Do not append VM instance names to URLs silently; supporting parallel instances requires separately selected base domains/environments. -### 4. Wire VM lifecycle and verify live ACME +### 4. Wire VM lifecycle and verify live ACME (wiring implemented; live check pending) -Extend `test vm provision ` with explicit live-UI opt-in, -for example `--with-ui`. Standard fixture smoke remains offline from Cloudflare. -The caller supplies test environment and disposable ownership; the shared UI -pipeline handles token, records, config delivery, and Caddy activation after -Tailscale is connected. Destroy cleans Cloudflare resources and Tailscale -identity before deleting local ownership metadata. Missing vault entries fail -before external mutations, naming only the required paths. +`test vm provision --with-ui` explicitly opts into Cloudflare +and Caddy. Standard fixture smoke remains offline from Cloudflare. Provisioning +uses the test environment, journals disposable ownership, then creates the +token, records, delivers the config, and activates Caddy after Tailscale joins. +Destroy reconciles Cloudflare resources and Tailscale identity before local +ownership metadata is deleted. Missing vault entries name only required paths. Reuse the retained VM for development, then run one fresh destroy/create/ provision cycle for final acceptance. User unlocks the KDBX in their own terminal; diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 43e2985f..68de49a4 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -179,10 +179,13 @@ vault edit and redelivery for rotation. The production TPM binding decision remains in the [secret design](../design/secrets.md). For Cloudflare token issuance, select **Create additional tokens** in the -Cloudflare dashboard, with **User > API Tokens > Edit** and no extra -permissions. Save it as `skillet/cloudflare/token-creator`. If already stored -in the desktop keyring, move that value into KeePassXC and verify the saved -entry before removing the old copy. The current smoke command does not use it. +Cloudflare dashboard, granting **User > API Tokens > Read** and **Write** only. +Do not add zone permissions to this workstation issuer. Save it as +`skillet/cloudflare/token-creator`. Skillet discovers the Cloudflare +permission-group IDs at runtime, then creates zone-scoped child tokens with +Zone Read and DNS Write. If the issuer is already stored in the desktop +keyring, move the value into KeePassXC and verify the saved entry before +removing the old copy. ### Caddy UI credentials @@ -201,11 +204,15 @@ selected zone's Cloudflare Overview page. Move the base-domain value from the old entry. The previous `cloudflare/zone` entry contained a zone name; populate `dns/cloudflare-zone-id` with the actual ID. -These are the agreed target paths; current delivery still reads older paths. -Token issuance, DNS reconciliation, path migration, and cleanup must be -implemented before the next live ACME acceptance run. Do not create a manual -smoke VM ACME token. See the [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md) -and [Cloudflare lifecycle design](../design/cloudflare-ui-lifecycle.md). +The CLI reads these environment paths directly. Persistent Caddy delivery +creates and saves a missing host ACME token at +`skillet/environments//hosts//cloudflare/acme-token`, then +reconciles the host's Tailscale A/AAAA records and UI CNAMEs. Smoke VMs only do +this when explicitly provisioned with `--with-ui`; they receive a 12-hour +zone-scoped token and `test vm destroy` removes its owned records and token. +Do not create a manual smoke VM ACME token. See the +[generic UI plan](../plan/GENERIC-PRIVATE-UIS.md) and +[Cloudflare lifecycle design](../design/cloudflare-ui-lifecycle.md). ### Tailscale setup for Skillet @@ -242,16 +249,16 @@ requires `curl` on the workstation. See the mint new auth keys, so treat it as the workstation-side master credential. Never put it in a VM, Quadlet, environment file, or repository. Close KeePassXC before Skillet edits the database. -4. In each existing Cloudflare zone, create DNS-only (grey cloud) A/AAAA UI - records pointing to the enrolled host's Tailscale address. Use the - production zone for production hosts and the smoke-test zone for disposable - VMs. No delegated subzone or Tailscale split DNS configuration is required. - Test lookup using a tailnet client's normal resolver. Caddy obtains its own - certificate through Cloudflare DNS-01. +4. Keep the test and production UI domain and Cloudflare Zone ID in their + respective KeePassXC environment entries. Skillet validates each domain + against the exact zone ID before it creates credentials or DNS records. No + delegated subzone or Tailscale split DNS configuration is required. 5. For a disposable VM, run `cargo run --release -p skillet -- test vm provision clamps smoke`. Skillet prompts to unlock KeePassXC when its three-hour kernel cache is empty, enrolls the VM, then provisions Pi-hole. Repeat the command to reuse - the Tailscale identity and Pi-hole credential. Dispose of the VM with + the Tailscale identity and Pi-hole credential. Add `--with-ui` to also mint + the disposable Cloudflare credential, publish its tailnet DNS records, and + activate Caddy with the test ACME staging issuer. Dispose of the VM with `cargo run --release -p skillet -- test vm destroy clamps smoke`. If Tailscale cleanup fails, destruction stops and the VM metadata remains for retry. Keep the OAuth client available for cleanup. @@ -267,9 +274,10 @@ requires `curl` on the workstation. See the services. Preserve the encrypted KeePassXC entries so another workstation can reprovision credentials. -Cloudflare DNS record automation and smoke-specific Caddy staging remain future -work. Keep the Cloudflare token creator on the workstation; Tailscale -enrollment needs no Cloudflare or Tailscale DNS permissions. See the +Cloudflare credentials and DNS records are removed before the smoke Tailscale +device and VM are destroyed. Keep the Cloudflare token creator on the +workstation; Tailscale enrollment needs no Cloudflare or Tailscale DNS +permissions. Live ACME and HTTPS acceptance remains pending. See the [VM lifecycle](../design/smoke-vms.md). ## Development checks From 25244ffdab089df7f7cad06d210637465d8c7e31 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 11:08:07 +0200 Subject: [PATCH 109/252] docs(cloudflare): clarify token issuer permissions --- ublue/design/cloudflare-ui-lifecycle.md | 4 ++-- ublue/plan/GENERIC-PRIVATE-UIS.md | 4 ++-- ublue/skillet/README.md | 3 ++- ublue/skillet/crates/cli/src/cloudflare.rs | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index 1fead2c1..35fdbbe1 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -43,8 +43,8 @@ and validate it against the receiving host's declaration. Verify DNS, TLS hostname identity, and upstream responses for canonical names and aliases. Publish no LAN or public host addresses. -Child tokens have Zone Read and DNS Edit restricted to the selected zone. -DNS Edit covers A, AAAA, CNAME, and ACME TXT records, so address publication +Child tokens have Zone Read and DNS Write restricted to the selected zone. +DNS Write covers A, AAAA, CNAME, and ACME TXT records, so address publication needs no additional permission. The scope covers the whole zone, not an individual host's records. Separate tokens allow independent revocation, while the issuer stays on the workstation. Public DNS exposes the published names and tailnet diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 97a59e1a..8303db97 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -41,7 +41,7 @@ their values, never invent or overwrite them. Read-only config migration must not expose their values. Keep unrelated host secret paths unchanged. The shared environment policy selects ACME staging for test and the normal -issuer for production. Token scope is Zone Read plus DNS Edit for the selected +issuer for production. Token scope is Zone Read plus DNS Write for the selected zone. This permits editing the whole zone, not just the derived names. The master token stays on the workstation. Persistent hosts use-or-create their token in KDBX; disposable deployments use a temporary token and recorded @@ -172,7 +172,7 @@ with A and AAAA records for its verified Tailscale IPv4 and usable IPv6. Report a missing family explicitly rather than creating an invalid record. Create each `..` as a CNAME to the machine name; create each caller-declared alias as a CNAME to its canonical service name. -Use short TTLs and `proxied=false` for every managed record. Zone DNS Edit +Use short TTLs and `proxied=false` for every managed record. Zone DNS Write covers A, AAAA, CNAME, and ACME TXT records with no extra permissions. Reuse matching owned records; refuse unrelated conflicts, including existing A/AAAA data at a desired CNAME name. Do not create a zone or configure diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 68de49a4..e9dd7c75 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -179,7 +179,8 @@ vault edit and redelivery for rotation. The production TPM binding decision remains in the [secret design](../design/secrets.md). For Cloudflare token issuance, select **Create additional tokens** in the -Cloudflare dashboard, granting **User > API Tokens > Read** and **Write** only. +Cloudflare dashboard, granting **User > API Tokens > Read** and **API Tokens > +Edit** (the write permission) only. Do not add zone permissions to this workstation issuer. Save it as `skillet/cloudflare/token-creator`. Skillet discovers the Cloudflare permission-group IDs at runtime, then creates zone-scoped child tokens with diff --git a/ublue/skillet/crates/cli/src/cloudflare.rs b/ublue/skillet/crates/cli/src/cloudflare.rs index 3f999311..4ae5f067 100644 --- a/ublue/skillet/crates/cli/src/cloudflare.rs +++ b/ublue/skillet/crates/cli/src/cloudflare.rs @@ -172,7 +172,7 @@ impl Cloudflare { } match (zone_read, dns_write) { (Some(read), Some(write)) => Ok((read, write)), - _ => Err(anyhow!("Cloudflare did not expose selectable Zone Read and DNS Write permission groups; the token creator needs User > API Tokens > Read and Write")), + _ => Err(anyhow!("Cloudflare did not expose selectable Zone Read and DNS Write permission groups; the token creator needs User > API Tokens > Read and API Tokens > Edit (Write)")), } } From 0d5dc72822ddb7f5f14aeb0a0bb8da4b07c3c546 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 12:25:56 +0200 Subject: [PATCH 110/252] docs(skillet): record local musl compiler setup --- ublue/butane/ACCEPTANCE.md | 6 ++++-- ublue/skillet/AGENTS.md | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index edcdb2f9..c93c5797 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -368,7 +368,9 @@ host tool; it still uses normal command approvals. extra service starts. - `skillet test vm list clamps` found `generic-ui` and `smoke` running. Updating `generic-ui` for the integration test was attempted, but `test-vm update` - requires the x86_64 musl artifact and this workstation has no musl target - compiler. No packages or toolchains were installed. The VM smoke script, + requires both x86_64 musl artifacts; the generic CLI artifact was missing. + The musl Rust target is installed, and the existing cross compiler requires + `/opt/x86_64-linux-musl-cross/bin` in PATH. The earlier diagnosis of a missing + compiler was incorrect. No packages or toolchains were installed. The VM smoke script, `--with-ui` provisioning, real Cloudflare mutations, staging ACME, and HTTPS acceptance remain unverified. diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index c5d071de..4885c26f 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -30,6 +30,15 @@ This document defines the architectural mandates and project structure for `skil - **Unit Tests**: `cargo test` across the workspace. - **Runtime Smoke**: Run `integration_tests/smoke-ssh.sh` against an explicitly named disposable VM with real systemd and Podman for affected container resources. Record the guest state snapshots and failure diagnostics. +## Local musl toolchain + +- On this workstation, the musl cross compiler is installed under + `/opt/x86_64-linux-musl-cross/bin`. Before compiling musl binaries, run: + `export PATH=/opt/x86_64-linux-musl-cross/bin:$PATH`. +- Ensure this PATH is inherited by Cargo and its child build commands. Check + the installed Rust target and compiler before reporting musl as unavailable; + a missing binary artifact alone does not indicate a missing toolchain. + ## Testing Philosophy Skillet uses a multi-layered testing approach to ensure reliability and idempotency: From 4b9ef6afc0e881764d28dc2cb3e3df52258873a4 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 12:28:33 +0200 Subject: [PATCH 111/252] docs(acceptance): record musl build and vault unlock blocker --- ublue/butane/ACCEPTANCE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index c93c5797..004d3084 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -374,3 +374,18 @@ host tool; it still uses normal command approvals. compiler was incorrect. No packages or toolchains were installed. The VM smoke script, `--with-ui` provisioning, real Cloudflare mutations, staging ACME, and HTTPS acceptance remain unverified. + +### Musl build and live UI attempt, 2026-10-02 + +- With `/opt/x86_64-linux-musl-cross/bin` prepended to PATH and + `CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target`, + `cargo build --release --target x86_64-unknown-linux-musl -p skillet + -p skillet-clamps` passed using the existing toolchain. +- `skillet test vm update clamps generic-ui` installed both static binaries + in the existing named disposable VM. +- `skillet test vm provision clamps generic-ui --with-ui` stopped at vault + unlock: the kernel cache was unavailable and the tool process had no terminal + (`reading database password from terminal: No such device or address`). + No Cloudflare API or live certificate acceptance is claimed. User terminal + unlock is needed before continuing retained-VM checks and the fresh + destroy/create/provision/verify/dispose cycle. From 67c81e87d518330544a22f30385bc32482e953db Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 12:59:01 +0200 Subject: [PATCH 112/252] docs(storage): plan TPM encrypted root and acceptance tests --- ublue/design/secrets.md | 8 ++ ublue/design/smoke-vms.md | 6 ++ ublue/design/storage.md | 16 +++ ublue/plan/CLAMPS-MIGRATION.md | 4 + ublue/plan/TPM-ENCRYPTED-ROOT.md | 163 +++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 ublue/plan/TPM-ENCRYPTED-ROOT.md diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 3b42e772..2bb8a40d 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -13,6 +13,14 @@ stay on the workstation and mint short-lived, one-use enrollment keys. ## Portable storage +Planned disk recovery: production LUKS recovery keys use +`skillet/hosts//storage/root-recovery-key` and remain available off the +protected host. Root unlock uses the TPM, independently of vault access or +Skillet application-credential delivery. Disposable encryption tests generate +their own recovery material. Header backups require protected off-machine +storage; implementation and recovery checks are in the +[TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). + KeePassXC is already the user's password manager, and Syncthing already moves the encrypted database between machines. Reusing that database avoids a custom vault format, export mechanism, or second authoritative store. Skillet defaults diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 5f3b6714..75cc9ba8 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -25,6 +25,12 @@ the test artifacts and uses QEMU user networking. ## States and recovery +Planned encrypted-root tests add a software TPM2 and UEFI/Secure Boot profile. +Each VM owns independent TPM state and firmware NVRAM alongside its disk; +reboots preserve them and disposal removes only owned state. Software TPM +checks establish provisioning behavior; physical hardware acceptance remains +required. See the [TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). + 1. **Create:** build the generic CLI and host binary, allocate the run directory and SSH key, and provision a fresh VM disk. Ignition contains the public SSH key and base configuration. Large binaries travel over SSH because embedding them diff --git a/ublue/design/storage.md b/ublue/design/storage.md index 3fe9c1ce..5ba45c68 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -44,6 +44,22 @@ this rule by design. The `containers` subvolume can then be omitted from service-data snapshots. A shared filesystem shares free space and physical failure; snapshots are not independent backups. +## Planned encryption + +Decision, 2026-10-02: add LUKS2 beneath the shared Btrfs backing filesystem +and prefer local TPM2 unlock on both local and remote physical hosts. Normal +boots must not need a network or operator. This protects persistent service +state, including Podman's disk copies of secrets, while retaining the existing +subvolume layout. Tang is deferred; assess each remote host's TPM first. +EFI and `/boot` remain outside encryption. An explicit boot-integrity policy +and update/rollback compatibility must be proved before claiming protection +against theft of the whole machine. Keep an independent recovery key in the +private vault and a protected off-machine LUKS header backup. +Encryption is not implemented. Installation owns formatting; the current raw +root partition mount assumptions must be changed to the unlocked filesystem. +QEMU functional tests and physical acceptance are specified in the +[TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). + The clamps VM passed fresh provisioning, mount, SELinux, reboot, repeat apply, and unavailable/wrong-mount recovery. A post-provision OS rebase and the physical disk choice remain open in diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 18b4dd2b..1c30c22d 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -33,6 +33,10 @@ design](../design/syncthing.md), [Pi-hole network design](../design/pihole-netwo [storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The final physical disk remains open. +Planned before physical cutover: [TPM-encrypted Btrfs root](TPM-ENCRYPTED-ROOT.md), +including QEMU TPM2 tests, recovery and stock-SSD hardware acceptance. Tang is +deferred; remote hosts will first be assessed for usable TPM support. + ## Boundaries - Image: packages, static OS files, baseline unit enablement/masking. Preserve common-before-host image builds. diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md new file mode 100644 index 00000000..c85fd91b --- /dev/null +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -0,0 +1,163 @@ +# TPM-encrypted uCore root + +Status: planned, 2026-10-02. No encrypted-root implementation or acceptance +is claimed. Start after the current private-UI acceptance work. + +## Scope and decisions + +Use LUKS2 beneath the shared Btrfs root backing `/sysroot`, `/var`, and the +`data` subvolume mounted at `/var/lib/data`. Keep uCore's composefs/deployment +layout, application paths, Podman graphroot, and btrbk leaf snapshots. +EFI and `/boot` remain outside encryption; boot integrity needs a separate +verified policy. Normal boots must unlock locally without an operator or +network. Every host gets an independent recovery key and LUKS header backup. + +Prefer TPM2 on local and remote physical servers. Assess each remote server's +actual TPM and firmware before choosing its install profile. Tang is deferred; +do not implement a Tang service, initramfs VPN, or public unlock endpoint. +An explicitly requested encrypted profile must fail if its prerequisites are +missing, never silently install unencrypted storage or an unrestricted unlock +slot. Existing unencrypted development VMs may retain their explicit profile. + +Read `AGENTS.md`, `skillet/AGENTS.md`, `design/storage.md`, `design/secrets.md`, +`design/smoke-vms.md`, `butane/clamps.bu`, `butane/includes/data-storage.bu`, +`butane/bin/{coreos-install,test-vm,test-vm-ready}`, and the ucore-images recipes. +Make small validated changes in the order below. Never format a populated +physical disk as part of Skillet convergence; use only named disposable disks +until physical install and restore are explicitly arranged. + +## 1. Establish hardware, image, and boot policy + +- Inventory exposed TPM2, supported PCR banks, UEFI/Secure Boot, installed + image versions, and initramfs Clevis/cryptsetup/TPM/Btrfs support. On older + systemd use `systemd-creds has-tpm2`; newer releases can use + `systemd-analyze has-tpm2`. An empty device list is not proof that the + motherboard lacks a disabled TPM. Do not install workstation packages. +- Determine the actual boot chain before selecting PCRs. Plain Clevis + `tpm2: true` supplies no explicit PCR policy. PCR 7 represents Secure Boot + state; it alone does not authenticate our kernel, initramfs and command line. + Include shim/MOK handling in the assessment. Avoid choosing exact kernel or + command-line measurements that strand unattended hosts on every update. +- Investigate signed PCR policies and their compatibility with the existing + image build, FCOS-to-uCore bootstrap, updates and rollback. If using systemd + enrollment rather than native Clevis, prove its initramfs unlock support. + Record the selected mechanism, measurements, protection limits and update + procedure in the storage design. Do not claim whole-machine theft protection + until the boot components and alternative boot paths are covered. +- Keep packages/static initramfs support in ucore-images; host enrollment and + private values stay out of that public image. Preserve serialized image CI. + +Exit: an explicit boot policy and recovery procedure are documented, and all +bootstrap/deployed images can unlock it. Unsupported policy integration is a +reported blocker, not permission to weaken the chosen policy implicitly. + +## 2. Add an explicit encrypted installation profile + +- Model the selected encryption profile separately from host identity, VM + instance and physical disk selection. Reuse it for every host that opts in; + do not hardcode clamps into shared storage or unlock logic. +- In Butane/Ignition create LUKS2 on the selected root partition and Btrfs + labelled `root` on `/dev/mapper/root`. Remove the existing raw-partition + formatting entry in that profile. Keep explicit partition sizing/resizing; + do not assume automatic growth after root reprovisioning. +- Enrollment must occur under the intended final boot policy. Account for + MOK enrollment and the FCOS-to-uCore bootstrap before taking measurements. + If a temporary install unlock path is necessary, document its lifetime and + removal and prove it is absent before acceptance. +- Generalize the data helper and mount currently referencing + `/dev/disk/by-partlabel/root`. Use a stable identifier of the unlocked Btrfs + filesystem and verify it matches `/var` and the expected backing topology. + Preserve wrong/missing-mount guards and SELinux labeling for Podman. + +Exit: generated Ignition and units target the mapper filesystem, carry no +private key, and preserve the existing data layout. Generation rejects missing +capabilities and conflicting raw/encrypted formatting entries. + +## 3. Add TPM-capable disposable VMs + +- Add an opt-in TPM2/encrypted-install profile to the existing Rust CLI and + VM helper. Document its actual command interface once implemented. +- Both native libvirt XML and Flatpak virt-install paths must use equivalent + virtual TPM2 and UEFI/Secure Boot configuration. Check backend capabilities + before creating a run; missing swtpm/firmware support produces a useful error. + Use libvirt-managed swtpm, not passthrough of the workstation's physical TPM. +- Give each named VM independent TPM state and firmware NVRAM. Persist them + across shutdown/start/rebase; own and remove them during disposal. Never + reuse another VM's state. Record profile and ownership in ignored metadata. +- Readiness must be bounded and capture serial/early-boot diagnostics for + unlock failures, which occur before SSH. Do not add `systemctl --wait`. + +Exit: a fresh encrypted test VM reaches readiness automatically. Disposal and +fresh recreation create a new disk, TPM identity and NVRAM. Existing VM profiles +remain usable, and both backend paths have evidence or an explicit unverified +status. No workstation installation is performed by the agent. + +## 4. Establish recovery before destructive tests + +- Add an independent high-entropy recovery key while the valid TPM path works. + Persist production keys through the atomic Rust KDBX writer at + `skillet/hosts//storage/root-recovery-key`. Reuse an existing value; + verify it unlocks this specific volume before treating it as recovery. +- Disposable tests use generated temporary recovery material, never production + entries. Keep it out of command arguments, logs and Ignition; any temporary + file must be ignored, mode 0600, and removed on disposal. Prefer stdin. +- Store a LUKS header backup off the encrypted disk with restricted access; + document its private backup location and disk identity. Treat it as sensitive + and refresh it after enrollment/rotation. Test recovery from another + workstation using the backed-up vault. A header backup is not a data backup. +- Check for unintended key-file/unrestricted unlock slots; do not erase the + sole working unlock path. Capture only non-secret enrollment metadata. + +Exit: recovery is verified before TPM replacement or boot-policy negative +tests. TPM/motherboard loss cannot remove the only usable recovery material. + +## 5. QEMU acceptance matrix + +Use a fresh named disposable VM and generated secrets. Routine CI needs no +production vault, Cloudflare or Tailscale API. Networkless boot tests start +after image pulls/bootstrap; evaluate storage and base readiness separately +from services requiring the network. Record results in `butane/ACCEPTANCE.md`. + +| Check | Required observation | +| --- | --- | +| Fresh install and signed bootstrap | Every bootstrap reboot unlocks without typing; final boot policy is enrolled | +| Storage topology | Raw root is LUKS2; opened mapper contains Btrfs; `/var` and `/var/lib/data` share the expected filesystem and data subvolume | +| Cold power-off/start and reboot | Same TPM/NVRAM persists; no operator input; data sentinel and test credentials survive | +| Boot with NIC disconnected | Root/data unlock and base storage readiness succeed without Tang or Tailscale | +| Signed update, rebase and rollback | Each approved deployment boots unattended and preserves sentinel, subvolumes and credentials | +| Repeat Skillet apply | Enrollment and mount bytes remain stable; no unnecessary application recreation | +| Missing/wrong data mount | Applications cannot write into a fallback directory; restoring the mount recovers without formatting | +| Disk with a different or absent TPM | Automatic unlock fails within the test deadline; no unintended key-file or weaker slot succeeds | +| Change a covered boot measurement | Unlock is denied; restoring approved state recovers; test the actual selected policy, not an unrelated PCR | +| Recovery after simulated TPM loss | Recovery key opens the same volume and preserves data; re-enrollment restores unattended boot | +| Snapshot/restore | Syncthing leaf snapshot and restore work; no whole-data snapshot or nested subvolume support is introduced | +| Disposal and recreation | Only owned disk/TPM/NVRAM/test secrets are removed; next VM receives fresh identities | + +Positive-path VM tests cover functionality, not resistance to a compromised +hypervisor or physical firmware attacks. Negative tests operate on disposable +copies and never clear the workstation's TPM or change its boot firmware. + +## 6. Physical acceptance and completion + +- On the selected stock clamps SSD, confirm TPM2 and supported PCR bank, final + UEFI/Secure Boot/MOK state, unattended cold boot, networkless unlock, signed + update/rebase/rollback, storage/credential persistence, and recovery from + external media or an available remote console. Preserve rupik and its SSD. +- Verify each later physical server independently, including remote console + availability before enabling encryption. Do not assume a vendor/model has + TPM support or that a QEMU pass proves its firmware behavior. +- Run applicable CI checks and affected real-runtime smoke tests after code + changes. Keep design/README setup and VM lifecycle ownership documentation + updated. Make focused commits only after validation; report unavailable + checks honestly. No push without instruction. + +Done when QEMU acceptance and stock-SSD physical acceptance both have evidence, +recovery material is independently available, no temporary weaker unlock path +remains, and the chosen policy supports our unattended update workflow. + +References: [CoreOS encrypted root](https://github.com/coreos/fedora-coreos-docs/blob/main/modules/ROOT/pages/storage.adoc#encrypted-storage-luks), +[Ignition LUKS](https://coreos.github.io/ignition/operator-notes/#luks), +[Clevis TPM policy](https://github.com/latchset/clevis/blob/master/src/pins/tpm2/clevis-encrypt-tpm2.1.adoc), +[systemd enrollment policies](https://github.com/systemd/systemd/blob/main/man/systemd-cryptenroll.xml), +[PCR registry](https://uapi-group.org/specifications/specs/linux_tpm_pcr_registry/), +[libvirt TPM devices](https://libvirt.org/formatdomain.html#tpm-device). From 0937807af01d5e4dad4ae227ca7121b1c0e2e284 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 13:03:23 +0200 Subject: [PATCH 113/252] fix(cloudflare): explain zone preflight permission requirements --- ublue/butane/ACCEPTANCE.md | 20 ++++++++++++++++ ublue/design/cloudflare-ui-lifecycle.md | 3 +++ ublue/plan/GENERIC-PRIVATE-UIS.md | 3 +++ ublue/skillet/README.md | 7 ++++-- ublue/skillet/crates/cli/src/cloudflare.rs | 4 +++- .../crates/cli/src/cloudflare_tests.rs | 23 +++++++++++++++++++ 6 files changed, 57 insertions(+), 3 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 004d3084..6274acfa 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -389,3 +389,23 @@ host tool; it still uses normal command approvals. No Cloudflare API or live certificate acceptance is claimed. User terminal unlock is needed before continuing retained-VM checks and the fresh destroy/create/provision/verify/dispose cycle. + +### Cloudflare zone preflight permissions, 2026-10-02 + +- After user unlock, `skillet test vm provision clamps generic-ui --with-ui` + reached Cloudflare and returned HTTP 403 / code 9109 during the configured + zone lookup using the workstation issuer. This occurs before child-token + issuance and DNS reconciliation. Caddy ACME/HTTPS remain unverified. +- The README had incorrectly excluded zone permissions from the issuer. + Corrected setup requires Zone Read on configured zones as well as User API + Tokens Read/Edit; DNS Write is still confined to child tokens. The rebuilt + CLI reproduced the error with zone-preflight permission context. +- `cargo fmt --all --check`, workspace tests (64 tests), and all-target + pedantic Clippy passed with `--target x86_64-unknown-linux-gnu` for tests and + Clippy. The CI integration scenario `skillet test run beezelbot --phase base + --image fedora:latest` passed; repeat apply issued no service restarts. + `cargo build --release --target x86_64-unknown-linux-musl -p skillet` passed + with the documented compiler PATH. The HTTP mock regression test verifies + useful 403 context without token or configured zone ID disclosure. +- Live acceptance is pending issuer zone access being updated in Cloudflare; + no live certificate issuance or fresh full cycle is claimed. diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index 35fdbbe1..a03378c9 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -5,6 +5,9 @@ reconciles owned DNS before live Caddy ACME acceptance. Do not require manually created smoke VM ACME tokens. The workstation keeps `skillet/cloudflare/token-creator` in KeePassXC and mints zone-scoped child tokens; only a child token is delivered to Caddy. +The issuer also has Zone Read on the configured zones because preflight +validation fetches the exact zone before issuance. User API Tokens Read/Edit +alone does not authorize that lookup. DNS Write remains on child tokens. ## Configuration and access diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 8303db97..d413f02d 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -157,6 +157,9 @@ use manually created VM ACME tokens as an interim acceptance path. Environment lookups use the DNS paths above. The configured Zone ID is fetched exactly and the returned zone name must contain the UI domain before issuance or record changes. The former zone-name entry is never treated as an ID. +The workstation issuer therefore needs Zone Read on the selected zones in +addition to User API Tokens Read/Edit. The template's token-management +permissions alone do not authorize zone preflight; the issuer needs no DNS Write. Use `cloudflare-rs` for authenticated blocking HTTP transport and custom typed JSON endpoint specs for token APIs and DNS comments not represented by its DNS diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index e9dd7c75..e17cf403 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -180,8 +180,11 @@ remains in the [secret design](../design/secrets.md). For Cloudflare token issuance, select **Create additional tokens** in the Cloudflare dashboard, granting **User > API Tokens > Read** and **API Tokens > -Edit** (the write permission) only. -Do not add zone permissions to this workstation issuer. Save it as +Edit** (the write permission). Add **Zone > Zone > Read**, with Zone Resources +restricted to the configured test and/or production zones. Skillet uses the +issuer to validate the selected zone before minting a child token. The issuer +does not need DNS Write. A token with only the additional-tokens template +permissions fails this validation with HTTP 403. Save it as `skillet/cloudflare/token-creator`. Skillet discovers the Cloudflare permission-group IDs at runtime, then creates zone-scoped child tokens with Zone Read and DNS Write. If the issuer is already stored in the desktop diff --git a/ublue/skillet/crates/cli/src/cloudflare.rs b/ublue/skillet/crates/cli/src/cloudflare.rs index 4ae5f067..ca6e37f9 100644 --- a/ublue/skillet/crates/cli/src/cloudflare.rs +++ b/ublue/skillet/crates/cli/src/cloudflare.rs @@ -123,7 +123,9 @@ impl Cloudflare { pub(crate) fn zone(&self, token: &str, zone_id: &str) -> Result { validate_zone_id(zone_id)?; - let value = self.result(token, Method::GET, format!("zones/{zone_id}"), None, None)?; + let value = self + .result(token, Method::GET, format!("zones/{zone_id}"), None, None) + .context("reading configured Cloudflare zone; check Zone > Zone > Read permission and access to the configured zone")?; serde_json::from_value(value).context("decoding Cloudflare zone") } diff --git a/ublue/skillet/crates/cli/src/cloudflare_tests.rs b/ublue/skillet/crates/cli/src/cloudflare_tests.rs index f81b5cd2..44a9e4c3 100644 --- a/ublue/skillet/crates/cli/src/cloudflare_tests.rs +++ b/ublue/skillet/crates/cli/src/cloudflare_tests.rs @@ -89,6 +89,29 @@ fn sdk_transport_calls_cloudflare_with_bearer_token() { server.join().expect("server thread"); } +#[test] +fn forbidden_zone_lookup_reports_permission_context_without_zone_id() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("client"); + let _request = read_request(&mut stream); + let body = r#"{"success":false,"errors":[{"code":9109,"message":"Unauthorized to access requested resource"}],"messages":[],"result":null}"#; + write!(stream, "HTTP/1.1 403 Forbidden\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("response"); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let error = api + .zone("issuer-token", "0123456789abcdef0123456789abcdef") + .expect_err("forbidden lookup"); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("reading configured Cloudflare zone")); + assert!(diagnostic.contains("Zone > Zone > Read")); + assert!(diagnostic.contains("403")); + assert!(!diagnostic.contains("issuer-token")); + assert!(!diagnostic.contains("0123456789abcdef0123456789abcdef")); + server.join().expect("server thread"); +} + #[test] fn discovers_permission_group_ids_and_creates_scoped_token_through_sdk() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); From b40b7ac4c2c2ac454b4ba448cedcdd9ecab50d12 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 13:16:40 +0200 Subject: [PATCH 114/252] feat(caddy): derive relative UI namespace from Cloudflare zone --- ublue/butane/ACCEPTANCE.md | 26 ++++++++++ ublue/design/cloudflare-ui-lifecycle.md | 17 ++++--- ublue/design/private-ui-access.md | 4 +- ublue/design/secrets.md | 7 +-- ublue/plan/GENERIC-PRIVATE-UIS.md | 18 +++++-- ublue/skillet/README.md | 20 +++++--- ublue/skillet/crates/caddy/src/lib.rs | 12 +++++ ublue/skillet/crates/caddy/src/tests.rs | 49 +++++++++++++++++++ .../skillet/crates/cli/src/secret_delivery.rs | 35 ++++++------- 9 files changed, 148 insertions(+), 40 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 6274acfa..06886a4d 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -409,3 +409,29 @@ host tool; it still uses normal command approvals. useful 403 context without token or configured zone ID disclosure. - Live acceptance is pending issuer zone access being updated in Cloudflare; no live certificate issuance or fresh full cycle is claimed. + +### Relative UI namespace, 2026-10-02 + +- `dns/cloudflare-zone-id` remains mandatory. Workstation production delivery, + opt-in VM UI provisioning, and VM cleanup now resolve the optional + `dns/ui-domain` as a relative prefix appended to the fetched zone domain. + An absent entry defaults to `ui`; an empty entry is invalid. Full-domain + values are also treated as relative and require deliberate local migration. +- `cargo fmt --all --check`, workspace tests (66 tests), and all-target + pedantic Clippy passed; tests and Clippy used the GNU target. Tests cover + default/multiple-label prefixes, environment isolation, always-appended + zone suffixes, invalid prefixes and excessive total domain length. +- Both musl release binaries built successfully with the documented compiler + PATH. `skillet test run beezelbot --phase base --image fedora:latest` passed + with no repeat-apply service restart. The named `clamps-test-generic-ui` VM + was updated with both binaries. +- `integration_tests/smoke-ssh.sh --target giacomo@127.0.0.1 --port 2202 + --identity ../butane/runs/clamps-test-generic-ui/ssh/id_ed25519 + --disposable-target --binary + /home/giacomo/.cache/devmachine-target/x86_64-unknown-linux-musl/release/skillet + --clamps-binary /var/usrlocal/bin/skillet-clamps` passed all fixture cases, + failure recovery and reboot persistence. Evidence is retained in the guest + under `/var/lib/skillet-smoke/`. This fixture check is not live ACME acceptance. +- Live Cloudflare/Caddy acceptance remains pending migration of the existing + vault prefix. No new DNS mutations or certificate issuance are claimed for + this change. diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index a03378c9..0963d714 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -11,12 +11,17 @@ alone does not authorize that lookup. DNS Write remains on child tokens. ## Configuration and access -Each environment supplies `skillet/environments//dns/ui-domain` -and `skillet/environments//dns/cloudflare-zone-id` in the vault's -Password fields. The latter is a Cloudflare Zone ID. Resolve that exact ID -through the Cloudflare API and validate its returned zone name contains the UI -domain before token issuance or DNS mutations. Production and test retain -separate configuration. +Each environment requires `skillet/environments//dns/cloudflare-zone-id` +in the vault's Password field. Fetch that exact zone's domain from Cloudflare. +The optional `skillet/environments//dns/ui-domain` is a relative +prefix, defaulting to `ui` only when absent. Always append the zone's domain: +`ui.whatever` in zone `example.com` resolves to `ui.whatever.example.com`. +This removes redundant full-domain configuration and keeps namespaces within +the selected zone. Empty or invalid prefixes fail before issuance or DNS +mutations. Former full-domain values require a deliberate vault migration; +they are never interpreted as absolute names. Production and test retain +separate configuration. Delivery and cleanup use the same resolution rule; +ignored ownership metadata retains the resolved full namespace for verification. Host declarations derive a machine name `.` with one A record for its verified Tailscale IPv4 address and one AAAA for its verified, diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index ec5d958c..104a411f 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -79,7 +79,9 @@ acceptance remains pending; no manually created VM token is required. Host UI service declarations now drive a shared versioned Caddy payload. Provisioning derives `..` from the selected -environment's KeePassXC domain. Caddy validates the payload against the host's +environment's resolved namespace: the optional KeePassXC `dns/ui-domain` +relative prefix (default `ui`) appended to the zone domain fetched using the +mandatory `dns/cloudflare-zone-id`. Caddy validates the payload against the host's declaration and renders each route through bridge DNS with tailnet source restrictions. Shared Butane units call the generic `skillet` CLI and read the stable host profile from `/etc/skillet/host`; VM hostnames remain independent. diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 2bb8a40d..030bb796 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -1,7 +1,8 @@ # Secret storage and delivery -UI provisioning reads a base domain and Cloudflare zone from the selected -environment in KeePassXC, derives service names from the host's declarations, +UI provisioning reads a mandatory Cloudflare Zone ID and optional relative +UI prefix from the selected environment in KeePassXC, fetches the zone domain, +and derives service names from the host's declarations, and uses shared credential delivery. Cloudflare token issuance, DNS lifecycle, and smoke VM cleanup are implemented; live ACME acceptance remains pending. See the [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md). @@ -53,7 +54,7 @@ The prefix is consistently singular, `skillet`: | Vault entry | Host credential and Podman secret name | | --- | --- | | `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | -| `skillet/environments//dns/ui-domain` | Base for derived Caddy hostnames | +| `skillet/environments//dns/ui-domain` | Optional relative UI prefix; defaults to `ui`, appended to the fetched zone domain | | `skillet/environments//dns/cloudflare-zone-id` | Cloudflare Zone ID lookup | | `skillet/environments//hosts//cloudflare/acme-token` | Persistent `cloudflare_acme_token`, created on first Caddy delivery | | `skillet/hosts//cloudflare/acme-token` | Legacy production credential migrated on first delivery | diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index d413f02d..f0d08867 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -30,15 +30,20 @@ the Password field, following the existing exact-lookup convention: | Entry | Purpose | | --- | --- | -| `skillet/environments//dns/ui-domain` | Base UI domain | +| `skillet/environments//dns/ui-domain` | Optional relative prefix; absent means `ui` | | `skillet/environments//dns/cloudflare-zone-id` | Cloudflare Zone ID of the authorized existing zone | | `skillet/environments//hosts//cloudflare/acme-token` | Durable token for a persistent host | | `skillet/cloudflare/token-creator` | Workstation-only token issuer | Production and test are environment names, not distinct implementations. -Locate the existing domain entries privately before migration; reuse or move -their values, never invent or overwrite them. Read-only config migration -must not expose their values. Keep unrelated host secret paths unchanged. +Fetch the mandatory Zone ID's domain from Cloudflare and always append it to +the optional relative prefix. `ui.whatever` in zone `example.com` resolves to +`ui.whatever.example.com`; absent means `ui.example.com`, while empty is invalid. +Former full-domain values must be deliberately replaced or removed locally; +never silently treat them as absolute names or rewrite the vault. Cleanup must +resolve the prefix identically and compare the full namespace with recorded +ownership. Dispose of old UI deployments under their original configuration +before migrating. Keep unrelated host secret paths unchanged. The shared environment policy selects ACME staging for test and the normal issuer for production. Token scope is Zone Read plus DNS Write for the selected @@ -74,6 +79,8 @@ dependency. Declaring future services must not require editing Caddy's renderer. Add a validated environment configuration and one hostname derivation helper. +Resolve the UI namespace from the Cloudflare zone domain plus the optional +relative prefix (default `ui`) using the same helper for delivery and cleanup. Validate DNS labels, domain length, duplicates, empty service lists, and upstream inputs before SSH delivery or remote API mutations. Reject schemes, paths, ports, whitespace, and configuration injection in the base domain. @@ -82,7 +89,8 @@ scope to another zone automatically. Exit: a non-clamps host with only Syncthing derives exactly one route; the same host definition works with either environment. Use reserved example -domains in tests. Missing config fails with the entry path, without values. +domains in tests. Missing mandatory Zone ID config fails with the entry path, +without values; an absent relative prefix uses `ui`. ### 1a. Caller-declared UI aliases (implemented; environment isolation tests pending) diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index e17cf403..efd82ac7 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -198,15 +198,23 @@ KeePassXC entries in their Password fields: | Group path | Entry title | Value | | --- | --- | --- | -| `skillet/environments//dns` | `ui-domain` | Base domain for derived host UI names | -| `skillet/environments//dns` | `cloudflare-zone-id` | Cloudflare Zone ID containing the UI domain | +| `skillet/environments//dns` | `ui-domain` | Optional relative prefix; defaults to `ui` when absent | +| `skillet/environments//dns` | `cloudflare-zone-id` | Required Cloudflare Zone ID | | `skillet/cloudflare` | `token-creator` | Workstation token issuer | Select `production` or `test` for ``. Copy the Zone ID from the -selected zone's Cloudflare Overview page. Move the base-domain value from -`skillet/dns/smoke-ui-zone` to the test `dns/ui-domain` entry before removing -the old entry. The previous `cloudflare/zone` entry contained a zone name; -populate `dns/cloudflare-zone-id` with the actual ID. +selected zone's Cloudflare Overview page. Skillet fetches the zone's domain +and always appends it to the relative prefix. For zone `example.com`, an +absent entry produces `ui.example.com`; `ui.whatever` produces +`ui.whatever.example.com`. An empty entry is invalid. Replace any former +full-domain value with a relative prefix, or remove the entry to use `ui`. +Full-domain values are not detected or reused as absolute names: they too +are appended to the zone. Do not copy a full domain from the old +`skillet/dns/smoke-ui-zone` entry. The previous `cloudflare/zone` entry contained +a zone name; populate `dns/cloudflare-zone-id` with the actual ID. +Clean up any existing disposable UI deployment with the previous CLI and +configuration before migrating its prefix; cleanup verifies the recorded +resolved namespace and refuses a changed configuration. The CLI reads these environment paths directly. Persistent Caddy delivery creates and saves a missing host ACME token at diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index 387784b6..02442edd 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -241,6 +241,18 @@ pub fn validate_domain(domain: &str) -> Result<(), CaddyError> { Ok(()) } +/// Resolve an optional relative UI namespace beneath the authoritative zone. +pub fn resolve_ui_domain(zone: &str, prefix: Option<&str>) -> Result { + validate_domain(zone)?; + let prefix = prefix.unwrap_or("ui").trim(); + for label in prefix.split('.') { + validate_label(label, "relative UI domain prefix")?; + } + let domain = format!("{prefix}.{zone}"); + validate_domain(&domain)?; + Ok(domain) +} + pub fn validate_domain_in_zone(domain: &str, zone: &str) -> Result<(), CaddyError> { validate_domain(domain)?; validate_domain(zone)?; diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index f95f0d91..cd05614f 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -222,3 +222,52 @@ fn renders_declared_aliases_and_rejects_conflicting_names() { .is_err()); } } +#[test] +fn relative_ui_domain_defaults_and_always_appends_zone() { + assert_eq!( + super::resolve_ui_domain("example.com", None).expect("default"), + "ui.example.com" + ); + assert_eq!( + super::resolve_ui_domain("example.com", Some("ui.whatever")).expect("relative prefix"), + "ui.whatever.example.com" + ); + assert_eq!( + super::resolve_ui_domain("example.com", Some(" ui ")).expect("trimmed prefix"), + "ui.example.com" + ); + assert_eq!( + super::resolve_ui_domain("example.com", Some("other.example.com")) + .expect("all values are relative"), + "other.example.com.example.com" + ); + assert_eq!( + super::resolve_ui_domain("example.test", None).expect("other environment"), + "ui.example.test" + ); +} + +#[test] +fn relative_ui_domain_rejects_invalid_prefixes_and_total_length() { + for prefix in [ + "", + " ", + ".ui", + "ui.", + "ui..private", + "https://ui", + "ui:443", + "ui/path", + "ui space", + "*", + "{host}", + "-ui", + "ui-", + ] { + assert!(super::resolve_ui_domain("example.com", Some(prefix)).is_err()); + } + assert!(super::resolve_ui_domain("invalid", None).is_err()); + let label = "a".repeat(63); + let too_long = format!("{label}.{label}.{label}.{label}"); + assert!(super::resolve_ui_domain("example.com", Some(&too_long)).is_err()); +} diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index d7d2eddb..fa8362a4 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -102,12 +102,10 @@ fn deliver_caddy_from_vault( let ui_config = ui_config.ok_or_else(|| anyhow!("missing host UI declaration"))?; let environment = args.environment.as_str(); let domain_path = format!("skillet/environments/{environment}/dns/ui-domain"); - let domain = lookup(&vault.database, &domain_path)? - .ok_or_else(|| anyhow!("KeePassXC UI domain entry is missing: {domain_path}"))?; + let domain_prefix = lookup(&vault.database, &domain_path)?; let zone_path = format!("skillet/environments/{environment}/dns/cloudflare-zone-id"); let zone_id = lookup(&vault.database, &zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; - let domain = domain.trim(); let zone_id = zone_id.trim(); crate::cloudflare::validate_zone_id(zone_id)?; let creator = @@ -118,12 +116,12 @@ fn deliver_caddy_from_vault( })?; let cloudflare = crate::cloudflare::Cloudflare::new(); let zone = cloudflare.zone(&creator, zone_id)?; - skillet_caddy::validate_domain_in_zone(domain, &zone.name) - .context("validating KeePassXC UI domain against its Cloudflare zone")?; + let domain = skillet_caddy::resolve_ui_domain(&zone.name, domain_prefix.as_deref()) + .context("resolving KeePassXC relative UI domain beneath its Cloudflare zone")?; let sites = skillet_caddy::CaddySites::from_host( &args.hostname, &skillet_caddy::UiEnvironment { - ui_domain: domain.to_string(), + ui_domain: domain.clone(), acme_staging: args.environment.acme_staging(), }, &ui_config.services, @@ -138,7 +136,7 @@ fn deliver_caddy_from_vault( let token = host_acme_token(args, vault, &cloudflare, &creator, zone_id)?; cloudflare.zone(&token, zone_id)?; ensure_vault_unchanged(vault)?; - cloudflare.reconcile_dns(&token, zone_id, &dns_marker, domain, &dns)?; + cloudflare.reconcile_dns(&token, zone_id, &dns_marker, &domain, &dns)?; ensure_vault_unchanged(vault)?; let sites = serde_json::to_string(&sites)?; for (credential, value) in [ @@ -453,8 +451,7 @@ fn provision_vm_ui( let environment = UiEnvironmentName::Test.as_str(); let domain_path = format!("skillet/environments/{environment}/dns/ui-domain"); let zone_path = format!("skillet/environments/{environment}/dns/cloudflare-zone-id"); - let ui_domain = lookup(&vault.database, &domain_path)? - .ok_or_else(|| anyhow!("KeePassXC UI domain entry is missing: {domain_path}"))?; + let domain_prefix = lookup(&vault.database, &domain_path)?; let zone_id = lookup(&vault.database, &zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; let creator = @@ -463,13 +460,12 @@ fn provision_vm_ui( "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" ) })?; - let ui_domain = ui_domain.trim().to_string(); let zone_id = zone_id.trim().to_string(); crate::cloudflare::validate_zone_id(&zone_id)?; let api = crate::cloudflare::Cloudflare::new(); let zone = api.zone(&creator, &zone_id)?; - skillet_caddy::validate_domain_in_zone(&ui_domain, &zone.name) - .context("validating test UI domain against its Cloudflare zone")?; + let ui_domain = skillet_caddy::resolve_ui_domain(&zone.name, domain_prefix.as_deref()) + .context("resolving test relative UI domain beneath its Cloudflare zone")?; let host_ui = skillet_cli_common::hosts::ui_config_for_host(&args.hostname) .ok_or_else(|| anyhow!("host {} has no declared UI services", args.hostname))?; let sites = skillet_caddy::CaddySites::from_host( @@ -575,17 +571,18 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( "skillet/environments/test/dns/cloudflare-zone-id", )? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: skillet/environments/test/dns/cloudflare-zone-id"))?; - let configured_domain = lookup(&vault.database, "skillet/environments/test/dns/ui-domain")? - .ok_or_else(|| { - anyhow!("KeePassXC UI domain entry is missing: skillet/environments/test/dns/ui-domain") - })?; - if configured_zone.trim() != ownership.zone_id - || configured_domain.trim() != ownership.ui_domain - { + let configured_prefix = lookup(&vault.database, "skillet/environments/test/dns/ui-domain")?; + if configured_zone.trim() != ownership.zone_id { return Err(anyhow!("test Cloudflare configuration differs from the recorded VM owner; restore the original vault values before cleanup")); } let api = crate::cloudflare::Cloudflare::new(); let zone = api.zone(&creator, &ownership.zone_id)?; + let configured_domain = + skillet_caddy::resolve_ui_domain(&zone.name, configured_prefix.as_deref()) + .context("resolving test UI namespace before VM cleanup")?; + if configured_domain != ownership.ui_domain { + return Err(anyhow!("test Cloudflare configuration differs from the recorded VM owner; restore the original relative prefix before cleanup")); + } skillet_caddy::validate_domain_in_zone(&ownership.ui_domain, &zone.name) .context("validating recorded test UI domain against its Cloudflare zone")?; let cleanup_name = format!("{}:cleanup", ownership.token_name); From 8fcb5833f78ab54af04d019272c69dd9f590833a Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 14:25:24 +0200 Subject: [PATCH 115/252] feat(cloudflare): support account-owned token issuers --- ublue/butane/ACCEPTANCE.md | 36 ++++++++++++++ ublue/design/cloudflare-ui-lifecycle.md | 18 ++++--- ublue/plan/GENERIC-PRIVATE-UIS.md | 16 +++--- ublue/skillet/README.md | 13 +++-- ublue/skillet/crates/caddy/src/lib.rs | 4 +- ublue/skillet/crates/caddy/src/tests.rs | 7 +++ ublue/skillet/crates/cli/src/cloudflare.rs | 48 ++++++++++++++---- .../crates/cli/src/cloudflare_tests.rs | 49 ++++++++++++++++--- .../skillet/crates/cli/src/secret_delivery.rs | 28 ++++++----- 9 files changed, 170 insertions(+), 49 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 06886a4d..710ca79e 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -435,3 +435,39 @@ host tool; it still uses normal command approvals. - Live Cloudflare/Caddy acceptance remains pending migration of the existing vault prefix. No new DNS mutations or certificate issuance are claimed for this change. + +### Live issuer retry, 2026-10-02 + +- Retried `skillet test vm provision clamps generic-ui --with-ui` using the + cached vault unlock. Zone lookup and relative namespace resolution succeeded. +- The first retry found that the saved issuer is account-owned and correctly + has Account API Tokens Write. The original Skillet implementation called + user-token endpoints, which returned HTTP 403 / code 9109. This endpoint + mismatch was corrected by deriving the account ID from the selected zone and + using account-token endpoints for discovery, creation, listing, and revoke. +- See the live account-token and Caddy result below; the earlier blocked state + has been resolved. + +### Account token, Cloudflare DNS, and Caddy live acceptance, 2026-10-02 + +- The account-owned issuer passed permission-group discovery and created the + scoped child token. Cloudflare DNS reconciliation created the expected five + owned records for the machine address, canonical UIs, and declared alias. +- Caddy's disposable container started and obtained Let's Encrypt staging + certificates for Pi-hole, Syncthing, and the Syncthing alias using DNS-01. + OpenSSL checks confirmed each certificate SAN matched the requested name and + the issuer was staging. +- From the workstation on the tailnet, DNS resolved over IPv4 and IPv6. HTTPS + returned Pi-hole `/admin/` login redirects (302) and Syncthing canonical and + alias pages (200) over both address families. Bare Pi-hole `/` returns 403; + its UI is at `/admin/`. +- `skillet test vm destroy clamps generic-ui` completed. It removed the owned + Cloudflare records and child tokens through account endpoints, removed the + Tailscale device, undefined the libvirt domain, and removed Cloudflare + ownership metadata. The retained unrelated `clamps-test-smoke` VM remains. +- `cargo fmt --all --check`, workspace GNU tests (67 tests), all-target + pedantic Clippy, and `skillet test run beezelbot --phase base --image + fedora:latest` passed. The account create/list/revoke endpoints and Caddy + config directory mount have regression coverage. +- This validates staging only. A request from outside the tailnet and + production ACME issuance/renewal were not tested. diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index 0963d714..2a6a8cb2 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -4,10 +4,13 @@ Decision: the workstation issuer creates scoped Cloudflare child tokens and reconciles owned DNS before live Caddy ACME acceptance. Do not require manually created smoke VM ACME tokens. The workstation keeps `skillet/cloudflare/token-creator` in KeePassXC and mints -zone-scoped child tokens; only a child token is delivered to Caddy. -The issuer also has Zone Read on the configured zones because preflight -validation fetches the exact zone before issuance. User API Tokens Read/Edit -alone does not authorize that lookup. DNS Write remains on child tokens. +zone-scoped child tokens through Cloudflare's account-token API; the account ID +comes from the selected zone response. Only a child token is delivered to Caddy. +The issuer is an account-owned API token with Account API Tokens Read or Write +and Zone Read on the configured zones. Zone Read allows preflight validation to +fetch the selected zone and its account ID. Skillet uses account-token +permission discovery, creation, listing, and revocation endpoints; DNS Write +remains confined to child tokens. ## Configuration and access @@ -108,8 +111,11 @@ provisioning records token ID, expiry, DNS record IDs, and ownership metadata; destroy mints a short-lived cleanup token, deletes marker-owned DNS records, and revokes disposable tokens. Local tests cover the SDK transport, permission group discovery, scoped token payloads, address-family validation, and DNS -planning. DNS mutation fault-injection/recovery tests and live -staging ACME/HTTPS acceptance are still pending. +planning. Live 2026-10-02 staging acceptance issued certificates for each +declared canonical UI and alias, verified DNS and HTTPS over IPv4/IPv6, and +disposed the VM, DNS records, and child tokens. DNS mutation +fault-injection/recovery tests, outside-tailnet denial, and production +certificate acceptance remain pending. See [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md), [secret delivery](secrets.md), and [VM lifecycle](smoke-vms.md). diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index f0d08867..51c9d66f 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -1,10 +1,11 @@ # Generic private UI provisioning Status: shared UI declarations, aliases, environment lookup, Caddy payloads, -Cloudflare token issuance, DNS reconciliation, and opt-in smoke VM cleanup are -implemented locally, 2026-10-02. Local tests pass for the SDK transport and DNS -planning. Full API mutation fault-injection and live staging ACME/HTTPS -acceptance remain pending. +Cloudflare account-token issuance, DNS reconciliation, and opt-in smoke VM +cleanup are implemented and passed live staging ACME/HTTPS acceptance on +2026-10-02. Unit tests, Clippy, and routine CI pass. Remaining: API mutation +fault-injection tests, access denial from outside the tailnet, and production +ACME/renewal acceptance. ## Goal and fixed decisions @@ -165,9 +166,10 @@ use manually created VM ACME tokens as an interim acceptance path. Environment lookups use the DNS paths above. The configured Zone ID is fetched exactly and the returned zone name must contain the UI domain before issuance or record changes. The former zone-name entry is never treated as an ID. -The workstation issuer therefore needs Zone Read on the selected zones in -addition to User API Tokens Read/Edit. The template's token-management -permissions alone do not authorize zone preflight; the issuer needs no DNS Write. +The workstation issuer is an account-owned token with Account API Tokens Read +or Write and Zone Read on the selected zones. Skillet gets the account ID from +the zone response and uses account-token endpoints for permission discovery, +creation, listing, and revocation. The issuer needs no DNS Write. Use `cloudflare-rs` for authenticated blocking HTTP transport and custom typed JSON endpoint specs for token APIs and DNS comments not represented by its DNS diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index efd82ac7..f4948d89 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -178,13 +178,12 @@ Redeliver the same entry after moving to a new workstation; use a deliberate vault edit and redelivery for rotation. The production TPM binding decision remains in the [secret design](../design/secrets.md). -For Cloudflare token issuance, select **Create additional tokens** in the -Cloudflare dashboard, granting **User > API Tokens > Read** and **API Tokens > -Edit** (the write permission). Add **Zone > Zone > Read**, with Zone Resources -restricted to the configured test and/or production zones. Skillet uses the -issuer to validate the selected zone before minting a child token. The issuer -does not need DNS Write. A token with only the additional-tokens template -permissions fails this validation with HTTP 403. Save it as +For Cloudflare token issuance, create an **account-owned API token** with +**Account > API Tokens > Write** (Read also works for permission discovery) +and **Zone > Zone > Read**, with Zone Resources restricted to the configured +test and/or production zones. Skillet reads the account ID from the selected +zone and uses the account-token endpoints to create, list, and revoke child +tokens. The issuer does not need DNS Write. Save it as `skillet/cloudflare/token-creator`. Skillet discovers the Cloudflare permission-group IDs at runtime, then creates zone-scoped child tokens with Zone Read and DNS Write. If the issuer is already stored in the desktop diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index 02442edd..026bbabd 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -321,8 +321,8 @@ where create_host_user: false, volumes: vec![ Volume { - host_path: "/etc/skillet/caddy/Caddyfile".to_string(), - container_path: "/etc/caddy/Caddyfile".to_string(), + host_path: "/etc/skillet/caddy".to_string(), + container_path: "/etc/caddy".to_string(), options: Some("ro,Z".to_string()), }, Volume { diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index cd05614f..d21acfd9 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -50,6 +50,13 @@ fn applying_unchanged_caddy_sites_preserves_the_container() { .get("/etc/containers/systemd/caddy.container") .cloned() .unwrap(); + let initial_config_text = String::from_utf8_lossy(&initial_config); + assert!(initial_config_text.contains("Volume=/etc/skillet/caddy:/etc/caddy:ro,Z")); + assert!(files + .files + .lock() + .unwrap() + .contains_key("/etc/skillet/caddy/Caddyfile")); super::apply(&system, &files, &sites, test_network()).unwrap(); diff --git a/ublue/skillet/crates/cli/src/cloudflare.rs b/ublue/skillet/crates/cli/src/cloudflare.rs index ca6e37f9..6893fbd1 100644 --- a/ublue/skillet/crates/cli/src/cloudflare.rs +++ b/ublue/skillet/crates/cli/src/cloudflare.rs @@ -42,6 +42,12 @@ pub(crate) struct IssuedToken { pub(crate) struct Zone { pub id: String, pub name: String, + pub account: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct ZoneAccount { + pub id: String, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -129,14 +135,23 @@ impl Cloudflare { serde_json::from_value(value).context("decoding Cloudflare zone") } - fn permission_groups(&self, token: &str) -> Result<(String, String)> { + pub(crate) fn account_id(zone: &Zone) -> Result<&str> { + zone.account + .as_ref() + .map(|account| account.id.as_str()) + .filter(|id| validate_token_id(id).is_ok()) + .ok_or_else(|| anyhow!("Cloudflare zone response does not include a valid account ID")) + } + + fn permission_groups(&self, token: &str, account_id: &str) -> Result<(String, String)> { + validate_token_id(account_id).context("validating Cloudflare account ID")?; let value = self.result( token, Method::GET, - "user/tokens/permission_groups".to_string(), + format!("accounts/{account_id}/tokens/permission_groups"), Some("per_page=500".to_string()), None, - )?; + ).context("discovering Cloudflare account-token permission groups; the issuer needs Account > API Tokens > Read or Write")?; let groups = value .as_array() .ok_or_else(|| anyhow!("Cloudflare permission group response is malformed"))?; @@ -174,7 +189,7 @@ impl Cloudflare { } match (zone_read, dns_write) { (Some(read), Some(write)) => Ok((read, write)), - _ => Err(anyhow!("Cloudflare did not expose selectable Zone Read and DNS Write permission groups; the token creator needs User > API Tokens > Read and API Tokens > Edit (Write)")), + _ => Err(anyhow!("Cloudflare did not expose selectable Zone Read and DNS Write permission groups for this account")), } } @@ -182,11 +197,12 @@ impl Cloudflare { &self, creator_token: &str, zone_id: &str, + account_id: &str, name: &str, lifetime: Option, ) -> Result { validate_zone_id(zone_id)?; - let (zone_read, dns_write) = self.permission_groups(creator_token)?; + let (zone_read, dns_write) = self.permission_groups(creator_token, account_id)?; let mut payload = json!({ "name": name, "policies": [{ @@ -201,20 +217,26 @@ impl Cloudflare { let value = self.result( creator_token, Method::POST, - "user/tokens".to_string(), + format!("accounts/{account_id}/tokens"), None, Some(payload), )?; serde_json::from_value(value).context("decoding newly issued Cloudflare token") } - pub(crate) fn token_ids_by_name(&self, creator_token: &str, name: &str) -> Result> { + pub(crate) fn token_ids_by_name( + &self, + creator_token: &str, + account_id: &str, + name: &str, + ) -> Result> { + validate_token_id(account_id).context("validating Cloudflare account ID")?; let mut matches = Vec::new(); for page in 1..=100_u32 { let response = self.request( creator_token, Method::GET, - "user/tokens".to_string(), + format!("accounts/{account_id}/tokens"), Some(format!("page={page}&per_page=100")), None, )?; @@ -243,12 +265,18 @@ impl Cloudflare { Ok(matches) } - pub(crate) fn revoke_token(&self, creator_token: &str, token_id: &str) -> Result<()> { + pub(crate) fn revoke_token( + &self, + creator_token: &str, + account_id: &str, + token_id: &str, + ) -> Result<()> { + validate_token_id(account_id).context("validating Cloudflare account ID")?; validate_token_id(token_id)?; self.result( creator_token, Method::DELETE, - format!("user/tokens/{token_id}"), + format!("accounts/{account_id}/tokens/{token_id}"), None, None, )?; diff --git a/ublue/skillet/crates/cli/src/cloudflare_tests.rs b/ublue/skillet/crates/cli/src/cloudflare_tests.rs index 44a9e4c3..72e23649 100644 --- a/ublue/skillet/crates/cli/src/cloudflare_tests.rs +++ b/ublue/skillet/crates/cli/src/cloudflare_tests.rs @@ -78,7 +78,7 @@ fn sdk_transport_calls_cloudflare_with_bearer_token() { let request = String::from_utf8_lossy(&request[..count]).to_ascii_lowercase(); assert!(request.contains("authorization: bearer test-token")); assert!(request.contains("/client/v4/zones/0123456789abcdef0123456789abcdef")); - let body = r#"{"success":true,"errors":[],"messages":[],"result":{"id":"0123456789abcdef0123456789abcdef","name":"example.test"}}"#; + let body = r#"{"success":true,"errors":[],"messages":[],"result":{"id":"0123456789abcdef0123456789abcdef","name":"example.test","account":{"id":"abcdef0123456789abcdef0123456789"}}}"#; write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("response"); }); let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); @@ -119,11 +119,11 @@ fn discovers_permission_group_ids_and_creates_scoped_token_through_sdk() { let server = thread::spawn(move || { for (expected_path, body) in [ ( - "/client/v4/user/tokens/permission_groups", + "/client/v4/accounts/abcdef0123456789abcdef0123456789/tokens/permission_groups", r#"{"success":true,"errors":[],"messages":[],"result":[{"id":"zone-read-id","name":"Zone Read","scopes":["com.cloudflare.api.account.zone"],"is_selectable":true},{"id":"dns-write-id","name":"DNS Write","scopes":["com.cloudflare.api.account.zone"],"is_selectable":true}]}"#, ), ( - "/client/v4/user/tokens", + "/client/v4/accounts/abcdef0123456789abcdef0123456789/tokens", r#"{"success":true,"errors":[],"messages":[],"result":{"id":"0123456789abcdef0123456789abcdef","value":"child-secret","expires_on":"2026-10-02T00:00:00Z"}}"#, ), ] { @@ -133,9 +133,7 @@ fn discovers_permission_group_ids_and_creates_scoped_token_through_sdk() { .to_ascii_lowercase() .contains("authorization: bearer issuer-token")); assert!(request.contains(expected_path)); - if expected_path.ends_with("user/tokens") - && !expected_path.ends_with("permission_groups") - { + if expected_path.ends_with("/tokens") { let (_, request_body) = request.split_once("\r\n\r\n").expect("POST body"); let payload: Value = serde_json::from_str(request_body).expect("JSON payload"); assert_eq!( @@ -162,6 +160,7 @@ fn discovers_permission_group_ids_and_creates_scoped_token_through_sdk() { .create_zone_token( "issuer-token", "0123456789abcdef0123456789abcdef", + "abcdef0123456789abcdef0123456789", "skillet:test:clamps-smoke", Some(Duration::from_mins(30)), ) @@ -171,6 +170,44 @@ fn discovers_permission_group_ids_and_creates_scoped_token_through_sdk() { server.join().expect("server thread"); } +#[test] +fn lists_and_revokes_tokens_through_account_endpoints() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + for (expected, body) in [ + ( + "GET /client/v4/accounts/abcdef0123456789abcdef0123456789/tokens?", + r#"{"success":true,"errors":[],"messages":[],"result":[{"id":"0123456789abcdef0123456789abcdef","name":"skillet:test:clamps-smoke"}],"result_info":{"total_pages":1}}"#, + ), + ( + "DELETE /client/v4/accounts/abcdef0123456789abcdef0123456789/tokens/0123456789abcdef0123456789abcdef", + r#"{"success":true,"errors":[],"messages":[],"result":{}}"#, + ), + ] { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + assert!(request.starts_with(expected)); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer issuer-token")); + write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("response"); + } + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let ids = api + .token_ids_by_name( + "issuer-token", + "abcdef0123456789abcdef0123456789", + "skillet:test:clamps-smoke", + ) + .expect("list account tokens"); + assert_eq!(ids, ["0123456789abcdef0123456789abcdef"]); + api.revoke_token("issuer-token", "abcdef0123456789abcdef0123456789", &ids[0]) + .expect("revoke account token"); + server.join().expect("server thread"); +} + fn read_request(stream: &mut std::net::TcpStream) -> String { let mut request = Vec::new(); let mut chunk = [0_u8; 2048]; diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index fa8362a4..a5dc46ec 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -133,7 +133,8 @@ fn deliver_caddy_from_vault( let dns = crate::cloudflare::desired_records(&sites.machine_hostname, &device.addresses, &sites)?; ensure_vault_unchanged(vault)?; - let token = host_acme_token(args, vault, &cloudflare, &creator, zone_id)?; + let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?; + let token = host_acme_token(args, vault, &cloudflare, &creator, zone_id, account_id)?; cloudflare.zone(&token, zone_id)?; ensure_vault_unchanged(vault)?; cloudflare.reconcile_dns(&token, zone_id, &dns_marker, &domain, &dns)?; @@ -167,6 +168,7 @@ fn host_acme_token( api: &crate::cloudflare::Cloudflare, creator: &str, zone_id: &str, + account_id: &str, ) -> Result { let environment = args.environment.as_str(); let token_path = format!( @@ -193,14 +195,14 @@ fn host_acme_token( // A previous request may have reached Cloudflare before its response was // lost. With no vault credential to reuse, clean up only child tokens // carrying this exact Skillet-owned name. - for orphan in api.token_ids_by_name(creator, &token_name)? { - api.revoke_token(creator, &orphan)?; + for orphan in api.token_ids_by_name(creator, account_id, &token_name)? { + api.revoke_token(creator, account_id, &orphan)?; } - let issued = api.create_zone_token(creator, zone_id, &token_name, None)?; + let issued = api.create_zone_token(creator, zone_id, account_id, &token_name, None)?; if let Err(error) = create_entry(&mut vault.database, &token_path, &issued.value) .and_then(|()| save_vault(vault, args.key_file.as_deref(), &token_path, &issued.value)) { - if let Err(revoke_error) = api.revoke_token(creator, &issued.id) { + if let Err(revoke_error) = api.revoke_token(creator, account_id, &issued.id) { return Err(anyhow!("saving issued Cloudflare credential failed ({error}); revoking token {} also failed ({revoke_error})", issued.id)); } return Err( @@ -491,11 +493,13 @@ fn provision_vm_ui( expires_on: None, record_ids: Vec::new(), }; + let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?.to_string(); write_cloudflare_ownership(&metadata_path, &ownership)?; ensure_vault_unchanged(vault)?; let issued = api.create_zone_token( &creator, &ownership.zone_id, + &account_id, &ownership.token_name, Some(std::time::Duration::from_hours(12)), )?; @@ -523,9 +527,9 @@ fn provision_vm_ui( &issued.value, )?; ssh.run("sudo -n systemctl start skillet-caddy-apply.service")?; - for old_id in api.token_ids_by_name(&creator, &ownership.token_name)? { + for old_id in api.token_ids_by_name(&creator, &account_id, &ownership.token_name)? { if old_id != issued.id { - api.revoke_token(&creator, &old_id)?; + api.revoke_token(&creator, &account_id, &old_id)?; } } Ok::<(), anyhow::Error>(()) @@ -577,6 +581,7 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( } let api = crate::cloudflare::Cloudflare::new(); let zone = api.zone(&creator, &ownership.zone_id)?; + let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?.to_string(); let configured_domain = skillet_caddy::resolve_ui_domain(&zone.name, configured_prefix.as_deref()) .context("resolving test UI namespace before VM cleanup")?; @@ -589,6 +594,7 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( let cleanup_token = api.create_zone_token( &creator, &ownership.zone_id, + &account_id, &cleanup_name, Some(std::time::Duration::from_mins(15)), )?; @@ -600,16 +606,16 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( &ownership.marker, &ownership.ui_domain, )?; - for id in api.token_ids_by_name(&creator, &ownership.token_name)? { - api.revoke_token(&creator, &id)?; + for id in api.token_ids_by_name(&creator, &account_id, &ownership.token_name)? { + api.revoke_token(&creator, &account_id, &id)?; } Ok::<(), anyhow::Error>(()) })(); let revoke_cleanup = api - .token_ids_by_name(&creator, &cleanup_name) + .token_ids_by_name(&creator, &account_id, &cleanup_name) .and_then(|ids| { for id in ids { - api.revoke_token(&creator, &id)?; + api.revoke_token(&creator, &account_id, &id)?; } Ok(()) }); From f91c63ae9a7c717adb14033667dd176ccaa9dad7 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 15:10:20 +0200 Subject: [PATCH 116/252] test(cloudflare): cover mutation retry recovery --- ublue/butane/ACCEPTANCE.md | 14 ++ ublue/design/cloudflare-ui-lifecycle.md | 8 +- ublue/plan/GENERIC-PRIVATE-UIS.md | 9 +- ublue/skillet/crates/cli/src/cloudflare.rs | 17 ++ .../crates/cli/src/cloudflare_tests.rs | 227 ++++++++++++++++++ .../skillet/crates/cli/src/secret_delivery.rs | 8 +- 6 files changed, 269 insertions(+), 14 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 710ca79e..dddbacf9 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -471,3 +471,17 @@ host tool; it still uses normal command approvals. config directory mount have regression coverage. - This validates staging only. A request from outside the tailnet and production ACME issuance/renewal were not tested. + +### Cloudflare API failure recovery tests, 2026-10-02 + +- Added local mocked-HTTP tests under `skillet/crates/cli/src/cloudflare_tests.rs`. + They run as part of the existing workspace `cargo test` CI job and require + no vault, Cloudflare account, credentials, or DNS zone. +- The token test models Cloudflare committing a named token while returning an + error; retry lists and revokes that orphan before creating exactly one + replacement. DNS reconciliation retries after a create failure and adopts + the already committed records without duplicates. Cleanup retries after a + partial delete failure and preserves a record owned by another marker. +- Focused Cloudflare tests pass (10 tests). `cargo fmt --all --check`, all 70 + workspace tests, `cargo clippy --all-targets -- -D warnings`, and + `skillet test run beezelbot --phase base --image fedora:latest` all pass. diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index 2a6a8cb2..154a0823 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -113,9 +113,11 @@ and revokes disposable tokens. Local tests cover the SDK transport, permission group discovery, scoped token payloads, address-family validation, and DNS planning. Live 2026-10-02 staging acceptance issued certificates for each declared canonical UI and alias, verified DNS and HTTPS over IPv4/IPv6, and -disposed the VM, DNS records, and child tokens. DNS mutation -fault-injection/recovery tests, outside-tailnet denial, and production -certificate acceptance remain pending. +disposed the VM, DNS records, and child tokens. CI tests inject an ambiguous +token-create response, a DNS-create failure after a partial reconciliation, +and a DNS-delete failure during cleanup; each retry converges without +duplicates or deleting unrelated records. Outside-tailnet denial and +production certificate acceptance remain pending. See [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md), [secret delivery](secrets.md), and [VM lifecycle](smoke-vms.md). diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 51c9d66f..554c0e5b 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -3,9 +3,9 @@ Status: shared UI declarations, aliases, environment lookup, Caddy payloads, Cloudflare account-token issuance, DNS reconciliation, and opt-in smoke VM cleanup are implemented and passed live staging ACME/HTTPS acceptance on -2026-10-02. Unit tests, Clippy, and routine CI pass. Remaining: API mutation -fault-injection tests, access denial from outside the tailnet, and production -ACME/renewal acceptance. +2026-10-02. Unit tests cover ambiguous token creation, retry after partial DNS +reconciliation, and retry after interrupted DNS cleanup. Remaining: access +denial from outside the tailnet and production ACME/renewal acceptance. ## Goal and fixed decisions @@ -216,7 +216,8 @@ Exit: new DNS paths and Zone ID validation are covered; issuance requires no manually populated VM ACME entry; cleanup works after token expiry; both environments use the same reconciler; a second apply creates no duplicate token or records; failures at each external mutation can resume or -clean up. Mutation fault-injection coverage remains outstanding. Concurrent deployments claiming the same host/environment names +clean up. The retry tests inject API failures after token creation, between DNS +record creates, and during DNS deletion. Concurrent deployments claiming the same host/environment names are refused. Do not append VM instance names to URLs silently; supporting parallel instances requires separately selected base domains/environments. diff --git a/ublue/skillet/crates/cli/src/cloudflare.rs b/ublue/skillet/crates/cli/src/cloudflare.rs index 6893fbd1..d8e6d284 100644 --- a/ublue/skillet/crates/cli/src/cloudflare.rs +++ b/ublue/skillet/crates/cli/src/cloudflare.rs @@ -224,6 +224,23 @@ impl Cloudflare { serde_json::from_value(value).context("decoding newly issued Cloudflare token") } + pub(crate) fn replace_named_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + // A previous create may have reached Cloudflare even when its response + // was lost. Deterministic names let the next invocation find and + // revoke that orphan before issuing a replacement. + for id in self.token_ids_by_name(creator_token, account_id, name)? { + self.revoke_token(creator_token, account_id, &id)?; + } + self.create_zone_token(creator_token, zone_id, account_id, name, lifetime) + } + pub(crate) fn token_ids_by_name( &self, creator_token: &str, diff --git a/ublue/skillet/crates/cli/src/cloudflare_tests.rs b/ublue/skillet/crates/cli/src/cloudflare_tests.rs index 72e23649..2c4b457f 100644 --- a/ublue/skillet/crates/cli/src/cloudflare_tests.rs +++ b/ublue/skillet/crates/cli/src/cloudflare_tests.rs @@ -208,6 +208,233 @@ fn lists_and_revokes_tokens_through_account_endpoints() { server.join().expect("server thread"); } +#[test] +fn named_token_retry_recovers_and_replaces_a_token_after_ambiguous_creation() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let mut tokens: Vec = Vec::new(); + let mut post_count = 0; + for _ in 0..7 { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + let line = request.lines().next().expect("request line"); + let (headers, body) = request.split_once("\r\n\r\n").expect("headers"); + assert!(headers + .to_ascii_lowercase() + .contains("authorization: bearer issuer-token")); + if line.starts_with("GET /client/v4/accounts/") && line.contains("/tokens?") { + send_json(&mut stream, 200, &json!({"result": tokens})); + } else if line.starts_with("GET /client/v4/accounts/") + && line.contains("/tokens/permission_groups?") + { + send_json( + &mut stream, + 200, + &json!({"result": [ + {"id":"zone-read-id", "name":"Zone Read", "scopes":["com.cloudflare.api.account.zone"], "is_selectable":true}, + {"id":"dns-write-id", "name":"DNS Write", "scopes":["com.cloudflare.api.account.zone"], "is_selectable":true} + ]}), + ); + } else if line.starts_with("DELETE /client/v4/accounts/") { + let id = line + .split_whitespace() + .nth(1) + .and_then(|path| path.split('/').next_back()) + .expect("token ID"); + tokens.retain(|token| token["id"] != id); + send_json(&mut stream, 200, &json!({"result": {}})); + } else if line.starts_with("POST /client/v4/accounts/") && line.contains("/tokens ") { + let payload: Value = serde_json::from_str(body).expect("token payload"); + post_count += 1; + let id = if post_count == 1 { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } else { + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }; + tokens.push(json!({"id":id, "name":payload["name"]})); + if post_count == 1 { + // The API committed the token, but the caller saw a failed + // response. This models the retry ambiguity. + send_json(&mut stream, 500, &json!({"errors":[{"code":1}]})); + } else { + send_json( + &mut stream, + 200, + &json!({"result":{"id":id, "value":"replacement-secret"}}), + ); + } + } else { + panic!("unexpected mock Cloudflare request: {line}"); + } + } + assert_eq!(post_count, 2); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0]["id"], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let account = "abcdef0123456789abcdef0123456789"; + let zone = "0123456789abcdef0123456789abcdef"; + let name = "skillet:production:clamps"; + + assert!(api + .replace_named_zone_token("issuer-token", zone, account, name, None) + .is_err()); + let retry = api + .replace_named_zone_token("issuer-token", zone, account, name, None) + .expect("retry replaces ambiguous first issuance"); + assert_eq!(retry.id, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + assert_eq!(retry.value, "replacement-secret"); + server.join().expect("server thread"); +} + +#[test] +fn dns_reconcile_retry_does_not_duplicate_a_record_after_lost_create_response() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let mut records = Vec::::new(); + let mut posts = 0; + for _ in 0..4 { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + let line = request.lines().next().expect("request line"); + if line.starts_with("GET /client/v4/zones/") { + send_json(&mut stream, 200, &json!({"result": records})); + } else if line.starts_with("POST /client/v4/zones/") { + let (_, body) = request.split_once("\r\n\r\n").expect("POST body"); + let payload: Value = serde_json::from_str(body).expect("record payload"); + posts += 1; + let id = format!("{posts:032x}"); + let record = json!({ + "id": id, + "name": payload["name"], + "type": payload["type"], + "content": payload["content"], + "comment": payload["comment"] + }); + records.push(record.clone()); + if posts == 2 { + send_json(&mut stream, 500, &json!({"errors":[{"code":1}]})); + } else { + send_json(&mut stream, 200, &json!({"result": record})); + } + } else { + panic!("unexpected mock Cloudflare request: {line}"); + } + } + assert_eq!(posts, 2); + assert_eq!(records.len(), 2); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let desired = [ + DesiredRecord { + name: "clamps.example.test".to_string(), + record_type: "A".to_string(), + content: "100.64.0.2".to_string(), + }, + DesiredRecord { + name: "syncthing.clamps.example.test".to_string(), + record_type: "CNAME".to_string(), + content: "clamps.example.test".to_string(), + }, + ]; + assert!(api + .reconcile_dns( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet:test:clamps:smoke", + "example.test", + &desired, + ) + .is_err()); + let reconciled = api + .reconcile_dns( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet:test:clamps:smoke", + "example.test", + &desired, + ) + .expect("retry adopts both previously created records"); + assert_eq!(reconciled.records.len(), 2); + server.join().expect("server thread"); +} + +#[test] +fn dns_cleanup_retry_finishes_after_a_partial_delete_failure() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let mut records = vec![ + json!({"id":"11111111111111111111111111111111", "name":"clamps.example.test", "type":"A", "content":"100.64.0.2", "comment":"skillet:test:clamps:smoke"}), + json!({"id":"22222222222222222222222222222222", "name":"syncthing.clamps.example.test", "type":"CNAME", "content":"clamps.example.test", "comment":"skillet:test:clamps:smoke"}), + json!({"id":"33333333333333333333333333333333", "name":"other.example.test", "type":"A", "content":"192.0.2.4", "comment":"other-owner"}), + ]; + let mut fail_once = true; + for _ in 0..5 { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + let line = request.lines().next().expect("request line"); + if line.starts_with("GET /client/v4/zones/") { + send_json(&mut stream, 200, &json!({"result": records})); + } else if line.starts_with("DELETE /client/v4/zones/") { + let id = line + .split_whitespace() + .nth(1) + .and_then(|path| path.split('/').next_back()) + .expect("record ID"); + if id.starts_with('2') && fail_once { + fail_once = false; + send_json(&mut stream, 500, &json!({"errors":[{"code":1}]})); + } else { + records.retain(|record| record["id"] != id); + send_json(&mut stream, 200, &json!({"result": {}})); + } + } else { + panic!("unexpected mock Cloudflare request: {line}"); + } + } + assert_eq!(records.len(), 1); + assert_eq!(records[0]["comment"], "other-owner"); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + assert!(api + .remove_dns_marker( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet:test:clamps:smoke", + "example.test", + ) + .is_err()); + api.remove_dns_marker( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet:test:clamps:smoke", + "example.test", + ) + .expect("retry removes remaining owned record"); + server.join().expect("server thread"); +} + +fn send_json(stream: &mut std::net::TcpStream, status: u16, value: &Value) { + let success = (200..300).contains(&status); + let body = json!({ + "success": success, + "errors": value.get("errors").cloned().unwrap_or_else(|| json!([])), + "messages": [], + "result": value.get("result").cloned().unwrap_or(Value::Null), + "result_info": value.get("result_info").cloned().unwrap_or(Value::Null) + }) + .to_string(); + let phrase = if success { + "OK" + } else { + "Internal Server Error" + }; + write!(stream, "HTTP/1.1 {status} {phrase}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("mock response"); +} + fn read_request(stream: &mut std::net::TcpStream) -> String { let mut request = Vec::new(); let mut chunk = [0_u8; 2048]; diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index a5dc46ec..03ceb990 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -192,13 +192,7 @@ fn host_acme_token( return Ok(token); } let token_name = format!("skillet:{environment}:{}", args.hostname); - // A previous request may have reached Cloudflare before its response was - // lost. With no vault credential to reuse, clean up only child tokens - // carrying this exact Skillet-owned name. - for orphan in api.token_ids_by_name(creator, account_id, &token_name)? { - api.revoke_token(creator, account_id, &orphan)?; - } - let issued = api.create_zone_token(creator, zone_id, account_id, &token_name, None)?; + let issued = api.replace_named_zone_token(creator, zone_id, account_id, &token_name, None)?; if let Err(error) = create_entry(&mut vault.database, &token_path, &issued.value) .and_then(|()| save_vault(vault, args.key_file.as_deref(), &token_path, &issued.value)) { From b6a26e6b16a25d6ce7c4d99b2a16d5c0e58f3664 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 17:08:39 +0200 Subject: [PATCH 117/252] test(caddy): probe non-tailnet UI denial in VM --- ublue/butane/ACCEPTANCE.md | 16 ++++++++++ ublue/design/cloudflare-ui-lifecycle.md | 7 +++-- ublue/plan/GENERIC-PRIVATE-UIS.md | 6 ++-- ublue/skillet/README.md | 5 +++- ublue/skillet/crates/caddy/src/lib.rs | 2 +- ublue/skillet/crates/caddy/src/tests.rs | 9 +++++- .../skillet/crates/cli/src/secret_delivery.rs | 30 +++++++++++++++++++ 7 files changed, 68 insertions(+), 7 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index dddbacf9..bf5f4e06 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -485,3 +485,19 @@ host tool; it still uses normal command approvals. - Focused Cloudflare tests pass (10 tests). `cargo fmt --all --check`, all 70 workspace tests, `cargo clippy --all-targets -- -D warnings`, and `skillet test run beezelbot --phase base --image fedora:latest` all pass. + +### Non-tailnet Caddy denial probe, 2026-10-02 + +- Caddy now returns a distinctive 403 body for requests outside the Tailscale + IPv4/IPv6 ranges. Opt-in `test vm provision --with-ui` probes each declared + canonical name and alias from the guest's loopback address, using the UI + hostname for TLS SNI and the HTTP Host header. +- The user completed live `test vm provision clamps generic-ui --with-ui` on + the disposable VM. Provisioning returned successfully, which means the + loopback probe received the exact configured 403 for every declared UI name + and alias. Follow-up inspection found the Caddy container running and three + successful certificate issuance events in its logs. +- The Caddy renderer test, workspace suite, Clippy, and routine integration + scenario pass. This loopback check does not replace a request from an + independent network outside the VM. The disposable VM remains running for + manual inspection. diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index 154a0823..f979b42c 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -116,8 +116,11 @@ declared canonical UI and alias, verified DNS and HTTPS over IPv4/IPv6, and disposed the VM, DNS records, and child tokens. CI tests inject an ambiguous token-create response, a DNS-create failure after a partial reconciliation, and a DNS-delete failure during cleanup; each retry converges without -duplicates or deleting unrelated records. Outside-tailnet denial and -production certificate acceptance remain pending. +duplicates or deleting unrelated records. Opt-in smoke provisioning also +requests each configured UI from the guest's loopback source and requires +Caddy's explicit 403 response. This verifies the source-address matcher for a +non-tailnet source; denial from a separate external network and production +certificate acceptance remain pending. See [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md), [secret delivery](secrets.md), and [VM lifecycle](smoke-vms.md). diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 554c0e5b..44f0f0f1 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -4,8 +4,10 @@ Status: shared UI declarations, aliases, environment lookup, Caddy payloads, Cloudflare account-token issuance, DNS reconciliation, and opt-in smoke VM cleanup are implemented and passed live staging ACME/HTTPS acceptance on 2026-10-02. Unit tests cover ambiguous token creation, retry after partial DNS -reconciliation, and retry after interrupted DNS cleanup. Remaining: access -denial from outside the tailnet and production ACME/renewal acceptance. +reconciliation, and retry after interrupted DNS cleanup. The opt-in UI VM +provision path now probes each UI from the guest's loopback source and checks +Caddy's explicit 403 response. Remaining: denial from a separate network +outside the tailnet and production ACME/renewal acceptance. ## Goal and fixed decisions diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index f4948d89..e655638f 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -272,7 +272,10 @@ requires `curl` on the workstation. See the activate Caddy with the test ACME staging issuer. Dispose of the VM with `cargo run --release -p skillet -- test vm destroy clamps smoke`. If Tailscale cleanup fails, destruction stops and the VM metadata remains for - retry. Keep the OAuth client available for cleanup. + retry. UI provisioning also probes each configured hostname from the VM's + loopback address and requires Caddy's explicit 403 denial response. This + checks the source-address rule; it does not replace a test from a separate + network outside the tailnet. Keep the OAuth client available for cleanup. 6. For production, deliver the Pi-hole credential first, then Tailscale: ```bash diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index 026bbabd..20ace32f 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -180,7 +180,7 @@ impl CaddySites { for hostname in std::iter::once(&service.hostname).chain(&service.aliases) { let _ = writeln!( config, - "\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet 403\n reverse_proxy {}:{}\n}}", + "\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet \"Access denied by Skillet tailnet policy\" 403\n reverse_proxy {}:{}\n}}", hostname, service.upstream, service.port ); } diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index d21acfd9..d2fa878a 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -124,7 +124,14 @@ fn renders_each_declared_service_with_tailnet_filter_and_container_dns() { assert!(rendered.contains("syncthing.clamps.private.example.invalid")); assert!(rendered.contains("reverse_proxy pihole:8088")); assert!(rendered.contains("reverse_proxy syncthing:8384")); - assert_eq!(rendered.matches("respond @outside_tailnet 403").count(), 2); + assert!(rendered + .contains("respond @outside_tailnet \"Access denied by Skillet tailnet policy\" 403")); + assert_eq!( + rendered + .matches("respond @outside_tailnet \"Access denied by Skillet tailnet policy\" 403") + .count(), + 2 + ); assert!(rendered.contains("https://acme-staging-v02.api.letsencrypt.org/directory")); } diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 03ceb990..3dbc827a 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -521,6 +521,7 @@ fn provision_vm_ui( &issued.value, )?; ssh.run("sudo -n systemctl start skillet-caddy-apply.service")?; + verify_caddy_denies_non_tailnet_probe(ssh, &sites)?; for old_id in api.token_ids_by_name(&creator, &account_id, &ownership.token_name)? { if old_id != issued.id { api.revoke_token(&creator, &account_id, &old_id)?; @@ -534,6 +535,35 @@ fn provision_vm_ui( Ok(()) } +fn verify_caddy_denies_non_tailnet_probe( + ssh: &mut VmSsh<'_>, + sites: &skillet_caddy::CaddySites, +) -> Result<()> { + for site in &sites.services { + for hostname in std::iter::once(&site.hostname).chain(&site.aliases) { + let probe = format!( + "curl --insecure --silent --show-error --max-time 8 --resolve '{hostname}:443:127.0.0.1' --write-out '\n%{{http_code}}' 'https://{hostname}/'" + ); + let mut denied = false; + for _ in 0..30 { + let output = ssh.output(&probe)?; + let response = String::from_utf8_lossy(&output.stdout); + if output.status.success() + && response.trim() == "Access denied by Skillet tailnet policy\n403" + { + denied = true; + break; + } + std::thread::sleep(std::time::Duration::from_secs(2)); + } + if !denied { + return Err(anyhow!("Caddy did not return its explicit access-denied response to a non-tailnet probe")); + } + } + } + Ok(()) +} + fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<()> { let bytes = fs::read(metadata_path).context("reading Cloudflare VM ownership metadata")?; let ownership: CloudflareVmOwnership = From 404c92caf7c17d5885179a28bd0606b5eab366bd Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 17:38:26 +0200 Subject: [PATCH 118/252] docs(pihole): record smoke DNS checks --- ublue/butane/ACCEPTANCE.md | 18 ++++++++++++++++++ ublue/plan/CLAMPS-MIGRATION.md | 23 ++++++++++++----------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index bf5f4e06..9708ecde 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -501,3 +501,21 @@ host tool; it still uses normal command approvals. scenario pass. This loopback check does not replace a request from an independent network outside the VM. The disposable VM remains running for manual inspection. + +### Pi-hole DNS behavior on smoke VM, 2026-10-02 + +- On the running `clamps-test-smoke` VM, Pi-hole was active and published DNS + listeners on wildcard IPv4 and IPv6 addresses. Queries for a public test + name succeeded over UDP and TCP through the VM's IPv4 and IPv6 interface + addresses. +- A temporary Pi-hole v6 `dns.hosts` record using the documented + `"IP HOSTNAME"` format resolved over both transports and address families. + The original empty custom-host setting was restored and verified afterward. + The unauthenticated admin page redirected to login (302), and the unauthenticated + API auth endpoint returned 401. +- This VM uses libvirt's user-mode `passt` network with only SSH forwarded to + the workstation. Its active firewalld zone has no explicit DNS service or + port allowance. Guest-local queries do not establish client ingress, so LAN + reachability and host-firewall behavior remain unverified; validate them on + a bridged/physical clamps network before cutover. No production DNS records + were supplied or changed. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 1c30c22d..601eadb7 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -17,15 +17,16 @@ Pi-hole container runs in the VM with data and Podman storage under `/var/lib/data`; service networking, live ACME, final disk selection, and post-provision rebase checks remain open. -Next: implement Cloudflare token/DNS lifecycle and automatic smoke VM delivery -from [generic private UI provisioning](GENERIC-PRIVATE-UIS.md), then validate -DNS-01 staging for private Pi-hole and Syncthing UIs. -The private-UI design requires a tailnet-only -listener and DNS-only Cloudflare records pointing to tailnet addresses, so -certificate issuance alone is not the acceptance target. -Pi-hole LAN-client reachability and final custom DNS records also remain open; -production DNS stays on rupik. Syncthing passed the named disposable-VM service -and repeat-apply checks, and the [btrbk milestone](CLAMPS-BTRBK.md) is complete. +Next: complete Pi-hole LAN acceptance and configure the actual custom DNS +records once their values are supplied. On 2026-10-02, the running smoke VM +passed IPv4/IPv6 UDP/TCP DNS queries and a reversible custom-record test. +Its libvirt user-mode network forwards only SSH, so client ingress and firewall +behavior remain unverified; use a bridged/physical clamps network for that +check. Keep production DNS on rupik. Generic private UI staging acceptance is +recorded in [ACCEPTANCE.md](../butane/ACCEPTANCE.md); two deferred UI checks are +tracked in [generic private UI provisioning](GENERIC-PRIVATE-UIS.md). +Syncthing passed the named disposable-VM service and repeat-apply checks, and +the [btrbk milestone](CLAMPS-BTRBK.md) is complete. See the [private UI design](../design/private-ui-access.md), [Syncthing design](../design/syncthing.md), [Pi-hole network design](../design/pihole-network.md), [storage design](../design/storage.md), [secret design](../design/secrets.md), @@ -79,8 +80,8 @@ Done when: Pi-hole's storage and password can be provisioned, recovered after in - Implemented the reusable Quadlet network resource. Pi-hole now has a dual-stack bridge, network DNS enabled, container name `pihole`, and registry auto-update; see the [network design](../design/pihole-network.md). - VM apply found Aardvark DNS colliding with Pi-hole's host port 53. Skillet now configures rootful Aardvark DNS to listen on port 54. On the named uCore VM, Pi-hole starts, Aardvark resolves its dual-stack container addresses from another container, an HTTP request reaches `http://pihole:8088/admin/`, and Pi-hole answers DNS over UDP and TCP through host IPv4 and IPv6 addresses. `ss` confirms host publication on IPv4 and IPv6 for both transports. - Replace the Chef web pod with separate containers on the shared bridge. Caddy can resolve `pihole` and proxy to port 8088 without static container IPs or a host-published admin port. -- The Chef DNS listener behavior is represented as wildcard IPv4/IPv6 TCP/UDP port 53 publication. Verify the test VM's host firewall and actual client reachability before considering this item accepted. -- Pi-hole password-file consumption and startup identity/permissions passed on the VM. Verify custom-record format for the selected image version and DNS reachability from a LAN client through the host firewall. +- The Chef DNS listener behavior is represented as wildcard IPv4/IPv6 TCP/UDP port 53 publication. The running smoke VM answers queries on its IPv4/IPv6 interface addresses, but its `passt` user-mode network only forwards SSH; verify ingress from a real LAN client through the host firewall before accepting LAN reachability. +- Pi-hole password-file consumption and startup identity/permissions passed on the VM. Pi-hole v6 accepted and served a temporary `dns.hosts` custom record; the setting was restored empty. Configure actual custom records only after their values are supplied. - Replace placeholder DNS records with supplied configuration. Support multiple names per address. - Keep host bootstrap DNS independent of Pi-hole availability. Verify IPv4/IPv6 TCP and UDP DNS, web authentication, persistence, rotation, repeated apply and reboot. - Decide whether Nebula Sync is required and which instance is authoritative before enabling it. From d3a96578b3fac5a7686c2ca91c58ef6cad747766 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 2 Oct 2026 19:12:14 +0200 Subject: [PATCH 119/252] docs(plan): group deferred network acceptance checks --- ublue/plan/CLAMPS-MIGRATION.md | 17 +++++++++++++++++ ublue/plan/GENERIC-PRIVATE-UIS.md | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 601eadb7..ae77570d 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -34,6 +34,23 @@ design](../design/syncthing.md), [Pi-hole network design](../design/pihole-netwo [storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The final physical disk remains open. +## Deferred live acceptance batch + +Run these together when a suitable client network and the required production +configuration are available; record each result in +[ACCEPTANCE.md](../butane/ACCEPTANCE.md): + +1. Pi-hole: query from a real LAN client over IPv4/IPv6 and UDP/TCP, confirming + host-firewall behavior. Use isolated test records; add actual custom DNS + records only after their values are supplied. +2. Private UIs: verify a request from outside the tailnet gets Caddy's denial + response. Workstation loopback is not an outside-tailnet source. +3. Cloudflare production: accept production certificate issuance and a bounded + renewal scenario using the production environment and its existing DNS. + +The smoke VM uses libvirt user-mode networking and cannot satisfy item 1; use a +bridged test VM or the physical clamps host on an isolated/test network. + Planned before physical cutover: [TPM-encrypted Btrfs root](TPM-ENCRYPTED-ROOT.md), including QEMU TPM2 tests, recovery and stock-SSD hardware acceptance. Tang is deferred; remote hosts will first be assessed for usable TPM support. diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 44f0f0f1..501de1fe 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -7,7 +7,8 @@ cleanup are implemented and passed live staging ACME/HTTPS acceptance on reconciliation, and retry after interrupted DNS cleanup. The opt-in UI VM provision path now probes each UI from the guest's loopback source and checks Caddy's explicit 403 response. Remaining: denial from a separate network -outside the tailnet and production ACME/renewal acceptance. +outside the tailnet and production ACME/renewal acceptance; these are grouped +with Pi-hole LAN DNS acceptance in the [deferred live acceptance batch](CLAMPS-MIGRATION.md#deferred-live-acceptance-batch). ## Goal and fixed decisions @@ -223,7 +224,7 @@ record creates, and during DNS deletion. Concurrent deployments claiming the sam are refused. Do not append VM instance names to URLs silently; supporting parallel instances requires separately selected base domains/environments. -### 4. Wire VM lifecycle and verify live ACME (wiring implemented; live check pending) +### 4. Wire VM lifecycle and verify live ACME (staging verified; two checks deferred) `test vm provision --with-ui` explicitly opts into Cloudflare and Caddy. Standard fixture smoke remains offline from Cloudflare. Provisioning From 37fc526e70652696eb82c679d3eb989787e05b41 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 00:05:06 +0200 Subject: [PATCH 120/252] feat(unifi): add clamps controller container --- ublue/butane/ACCEPTANCE.md | 29 +++++ ublue/butane/bin/rupik-unifi-inventory.sh | 19 +++ ublue/design/unifi.md | 21 ++++ ublue/plan/CLAMPS-MIGRATION.md | 2 +- ublue/plan/CLAMPS-UNIFI.md | 68 +++++++++++ ublue/skillet/Cargo.lock | 12 ++ ublue/skillet/Cargo.toml | 2 + ublue/skillet/crates/cli-common/Cargo.toml | 1 + ublue/skillet/crates/cli-common/src/hosts.rs | 3 + ublue/skillet/crates/unifi/Cargo.toml | 17 +++ ublue/skillet/crates/unifi/src/lib.rs | 117 +++++++++++++++++++ 11 files changed, 290 insertions(+), 1 deletion(-) create mode 100755 ublue/butane/bin/rupik-unifi-inventory.sh create mode 100644 ublue/design/unifi.md create mode 100644 ublue/plan/CLAMPS-UNIFI.md create mode 100644 ublue/skillet/crates/unifi/Cargo.toml create mode 100644 ublue/skillet/crates/unifi/src/lib.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 9708ecde..c0eaaf14 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -519,3 +519,32 @@ host tool; it still uses normal command approvals. reachability and host-firewall behavior remain unverified; validate them on a bridged/physical clamps network before cutover. No production DNS records were supplied or changed. + +### UniFi rupik source inventory, 2026-10-02 + +- The sanitized inventory log is `/tmp/rupik.log` on the workstation. Rupik is + ARM64 and runs `jacobalberty/unifi:latest`, package `10.0.162-32076-1`, in a + rootful host-network container as image user `unifi` (UID/GID 999). Podman + reports host UID 999 as `systemd-coredump`; Chef separately creates host + `unifi` UID/GID 2666. The data tree is owned by 999:999, mode 0750, and uses + 1.2 GiB. `/srv` is Btrfs; the image's `/unifi/run` is a separate generated + Podman volume. +- Observed listeners: TCP 8080, 8443, 6789, 8843, 8880; UDP 3478, 10001. HTTPS + on local 8443 returned 302. No UniFi nginx route appears in the Chef repo. + Eight small monthly `.unf` backup files were listed through October 2026; + validity and restore compatibility are unverified. Clamps is x86_64, so a + backup restore from the ARM64 controller must pass on an isolated VM before + any production migration. The full private controller settings were not + included in this log. + +### UniFi Skillet implementation, 2026-10-02 + +- Added reusable `skillet_unifi` support and enabled it from clamps full + apply. It requires `/var/lib/data`, creates `/var/lib/data/unifi` as a Btrfs + subvolume, mounts it at `/unifi`, and configures rootful Podman with mandatory + host networking, the Chef image family, `User=unifi`, `TZ=Europe/Madrid`, + restart-at-boot, and registry auto-update. +- Skillet updates only the subvolume root to host names corresponding to + UID/GID 999; it does not recursively alter existing controller data. No + container runtime or backup-restore acceptance has been run yet. Those checks + remain pending in [the UniFi plan](../plan/CLAMPS-UNIFI.md). diff --git a/ublue/butane/bin/rupik-unifi-inventory.sh b/ublue/butane/bin/rupik-unifi-inventory.sh new file mode 100755 index 00000000..eb3400be --- /dev/null +++ b/ublue/butane/bin/rupik-unifi-inventory.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +log="$HOME/rupik-unifi-inventory-$(date -u +%Y%m%dT%H%M%SZ).log" +exec > >(tee "$log") 2>&1 + +printf 'UniFi inventory log: %s\n' "$log" +uname -m +sudo podman inspect unifi --format 'image={{.ImageName}} image-id={{.Image}} user={{.Config.User}} network={{.HostConfig.NetworkMode}} state={{.State.Status}}' +sudo podman inspect unifi --format '{{range .Mounts}}{{printf "mount %s -> %s writable=%t\n" .Source .Destination .RW}}{{end}}' +sudo podman top unifi user huser pid +sudo podman exec unifi dpkg-query -W -f='UniFi package version=${Version}\n' unifi +getent passwd unifi +getent group unifi +sudo stat -c '%u:%g mode=%a %n' /srv/unifi /srv/unifi/data /srv/unifi/data/logs +findmnt -T /srv/unifi -o TARGET,SOURCE,FSTYPE +sudo du -sh /srv/unifi /srv/unifi/data /srv/unifi/data/logs +sudo ss -lntup | grep -E ':(8080|8443|3478|10001|8843|8880|6789)([[:space:]]|$)' +curl -k --silent --output /dev/null --max-time 5 --write-out 'HTTPS 8443 status=%{http_code}\n' https://127.0.0.1:8443/ +sudo find /srv/unifi -type f -iname '*.unf' -printf '%TY-%Tm-%Td %s bytes\n' diff --git a/ublue/design/unifi.md b/ublue/design/unifi.md new file mode 100644 index 00000000..17171357 --- /dev/null +++ b/ublue/design/unifi.md @@ -0,0 +1,21 @@ +# UniFi controller + +Run the self-hosted UniFi Network application as a rootful Podman container +with host networking, required for device discovery/adoption, and persistent +data under `/var/lib/data/unifi`. Preserve the current controller state when +migrating; ordinary Skillet apply must not overwrite or import a database. +The container runs as image user `unifi` (UID/GID 999). Skillet sets only the +data subvolume root to those IDs, resolving local account names at runtime, and +preserves ownership of existing descendants. + +Use UniFi Site Manager at `unifi.ui.com` for remote access. The cloud service +brokers access to the local controller; configuration and device management +remain on the server. Do not add UniFi to Caddy's private UI routes. Keep the +controller's local HTTPS management endpoint available on the host network. + +Rupik is ARM64 and runs UniFi 10.0.162; clamps is x86_64. Validate a supported +application backup restore across architectures in an isolated VM before +production migration. Retain the old controller and an independent backup until +clamps manages the devices successfully. + +See the [implementation plan](../plan/CLAMPS-UNIFI.md). diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index ae77570d..7783362c 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -113,7 +113,7 @@ Each row is a separate implementation and validation step. Adjust order for depe | --- | --- | --- | | 1 | Syncthing (implemented; disposable VM service and repeat-apply checks passed) | Data, device identity, folder configuration, UID/GID, required ports | | 2 | btrbk (implemented and verified) | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | -| 3 | UniFi | Controller data or supported backup restore, version compatibility, adoption and ownership | +| 3 | UniFi (implementation added; acceptance pending) | Follow the [UniFi port plan](CLAMPS-UNIFI.md): rootful host-network container for discovery/adoption, persistent data, and supported backup restore/version compatibility before cutover. No Caddy route. | | 4 | Tailscale (implemented; smoke enrollment and cleanup accepted 2026-09-30) | Host-network container, persistent state, KeePassXC OAuth client, one-use tagged key delivery, and smoke device cleanup are implemented. Production forwarding remains to validate. UI names use Cloudflare DNS-only records; Tailscale DNS management is not required. | | 5 | Private UI access with Caddy + ACME (implementation started) | Caddy's credential-gated Quadlet config proxies the Pi-hole and Syncthing UIs by bridge DNS name; Syncthing's host-published GUI port is removed. Next wire smoke-specific KeePassXC values, mint per-VM zone-scoped tokens and manage DNS-only A/AAAA records through the workstation Cloudflare token creator. Validate tailnet-only access, ACME staging, certificate persistence/renewal, record/token cleanup, repeated runs and interruption recovery. | | 6 | Cloudflare DDNS | Required records and token delivery; reconcile the legacy updater before enabling competing writers | diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md new file mode 100644 index 00000000..5a3fcfbd --- /dev/null +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -0,0 +1,68 @@ +# UniFi container on clamps + +Status: initial Skillet implementation added, 2026-10-02. Disposable VM +acceptance, backup restore, and production migration remain pending. + +## Current behavior + +Chef recipe: `site-cookbooks/rupik/recipes/unifi.rb`. + +- Rootful Podman, `Network=host`, image `docker.io/jacobalberty/unifi:latest`, + `User=unifi`, `TZ=Europe/Madrid`, `Restart=always`, network-online ordering, + and registry auto-update. +- Rupik is ARM64; the running image package is + `10.0.162-32076-1`. Clamps is x86_64. +- `/srv/unifi` is bind-mounted to `/unifi`, currently 1.2 GiB on Btrfs. Data + files are owned by 999:999. The image user is UID/GID 999; Chef's host + account `unifi` UID/GID 2666 is not the data owner. `/unifi/run` is a + separate generated Podman volume. +- Active listeners: TCP 8080, 8443, 6789, 8843, 8880; UDP 3478, 10001. Local + HTTPS on 8443 returned 302. The Chef repo has no UniFi nginx route. +- Eight small `.unf` backups were listed through October 2026. Restore validity + and off-host availability are unverified. + +## Scope and implementation + +Implemented: reusable `skillet-unifi` module, called by clamps full apply. It +uses rootful Podman with mandatory host networking for device discovery and +adoption, `/var/lib/data/unifi` mounted at `/unifi`, the Chef image family, +`Europe/Madrid`, restart-at-boot, and registry auto-update. It requires the +shared data mount and prepares the service subvolume. The image's `unifi` user +owns the application state as UID/GID 999; Skillet resolves host names for +those numeric IDs and updates only the subvolume root metadata, preserving +existing application-owned files. On rupik UID 999 is `systemd-coredump`, not +Chef's separate UID 2666 `unifi` account. + +Do not add a Caddy route or a new credential flow for UniFi. UniFi Site Manager +at `unifi.ui.com` provides remote access to a locally running controller. The +control plane, configuration, and device management remain on the server; local +management remains available on the host network. Remote access must be enabled +for the controller's UI account. + +## Migration and acceptance + +1. Test the Quadlet/module on a named disposable x86_64 VM with an + empty controller: service starts, remains active, uses intended ownership, + has `Network=host` for discovery/adoption, and survives repeated apply, + restart, and reboot. Confirm the expected host listeners and that no + Caddy/Cloudflare setup is needed for UniFi. +2. Test restoring one private rupik `.unf` backup into an isolated x86_64 VM + using a compatible application version. Keep it disconnected from production + devices. Confirm the restored site/configuration and remote Site Manager + access without publishing backup contents or credentials. +3. Before cutover, restore the selected backup on clamps, verify remote access + and device connectivity, and retain rupik plus an independent backup for + rollback. Do not run two controllers against the same devices concurrently. + +Add routine CI coverage for caller opt-in, Quadlet rendering, data-mount +dependency, and idempotent state. This coverage and the disposable VM runtime +acceptance are pending. Record commands and results in `butane/ACCEPTANCE.md`. +Device migration stays unverified until a backup restore or deliberate +re-adoption succeeds. + +Read `skillet/AGENTS.md`, `design/storage.md`, `design/secrets.md`, and +`design/smoke-vms.md` before implementation. + +References: [UniFi cloud architecture](https://help.ui.com/hc/en-us/articles/30319146145175-Understanding-UniFi-Cloud-Architecture), +[remote access](https://help.ui.com/hc/en-us/articles/360012192813-Introduction-to-UniFi), +[local management](https://help.ui.com/hc/en-us/articles/28457353760919-UniFi-Local-Management). diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index ea320422..de5df36b 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2349,6 +2349,7 @@ dependencies = [ "skillet_pihole", "skillet_podman", "skillet_syncthing", + "skillet_unifi", "tempfile", "thiserror 1.0.69", "tracing", @@ -2420,6 +2421,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "skillet_unifi" +version = "0.1.0" +dependencies = [ + "skillet_core", + "skillet_podman", + "thiserror 1.0.69", + "tracing", + "users", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index a6da088d..88e0a5dc 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/core", "crates/hardening", "crates/pihole", + "crates/unifi", "crates/syncthing", "crates/btrbk", "crates/caddy", @@ -18,6 +19,7 @@ members = [ skillet_core = { path = "crates/core" } skillet_hardening = { path = "crates/hardening" } skillet_pihole = { path = "crates/pihole" } +skillet_unifi = { path = "crates/unifi" } skillet_syncthing = { path = "crates/syncthing" } skillet_btrbk = { path = "crates/btrbk" } skillet_caddy = { path = "crates/caddy" } diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index 3dcd19e8..1fb4c593 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -10,6 +10,7 @@ workspace = true skillet_core.workspace = true skillet_hardening.workspace = true skillet_pihole.workspace = true +skillet_unifi.workspace = true skillet_syncthing.workspace = true skillet_btrbk.workspace = true skillet_caddy.workspace = true diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index bfacd6fd..8247a5d8 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -35,6 +35,8 @@ pub enum ApplyError { Hardening(String), #[error("Pihole apply error: {0}")] Pihole(#[from] skillet_pihole::PiholeError), + #[error("UniFi apply error: {0}")] + Unifi(#[from] skillet_unifi::UnifiError), #[error("Syncthing apply error: {0}")] Syncthing(#[from] skillet_syncthing::SyncthingError), #[error("Btrbk apply error: {0}")] @@ -300,6 +302,7 @@ pub fn apply_clamps( )?; apply_syncthing(system, files, host_service_network("clamps"))?; + skillet_unifi::apply(system, files)?; skillet_btrbk::apply( system, files, diff --git a/ublue/skillet/crates/unifi/Cargo.toml b/ublue/skillet/crates/unifi/Cargo.toml new file mode 100644 index 00000000..ad7c0a32 --- /dev/null +++ b/ublue/skillet/crates/unifi/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skillet_unifi" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +thiserror.workspace = true +tracing.workspace = true +users.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs new file mode 100644 index 00000000..160f5a99 --- /dev/null +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -0,0 +1,117 @@ +use skillet_core::files::{FileError, FileResource}; +use skillet_core::system::{SystemError, SystemResource}; +use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, Volume}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; +use tracing::info; +use users::{get_group_by_gid, get_user_by_uid}; + +const DATA_PATH: &str = "/var/lib/data/unifi"; +const CONTAINER_UID: u32 = 999; +const CONTAINER_GID: u32 = 999; + +#[derive(Debug, Error)] +pub enum UnifiError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), + #[error("host has no account for UniFi container UID {CONTAINER_UID}")] + HostUidMissing, + #[error("host has no group for UniFi container GID {CONTAINER_GID}")] + HostGidMissing, +} + +/// Configure the rootful `UniFi Network` container and its persistent state. +/// +/// The rootful Podman service uses host networking so `UniFi` can discover and +/// adopt devices on the LAN. The image's `unifi` user writes as UID/GID 999; +/// match the volume root to those numeric identities without recursively +/// changing existing application data. +pub fn apply(system: &S, files: &F) -> Result<(), UnifiError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + let owner = get_user_by_uid(CONTAINER_UID).ok_or(UnifiError::HostUidMissing)?; + let group = get_group_by_gid(CONTAINER_GID).ok_or(UnifiError::HostGidMissing)?; + apply_with_owners( + system, + files, + &owner.name().to_string_lossy(), + &group.name().to_string_lossy(), + ) +} + +fn apply_with_owners( + system: &S, + files: &F, + owner: &str, + group: &str, +) -> Result<(), UnifiError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Applying UniFi Network container..."); + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + files.ensure_btrfs_subvolume(Path::new(DATA_PATH))?; + files.ensure_directory(Path::new(DATA_PATH), Some(0o750), Some(owner), Some(group))?; + + let mut extra_config = BTreeMap::new(); + extra_config.insert( + "Container".to_string(), + vec![ + "AutoUpdate=registry".to_string(), + "ContainerName=unifi".to_string(), + "Environment=TZ=Europe/Madrid".to_string(), + "Network=host".to_string(), + "User=unifi".to_string(), + ], + ); + extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); + extra_config.insert( + "Unit".to_string(), + vec![ + "Description=UniFi Network application".to_string(), + "After=network-online.target".to_string(), + "Wants=network-online.target".to_string(), + "Requires=skillet-data-prepare.service".to_string(), + "After=skillet-data-prepare.service".to_string(), + "BindsTo=var-lib-data.mount".to_string(), + "After=var-lib-data.mount".to_string(), + "AssertPathIsMountPoint=/var/lib/data".to_string(), + ], + ); + extra_config.insert( + "Install".to_string(), + vec!["WantedBy=multi-user.target default.target".to_string()], + ); + + skillet_podman::container( + system, + files, + PodmanConfig { + name: "unifi".to_string(), + image: "docker.io/jacobalberty/unifi:latest".to_string(), + networks: Vec::new(), + user: ContainerUser { + container_uid: 0, + container_gid: 0, + host_user: None, + }, + create_host_user: false, + volumes: vec![Volume { + host_path: DATA_PATH.to_string(), + container_path: "/unifi".to_string(), + options: Some("Z".to_string()), + }], + secrets: Vec::new(), + config_revisions: Vec::new(), + extra_config, + }, + )?; + Ok(()) +} From 6765ecb052ab834fab97018f2b2ae79bee806cf5 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 10:43:48 +0200 Subject: [PATCH 121/252] fix(unifi): support numeric container ownership --- ublue/butane/ACCEPTANCE.md | 10 ++-- ublue/design/unifi.md | 4 +- ublue/plan/CLAMPS-UNIFI.md | 8 +-- ublue/skillet/Cargo.lock | 1 - ublue/skillet/crates/core/src/files.rs | 27 +++++++++ ublue/skillet/crates/core/src/files/tests.rs | 23 ++++++++ ublue/skillet/crates/core/src/recorder.rs | 17 ++++++ ublue/skillet/crates/core/src/resource_op.rs | 6 ++ ublue/skillet/crates/core/src/test_utils.rs | 23 ++++++++ ublue/skillet/crates/unifi/Cargo.toml | 1 - ublue/skillet/crates/unifi/src/lib.rs | 38 ++++--------- ublue/skillet/crates/unifi/src/tests.rs | 59 ++++++++++++++++++++ 12 files changed, 178 insertions(+), 39 deletions(-) create mode 100644 ublue/skillet/crates/unifi/src/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index c0eaaf14..cd29a1e2 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -544,7 +544,9 @@ host tool; it still uses normal command approvals. subvolume, mounts it at `/unifi`, and configures rootful Podman with mandatory host networking, the Chef image family, `User=unifi`, `TZ=Europe/Madrid`, restart-at-boot, and registry auto-update. -- Skillet updates only the subvolume root to host names corresponding to - UID/GID 999; it does not recursively alter existing controller data. No - container runtime or backup-restore acceptance has been run yet. Those checks - remain pending in [the UniFi plan](../plan/CLAMPS-UNIFI.md). +- Skillet updates only the subvolume root to numeric UID/GID 999; it does not + recursively alter existing controller data and does not require matching + host account names. The first disposable VM apply exposed that clamps uCore + has no passwd entry for UID 999. The numeric ownership handling and its + regression tests are now implemented; the full-apply retry and backup + restore acceptance remain pending in [the UniFi plan](../plan/CLAMPS-UNIFI.md). diff --git a/ublue/design/unifi.md b/ublue/design/unifi.md index 17171357..ce003346 100644 --- a/ublue/design/unifi.md +++ b/ublue/design/unifi.md @@ -5,8 +5,8 @@ with host networking, required for device discovery/adoption, and persistent data under `/var/lib/data/unifi`. Preserve the current controller state when migrating; ordinary Skillet apply must not overwrite or import a database. The container runs as image user `unifi` (UID/GID 999). Skillet sets only the -data subvolume root to those IDs, resolving local account names at runtime, and -preserves ownership of existing descendants. +data subvolume root to those numeric IDs and preserves ownership of existing +descendants; the host does not need matching account names. Use UniFi Site Manager at `unifi.ui.com` for remote access. The cloud service brokers access to the local controller; configuration and device management diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md index 5a3fcfbd..f2b22ef2 100644 --- a/ublue/plan/CLAMPS-UNIFI.md +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -28,10 +28,10 @@ uses rootful Podman with mandatory host networking for device discovery and adoption, `/var/lib/data/unifi` mounted at `/unifi`, the Chef image family, `Europe/Madrid`, restart-at-boot, and registry auto-update. It requires the shared data mount and prepares the service subvolume. The image's `unifi` user -owns the application state as UID/GID 999; Skillet resolves host names for -those numeric IDs and updates only the subvolume root metadata, preserving -existing application-owned files. On rupik UID 999 is `systemd-coredump`, not -Chef's separate UID 2666 `unifi` account. +owns the application state as UID/GID 999; Skillet sets those numeric IDs on +only the subvolume root and preserves existing application-owned files. The +host does not need matching account names. Rupik UID 999 is +`systemd-coredump`, not Chef's separate UID 2666 `unifi` account. Do not add a Caddy route or a new credential flow for UniFi. UniFi Site Manager at `unifi.ui.com` provides remote access to a locally running controller. The diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index de5df36b..39cbe175 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2429,7 +2429,6 @@ dependencies = [ "skillet_podman", "thiserror 1.0.69", "tracing", - "users", ] [[package]] diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 94f1047a..6752eb12 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -63,6 +63,13 @@ pub trait FileResource { owner: Option<&str>, group: Option<&str>, ) -> Result; + fn ensure_directory_with_owner_ids( + &self, + path: &Path, + mode: Option, + uid: u32, + gid: u32, + ) -> Result; fn delete_file(&self, path: &Path) -> Result; fn require_btrfs_subvolume_mount( &self, @@ -383,6 +390,26 @@ impl FileResource for LocalFileResource { Ok(changed) } + fn ensure_directory_with_owner_ids( + &self, + path: &Path, + mode: Option, + uid: u32, + gid: u32, + ) -> Result { + let mut changed = self.ensure_directory(path, mode, None, None)?; + let metadata = fs::metadata(path) + .map_err(|error| FileError::Read(path.display().to_string(), error))?; + if metadata.uid() != uid || metadata.gid() != gid { + chown(path, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid))).map_err(|error| { + FileError::SetOwnership(path.display().to_string(), error.to_string()) + })?; + changed = true; + info!("Updated directory numeric ownership for {}", path.display()); + } + Ok(changed) + } + fn delete_file(&self, path: &Path) -> Result { if path.exists() { let metadata = fs::symlink_metadata(path) diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs index d9821872..22f26134 100644 --- a/ublue/skillet/crates/core/src/files/tests.rs +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -153,6 +153,29 @@ fn test_ensure_directory_creates_dir() { assert!(sub_dir.is_dir()); } +#[test] +fn directory_numeric_owner_is_idempotent() { + use std::os::unix::fs::MetadataExt; + + let dir = tempdir().unwrap(); + let path = dir.path().join("numeric-owner"); + let parent = fs::metadata(dir.path()).unwrap(); + let resource = LocalFileResource::new(); + + assert!(resource + .ensure_directory_with_owner_ids(&path, Some(0o750), parent.uid(), parent.gid()) + .unwrap()); + assert!(!resource + .ensure_directory_with_owner_ids(&path, Some(0o750), parent.uid(), parent.gid()) + .unwrap()); + + let metadata = fs::metadata(path).unwrap(); + assert_eq!( + (metadata.uid(), metadata.gid()), + (parent.uid(), parent.gid()) + ); +} + #[test] fn test_ensure_directory_fails_if_file() { let dir = tempdir().unwrap(); diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index ae2985a4..3a180c77 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -106,6 +106,23 @@ impl FileResource for Recorder { self.inner.ensure_directory(path, mode, owner, group) } + fn ensure_directory_with_owner_ids( + &self, + path: &Path, + mode: Option, + uid: u32, + gid: u32, + ) -> Result { + self.record(ResourceOp::EnsureDirectoryWithOwnerIds { + path: path.display().to_string(), + mode: mode.map(|m| format!("0o{m:o}")), + uid, + gid, + }); + self.inner + .ensure_directory_with_owner_ids(path, mode, uid, gid) + } + fn delete_file(&self, path: &Path) -> Result { self.record(ResourceOp::DeleteFile { path: path.display().to_string(), diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index c995fa74..4a277fc3 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -18,6 +18,12 @@ pub enum ResourceOp { owner: Option, group: Option, }, + EnsureDirectoryWithOwnerIds { + path: String, + mode: Option, + uid: u32, + gid: u32, + }, EnsureBtrfsSubvolume { path: String, }, diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index f80dbde7..1702ac21 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -176,6 +176,7 @@ pub struct MockFiles { pub files: Arc>>>, pub metadata: Arc>>, pub directories: Arc>>, + pub directory_owner_ids: Arc>>, pub fail_btrfs_mount_check: Arc, } @@ -185,6 +186,7 @@ impl MockFiles { files: Arc::new(Mutex::new(HashMap::new())), metadata: Arc::new(Mutex::new(HashMap::new())), directories: Arc::new(Mutex::new(HashSet::new())), + directory_owner_ids: Arc::new(Mutex::new(HashMap::new())), fail_btrfs_mount_check: Arc::new(AtomicBool::new(false)), } } @@ -294,6 +296,27 @@ impl FileResource for MockFiles { } } + fn ensure_directory_with_owner_ids( + &self, + path: &Path, + mode: Option, + uid: u32, + gid: u32, + ) -> Result { + let changed = self.ensure_directory(path, mode, None, None)?; + let path_str = path.display().to_string(); + let mut owners = self + .directory_owner_ids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if owners.get(&path_str) == Some(&(uid, gid)) { + Ok(changed) + } else { + owners.insert(path_str, (uid, gid)); + Ok(true) + } + } + fn delete_file(&self, path: &Path) -> Result { let path_str = path.display().to_string(); let mut files = self diff --git a/ublue/skillet/crates/unifi/Cargo.toml b/ublue/skillet/crates/unifi/Cargo.toml index ad7c0a32..392dd266 100644 --- a/ublue/skillet/crates/unifi/Cargo.toml +++ b/ublue/skillet/crates/unifi/Cargo.toml @@ -11,7 +11,6 @@ skillet_core.workspace = true skillet_podman.workspace = true thiserror.workspace = true tracing.workspace = true -users.workspace = true [dev-dependencies] skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs index 160f5a99..6b791e04 100644 --- a/ublue/skillet/crates/unifi/src/lib.rs +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -4,7 +4,6 @@ use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, Volume}; use std::{collections::BTreeMap, path::Path}; use thiserror::Error; use tracing::info; -use users::{get_group_by_gid, get_user_by_uid}; const DATA_PATH: &str = "/var/lib/data/unifi"; const CONTAINER_UID: u32 = 999; @@ -18,10 +17,6 @@ pub enum UnifiError { File(#[from] FileError), #[error("Podman error: {0}")] Podman(#[from] PodmanError), - #[error("host has no account for UniFi container UID {CONTAINER_UID}")] - HostUidMissing, - #[error("host has no group for UniFi container GID {CONTAINER_GID}")] - HostGidMissing, } /// Configure the rootful `UniFi Network` container and its persistent state. @@ -29,28 +24,8 @@ pub enum UnifiError { /// The rootful Podman service uses host networking so `UniFi` can discover and /// adopt devices on the LAN. The image's `unifi` user writes as UID/GID 999; /// match the volume root to those numeric identities without recursively -/// changing existing application data. +/// changing existing application data or requiring matching host accounts. pub fn apply(system: &S, files: &F) -> Result<(), UnifiError> -where - S: SystemResource + ?Sized, - F: FileResource + ?Sized, -{ - let owner = get_user_by_uid(CONTAINER_UID).ok_or(UnifiError::HostUidMissing)?; - let group = get_group_by_gid(CONTAINER_GID).ok_or(UnifiError::HostGidMissing)?; - apply_with_owners( - system, - files, - &owner.name().to_string_lossy(), - &group.name().to_string_lossy(), - ) -} - -fn apply_with_owners( - system: &S, - files: &F, - owner: &str, - group: &str, -) -> Result<(), UnifiError> where S: SystemResource + ?Sized, F: FileResource + ?Sized, @@ -58,7 +33,12 @@ where info!("Applying UniFi Network container..."); files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; files.ensure_btrfs_subvolume(Path::new(DATA_PATH))?; - files.ensure_directory(Path::new(DATA_PATH), Some(0o750), Some(owner), Some(group))?; + files.ensure_directory_with_owner_ids( + Path::new(DATA_PATH), + Some(0o750), + CONTAINER_UID, + CONTAINER_GID, + )?; let mut extra_config = BTreeMap::new(); extra_config.insert( @@ -115,3 +95,7 @@ where )?; Ok(()) } + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/unifi/src/tests.rs b/ublue/skillet/crates/unifi/src/tests.rs new file mode 100644 index 00000000..95fb729d --- /dev/null +++ b/ublue/skillet/crates/unifi/src/tests.rs @@ -0,0 +1,59 @@ +use super::{apply, DATA_PATH}; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +#[test] +fn unifi_uses_host_network_and_persistent_numeric_owned_data() { + let system = MockSystem::new(); + let files = MockFiles::new(); + + apply(&system, &files).unwrap(); + + let quadlets = files.files.lock().unwrap(); + let content = String::from_utf8_lossy( + quadlets + .get("/etc/containers/systemd/unifi.container") + .unwrap(), + ); + assert!(content.contains("Image=docker.io/jacobalberty/unifi:latest")); + assert!(content.contains("Network=host")); + assert!(content.contains("User=unifi")); + assert!(content.contains("Environment=TZ=Europe/Madrid")); + assert!(content.contains("AutoUpdate=registry")); + assert!(content.contains("Volume=/var/lib/data/unifi:/unifi:Z")); + assert!(content.contains("Requires=skillet-data-prepare.service")); + assert!(content.contains("AssertPathIsMountPoint=/var/lib/data")); + drop(quadlets); + + assert_eq!( + files.directory_owner_ids.lock().unwrap().get(DATA_PATH), + Some(&(999, 999)) + ); +} + +#[test] +fn unifi_rejects_a_missing_data_mount_before_writing_state() { + let system = MockSystem::new(); + let files = MockFiles::new(); + files.fail_btrfs_mount_check.store(true, Ordering::SeqCst); + + assert!(apply(&system, &files).is_err()); + assert!(files.directories.lock().unwrap().is_empty()); + assert!(files.files.lock().unwrap().is_empty()); +} + +#[test] +fn repeated_unifi_apply_does_not_restart_an_unchanged_container() { + let system = MockSystem::new(); + let files = MockFiles::new(); + + apply(&system, &files).unwrap(); + let restart_count = system.restart_count.load(Ordering::SeqCst); + apply(&system, &files).unwrap(); + + assert_eq!(system.restart_count.load(Ordering::SeqCst), restart_count); + assert_eq!( + files.directory_owner_ids.lock().unwrap().get(DATA_PATH), + Some(&(999, 999)) + ); +} From 6f450ad9fa26103e8003180cbd592100d2eaa5c1 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 10:47:35 +0200 Subject: [PATCH 122/252] docs(unifi): record VM acceptance --- ublue/butane/ACCEPTANCE.md | 21 ++++++++++++++++++--- ublue/plan/CLAMPS-UNIFI.md | 13 +++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index cd29a1e2..522ec0c7 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -547,6 +547,21 @@ host tool; it still uses normal command approvals. - Skillet updates only the subvolume root to numeric UID/GID 999; it does not recursively alter existing controller data and does not require matching host account names. The first disposable VM apply exposed that clamps uCore - has no passwd entry for UID 999. The numeric ownership handling and its - regression tests are now implemented; the full-apply retry and backup - restore acceptance remain pending in [the UniFi plan](../plan/CLAMPS-UNIFI.md). + has no passwd entry for UID 999. Numeric ownership handling and regression + tests were added in follow-up commit `6765ecb`. + +### UniFi empty-controller VM acceptance, 2026-10-03 + +- Updated the running `clamps-test-unifi` disposable VM with the new `skillet` + and `skillet-clamps` binaries using `cargo run --release -p skillet -- test + vm update clamps unifi`. +- Retried `skillet-full-apply.service`; it completed with status 0. The + `unifi.service` Quadlet is active and `podman inspect` reports + `network=host` with `/var/lib/data/unifi:/unifi` mounted. +- `/var/lib/data/unifi` is a Btrfs subvolume with owner `999:999` and mode + `750`. The container started and emitted UniFi application initialization + logs. +- Repeated the full apply. It completed with status 0, UniFi remained active, + and the container ID stayed unchanged. Backup restore, reboot persistence, + and expected host listener checks remain pending; see + [the UniFi plan](../plan/CLAMPS-UNIFI.md). diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md index f2b22ef2..3540ab5f 100644 --- a/ublue/plan/CLAMPS-UNIFI.md +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -1,7 +1,8 @@ # UniFi container on clamps -Status: initial Skillet implementation added, 2026-10-02. Disposable VM -acceptance, backup restore, and production migration remain pending. +Status: implementation and empty-controller acceptance passed on the +disposable x86_64 VM on 2026-10-03. Backup restore and production migration +remain pending. ## Current behavior @@ -41,11 +42,11 @@ for the controller's UI account. ## Migration and acceptance -1. Test the Quadlet/module on a named disposable x86_64 VM with an +1. **Passed:** Test the Quadlet/module on a named disposable x86_64 VM with an empty controller: service starts, remains active, uses intended ownership, - has `Network=host` for discovery/adoption, and survives repeated apply, - restart, and reboot. Confirm the expected host listeners and that no - Caddy/Cloudflare setup is needed for UniFi. + has `Network=host` for discovery/adoption, and survives repeated apply. + See `butane/ACCEPTANCE.md`. Reboot survival, expected host listeners, and + confirmation that no Caddy/Cloudflare setup is needed remain to verify. 2. Test restoring one private rupik `.unf` backup into an isolated x86_64 VM using a compatible application version. Keep it disconnected from production devices. Confirm the restored site/configuration and remote Site Manager From 471068f6740ca2f0ec08de046ad6075886f5c087 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 12:07:38 +0200 Subject: [PATCH 123/252] docs(skillet): define architecture refactoring prerequisite --- ublue/AGENTS.md | 31 +++++++ ublue/design/skillet-architecture.md | 31 +++++++ ublue/design/smoke-vms.md | 14 +++- ublue/plan/CLAMPS-MIGRATION.md | 7 +- ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md | 4 + ublue/plan/CLAMPS-UNIFI.md | 4 + ublue/plan/GENERIC-PRIVATE-UIS.md | 4 + ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 81 ++++++++++++++++++ ublue/plan/REFACTOR-02-HOST-PROFILES.md | 64 +++++++++++++++ ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 63 ++++++++++++++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 81 ++++++++++++++++++ ublue/plan/REFACTOR-05-PODMAN-MODEL.md | 65 +++++++++++++++ .../plan/REFACTOR-06-CONVERGENCE-RECORDING.md | 64 +++++++++++++++ ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md | 82 +++++++++++++++++++ ublue/plan/SKILLET-REFACTOR.md | 69 ++++++++++++++++ ublue/plan/TPM-ENCRYPTED-ROOT.md | 6 +- ublue/skillet/AGENTS.md | 61 +++++++++++++- 17 files changed, 726 insertions(+), 5 deletions(-) create mode 100644 ublue/design/skillet-architecture.md create mode 100644 ublue/plan/REFACTOR-01-VM-LIFECYCLE.md create mode 100644 ublue/plan/REFACTOR-02-HOST-PROFILES.md create mode 100644 ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md create mode 100644 ublue/plan/REFACTOR-04-CLI-PROVISIONING.md create mode 100644 ublue/plan/REFACTOR-05-PODMAN-MODEL.md create mode 100644 ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md create mode 100644 ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md create mode 100644 ublue/plan/SKILLET-REFACTOR.md diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index a73eaa06..157d54c2 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -1,5 +1,14 @@ # uBlue agent instructions +## Current priority + +- Complete the seven workstreams in [the Skillet refactoring roadmap](plan/SKILLET-REFACTOR.md) + before proceeding with further service migrations, encryption features, or + production cutover. VM management's Bash-to-Rust migration is included. +- Follow the roadmap's ordering and validation gates. Existing feature plans + retain their unfinished work but are gated by this prerequisite. Fixes and + acceptance work necessary to complete the refactoring are in scope. + ## Design documentation - Read the relevant documents in [design/](design/) before changing architecture @@ -22,6 +31,28 @@ - Remove completed or superseded plans after preserving any unfinished work in an active plan. Retain implementation evidence in the relevant acceptance record; do not label unverified checks as passed. +- Each refactoring handoff must name the implemented slice, remaining work, + interface changes, and validation evidence. Record the general prevention + rule in the applicable `AGENTS.md` when an architectural defect is corrected; + keep incident-specific details in designs, plans, or acceptance records. + +## Shared infrastructure boundaries + +- Shared helpers receive identity and configuration from validated inputs or + declarations. Keep deployment-specific assumptions in the selected profile; + do not bake them into generic readiness, delivery, or lifecycle code. +- Give each resource lifecycle one owner. Every public entry point must use + the same ownership validation, recovery, and cleanup policy; transport + adapters must not provide a shortcut that bypasses those guarantees. +- Keep host profile, environment, deployment instance, runtime identity, and + connection target distinct. Derive conventions in one place and persist the + resolved values needed for recovery. +- Persist ownership before mutations and preserve it across partial failures. + Cleanup must tolerate already-absent owned resources while refusing ambiguous + or unrelated resources. Remove recovery metadata only after cleanup completes. +- Compatibility-sensitive refactors must preserve retained artifacts and + manifests through an explicit migration; never infer that existing state is + disposable merely because its format is old. ## Local validation before commit diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md new file mode 100644 index 00000000..8348cd5d --- /dev/null +++ b/ublue/design/skillet-architecture.md @@ -0,0 +1,31 @@ +# Skillet architecture refactoring + +Decision, 2026-10-03: complete the seven refactoring workstreams before further +service migrations, encryption implementation, or production cutover. Existing +service behavior and unfinished acceptance requirements remain authoritative. +Status: planned; the review and plans do not establish runtime acceptance. + +Keep application crates as reusable recipes. Separate canonical host profiles +and composition from guest runtime adapters, CLI parsing, workstation +provisioning, and synthetic test fixtures. Effects need injectable capability +boundaries covering both observations and mutations. Pure configuration and +composition use ordinary data and functions. This makes host tests independent +of the workstation and keeps one declaration authoritative across entry points. + +Move VM lifecycle orchestration from Bash into Rust. One orchestrator owns the +typed manifest, identity validation, readiness, recovery, and destruction, +including external-resource cleanup before deleting local recovery metadata. +Native and Flatpak execution remain supported. Initially invoke focused +`virsh`, SSH, and existing compiler tools through subprocess adapters; native +libvirt linkage would add requirements to the static musl build and Flatpak +workstations. Keep that choice behind an interface. Do not embed shell programs +in Rust or install workstation dependencies during the migration. + +Preserve command behavior and import existing run manifests before replacing +their format. Guest assertion scripts may remain standalone fixtures. Recording +is diagnostic; fast tests establish decisions and adapter contracts, while +named disposable VMs establish real service and reboot behavior. + +Implementation order and exit criteria: +[refactoring roadmap](../plan/SKILLET-REFACTOR.md). Existing runtime design: +[smoke VMs](smoke-vms.md), [secrets](secrets.md), and [storage](storage.md). diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 75cc9ba8..5e8b1689 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -5,7 +5,8 @@ separate. A retained VM supports inspection and repeated convergence checks; a fresh VM establishes that bootstrap is reproducible. The existing fixture tests real systemd/Podman convergence. Pi-hole credential delivery, Tailscale enrollment/device cleanup, and opt-in Cloudflare/Caddy provisioning -are implemented. Live ACME scenarios remain unverified. +are implemented. Live staging ACME/HTTPS acceptance passed on 2026-10-02; +production ACME and the deferred external-access checks remain unverified. ## Identity and ownership @@ -23,6 +24,17 @@ libvirt and the existing virt-manager Flatpak support different workstation OSes without installing host packages. The Flatpak runtime grants access to the test artifacts and uses QEMU user networking. +## Planned orchestration migration + +Move VM lifecycle management from Bash to one Rust orchestrator, as recorded +in [the architecture decision](skillet-architecture.md) and +[the migration plan](../plan/REFACTOR-01-VM-LIFECYCLE.md). Preserve public +commands, retained manifests, native/Flatpak support, and guest assertions. +The current Bash destruction path does not perform external cleanup; the +target architecture routes all disposal through one owner and preserves +recovery metadata until cleanup succeeds. This migration is planned, not +implemented or accepted. + ## States and recovery Planned encrypted-root tests add a software TPM2 and UEFI/Secure Boot profile. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 7783362c..3c4eb77a 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -1,5 +1,9 @@ # Clamps migration draft +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + Status: remaining work, 2026-09-27. Skillet convergence and disposable VM bootstrap are implemented; their completed agent assignments have been removed. Runtime evidence and outstanding bootstrap checks are recorded in @@ -17,7 +21,8 @@ Pi-hole container runs in the VM with data and Podman storage under `/var/lib/data`; service networking, live ACME, final disk selection, and post-provision rebase checks remain open. -Next: complete Pi-hole LAN acceptance and configure the actual custom DNS +After the refactoring prerequisite: complete Pi-hole LAN acceptance and configure +the actual custom DNS records once their values are supplied. On 2026-10-02, the running smoke VM passed IPv4/IPv6 UDP/TCP DNS queries and a reversible custom-record test. Its libvirt user-mode network forwards only SSH, so client ingress and firewall diff --git a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md index ce63f6bd..25279b19 100644 --- a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -1,5 +1,9 @@ # Milestone 3 discussion: storage and credentials +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + Status: remaining work, 2026-09-27. Base provisioning is implemented. The storage mount path and subvolume layout are decided in [the storage design](../design/storage.md); final physical disk remains open. diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md index 3540ab5f..f41a9237 100644 --- a/ublue/plan/CLAMPS-UNIFI.md +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -1,5 +1,9 @@ # UniFi container on clamps +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + Status: implementation and empty-controller acceptance passed on the disposable x86_64 VM on 2026-10-03. Backup restore and production migration remain pending. diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 501de1fe..653b7bab 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -1,5 +1,9 @@ # Generic private UI provisioning +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + Status: shared UI declarations, aliases, environment lookup, Caddy payloads, Cloudflare account-token issuance, DNS reconciliation, and opt-in smoke VM cleanup are implemented and passed live staging ACME/HTTPS acceptance on diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md new file mode 100644 index 00000000..020fd5da --- /dev/null +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -0,0 +1,81 @@ +# 1. One VM lifecycle owner; migrate Bash management to Rust + +Status: planned. First workstream in [the prerequisite roadmap](SKILLET-REFACTOR.md). +Further feature milestones wait for that roadmap's completion. + +## Read and locate + +- `../design/{skillet-architecture,smoke-vms,secrets,cloudflare-ui-lifecycle}.md`. +- `../butane/bin/{test-vm,test-vm-ready,coreos-install,butane,flatpak-virt}` and + the `virsh`/`virt-install` wrappers; `../butane/README.md`. +- `../skillet/crates/cli/src/main.rs` VM dispatch and + `secret_delivery.rs` provisioning, external ownership, cleanup, and VM SSH. + +Current defects: Bash destruction skips external cleanup and deletes its +metadata; Rust destruction requires a live domain before cleanup; failed +creation can leave artifacts without a usable lifecycle record. Readiness, +ownership checks, manifest parsing, and target selection are duplicated. + +## Implementation sequence + +1. Add workstation library `skillet_vm` with `thiserror` errors and thin CLI + delegation. Define `VmRun`, a versioned manifest, backend/connection and + SSH target types. Keep host profile, instance, guest hostname, environment, + UUID, disk, SSH port/key/known-hosts, source and deployed artifact hashes + distinct. Use atomic manifest updates with restricted local permissions. +2. Import existing `run.conf`, UUID/hash files, and external ownership metadata + without deleting or recreating retained VMs. Validate paths, identities, and + supported legacy formats before conversion. Reject ambiguous state with a + useful diagnostic; missing evidence is not authorization to adopt a domain. +3. Define `VmBackend` for inspect/define/start/reboot/stop/undefine and + `GuestTransport` for execution and file delivery. Initially implement native + and Flatpak subprocess adapters invoking focused tools directly. Preserve + current connection/runtime selection and networking behavior. Do not link + native libvirt, embed shell, or install tools during this migration. +4. Move list/status/create/reboot/update into Rust. Generate domain XML with + an XML library, stage copied templates with structured data operations, and + build artifacts through a shared Cargo helper. Keep the standalone Butane + compiler if it still only compiles configuration. Replace VM disk/key + preparation, Python XML generation, and lifecycle logic in `coreos-install`. + Validate required tools and SSH-port conflicts before costly provisioning. +5. Persist creation intent and a generated UUID before domain definition; + advance through explicit recoverable phases as side effects complete. + Failed starts retain owned state for inspect/retry/disposal. Distinguish + an absent domain from an inaccessible backend. Validate actual UUID/disks + whenever a domain exists; refuse mismatches and unrelated paths. +6. Move readiness into Rust with bounded waits, progress, and retained guest + diagnostics. Use the selected profile and manifest, never a fixed host. + Preserve signed bootstrap, SELinux/resolver and user-environment checks, + base-apply verification, and captured-artifact versus update semantics. + Workstream 4 will consolidate its transport with credential delivery. +7. Make one lifecycle orchestrator own destruction. Supply external cleanup + through a narrow callback/interface backed initially by existing provider + code. Validate recorded ownership independently of domain presence; clean + external resources first, then verified local resources, then metadata. + Persist completed cleanup phases for retry. Retain metadata when cleanup + fails or a response is ambiguous; never treat a connection error as absence. +8. Route every public entry point through this orchestrator. Retire Bash VM + management or retain compatibility wrappers that only forward to Rust, + including destruction. Update READMEs and the smoke VM design with actual + implemented behavior. Guest smoke assertions can remain standalone Bash. + +## Validation and exit criteria + +- CI tests with fake backend/transport/cleanup cover successful transitions, + timeout diagnostics, port conflicts, failed definition/start, existing-name + refusal, UUID/disk mismatch, legacy import, missing domains, and cleanup + retries. Assert that failure preserves recovery state and unrelated resources. +- Native and Flatpak adapter contract tests verify arguments, environments, + connection selection and XML/network differences without starting real VMs. +- On an available supported backend run a named fresh VM through create, + ready, update, reboot/ready and destroy. Inspect a retained legacy run before + and after import, confirming its identity, SSH key, and original artifacts. +- Exercise cleanup after an owned domain is already absent; an injected + external cleanup failure must retain metadata and succeed on retry. Real + external cleanup uses only authorized disposable resources and vault access; + unavailable backend/API checks are explicitly unverified. +- No Bash script remains an independent VM lifecycle owner or provides local + destruction that discards pending external ownership. Shared shell assertion + or compiler fixtures are allowed and keep their explicit interfaces. +- Pass the roadmap checks, record live evidence in `../butane/ACCEPTANCE.md`, + and update designs, usage, and general AGENTS lifecycle rules before handoff. diff --git a/ublue/plan/REFACTOR-02-HOST-PROFILES.md b/ublue/plan/REFACTOR-02-HOST-PROFILES.md new file mode 100644 index 00000000..a74a580f --- /dev/null +++ b/ublue/plan/REFACTOR-02-HOST-PROFILES.md @@ -0,0 +1,64 @@ +# 2. Canonical host profiles and capabilities + +Status: planned; follows workstream 1 in [the prerequisite roadmap](SKILLET-REFACTOR.md). +Further feature milestones wait for that roadmap's completion. + +## Read and locate + +- `../skillet/crates/cli-common/src/{hosts,lib}.rs`, `hosts_tests.rs`, and + `../skillet/crates/hosts/*/src/main.rs`. +- Workstream 1's manifest/identity types; workstation service checks in + `../skillet/crates/cli/src/secret_delivery.rs` and CLI host validation. +- `../design/{private-ui-access,cloudflare-ui-lifecycle,secrets,storage,btrbk}.md`. + +Current defects: application composition, UI declarations, credentials, and +delivery eligibility have separate host checks. Host binaries select shared +dispatch but do not own composition. Unknown full-apply profiles silently fall +back to the baseline, and UI presence is used as a proxy for service eligibility. + +## Implementation sequence + +1. Add canonical library `skillet_hosts` and move host definitions/composition + out of `cli-common`. Define a validated `HostId` and `HostProfile` carrying + enabled application configurations, shared network/storage policy, optional + snapshot configuration, UI declarations, and credential requirements. + Derive applicable UI/credential data from enabled services where possible; + retain explicit aliases and exposure choices in the host declaration. +2. Preserve each current host's enabled services, paths, ports, snapshots, + private configuration sources and environment policies. Remove redundant + account setup only after workstream 3 establishes its purpose and consumers. + Declare runtime identity separately from the profile selected for apply. +3. Keep one composition function per canonical profile, consumed by the generic + CLI and per-host wrappers. Keep phase/domain logic independent of Clap; + parse CLI values at entry points. Retain thin host binaries for compatibility + while documenting their role. Do not duplicate recipes into the wrappers. +4. Have provisioning/delivery consult explicit service capabilities and their + credential consumers. A service does not require an exposed UI merely to + qualify for its own credentials. Unknown or unsupported profiles/services + fail before vault unlock, remote mutation, or full apply. If a generic + baseline is useful, make it explicit rather than an unknown-profile fallback. +5. Replace fixed identity checks in readiness and smoke selection with profile + and run-manifest inputs from workstream 1. Keep test environment selection + explicit. Preserve the current UI naming rule and ownership conflict refusal: + instances sharing a host/environment DNS namespace cannot claim it together. + Do not silently append an instance to served DNS names. +6. Remove duplicated registries and capability lists from CLI paths. Keep + application-specific configuration in the service crate and host selections + in the profile library. Document the authoritative source and dependencies + in `../design/skillet-architecture.md` and update AGENTS genericity rules. + +## Validation and exit criteria + +- Table-driven tests cover every current profile's services, UI upstreams, + aliases, credential consumers, network and snapshot selection. Test a profile + with a service whose UI is unexposed and a profile with no UI/credentials. +- Generic and per-host entry points select the same canonical configuration. + Full apply and delivery reject unknown profiles and undeclared services. +- A local synthetic declaration with a different identity proves shared lookup, + readiness selection and rendering have no fixed-profile assumption. It is + test data, not a synthetic production-dispatch host. No new real host needs + to be bootstrapped merely to test genericity. +- Host/VM instance/environment/SSH target remain distinct; UI naming and + DNS ownership conflicts preserve the current design and private values. +- Pass roadmap checks and affected retained-VM base/full apply checks. Record + evidence and update profile callers, designs and usage before completion. diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md new file mode 100644 index 00000000..8bd4ea25 --- /dev/null +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -0,0 +1,63 @@ +# 3. Cohesive effect interfaces and unified ownership + +Status: planned; follows workstream 2 in [the prerequisite roadmap](SKILLET-REFACTOR.md). +Further feature milestones wait for that roadmap's completion. + +## Read and locate + +- `../skillet/crates/core/src/{files,system,credentials,recorder,test_utils}.rs`. +- Account/subordinate-ID observations in `../skillet/crates/podman/src/lib.rs` + and the host library; ownership inputs in application crates. +- `../design/{secrets,storage,skillet-architecture}.md` and workstreams 2 and 4. + +Current defects: broad file/system traits accumulate unrelated capabilities, +but recipe code still reads live account data, subordinate-ID files and the +credential environment outside those interfaces. Named and numeric directory +ownership use separate APIs; mocks do not enforce important metadata contracts. + +## Implementation sequence + +1. Inventory effectful reads and writes outside concrete adapters. Assign each + to its actual owner. Keep plain validation/rendering as functions, and avoid + a general command-execution trait as a replacement for domain capabilities. +2. Split interfaces into cohesive file, storage, account lookup/ensure, service, + and Podman-secret capabilities where consumers need them. Account identity + and subordinate-range reads must be injectable together with account setup. + Keep a transitional aggregate/context if needed to avoid a flag-day change; + migrate recipe signatures and remove obsolete omnibus requirements afterward. +3. Define shared named/numeric ownership types for file and directory metadata. + Resolve names through the account boundary or a concrete filesystem adapter, + and preserve numeric IDs without requiring matching account names. Model + container process identity and namespace mapping separately. Preserve the + rule that application-data root convergence does not recursively change data. +4. Load systemd credentials and ambient configuration at the guest entry point. + Pass validated credential inputs into host composition; do not construct the + credential environment reader inside recipes. If a credential-reader trait + is needed, keep it focused and provide an in-memory implementation for tests. + Keep secret-value handling separate from recorded operations. +5. Move direct live account/file reads out of host/Podman recipes into adapters. + Missing/invalid subordinate ranges must follow an explicit documented mapping + policy, not an implicit hardcoded fallback. Audit user/group creation against + real consumers; remove obsolete setup rather than keeping inert configuration + merely for compatibility with an unused model. +6. Update concrete adapters, recorders and fakes together. Fakes preserve IDs, + mode/ownership, file-versus-directory and subvolume distinctions, activity + and failure semantics needed by callers. Unsupported operations fail clearly. + Do not claim new trait fidelity by testing only that a mock was called. + +## Validation and exit criteria + +- Full host composition runs with injected dummy inputs and fakes without + consulting the test runner's accounts, subordinate-ID files, environment, + filesystem or services. Tests vary observations to prove decisions change. +- Contract cases cover absent/existing/wrong-ID accounts, invalid/missing ranges, + named and numeric ownership, mode drift, wrong object type and failed effects. + Include an unmapped numeric owner and verify descendants remain untouched. +- Core filesystem tests exercise a real temporary directory; privileged + ownership/mapping and Btrfs behavior use an explicitly named disposable VM. + Preserve mount guards and namespace behavior; do not weaken checks for CI. +- Static audit finds no live effect observations in composition/recipes that + bypass their declared boundary. Traits have cohesive consumers and the two + ownership APIs have become one contract. +- Pass roadmap checks and affected VM ownership/mount/repeat-apply checks. + Update the design and generic AGENTS interface rules with the finished API. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md new file mode 100644 index 00000000..a9840062 --- /dev/null +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -0,0 +1,81 @@ +# 4. Thin CLIs and shared workstation provisioning + +Status: planned; follows workstreams 1–3 in +[the prerequisite roadmap](SKILLET-REFACTOR.md). +Further feature milestones wait for that roadmap's completion. + +## Read and locate + +- Workstreams 1–3's VM transport, profiles, and guest input/resource contracts. +- `../skillet/crates/cli/src/{main,secret_delivery,cloudflare,tailscale}.rs`, + `secret_delivery/vault_cache.rs`, and their tests. +- `../skillet/crates/cli-common/src/{lib,credential}.rs` and + `../skillet/crates/core/src/credentials.rs`. +- `../design/{secrets,cloudflare-ui-lifecycle,smoke-vms,skillet-architecture}.md`. + +Current defects: CLI modules combine parsing, vault persistence, transport, +remote credential installation, external APIs and lifecycle policy. Production +and VM delivery have duplicate implementations with different validation. + +## Implementation sequence + +1. Make binary modules responsible for parsing, tracing, interactive prompts, + and dispatch. Move workstation logic into `skillet_workstation` with cohesive + vault, providers, credential delivery and provisioning modules. Use typed + inputs independent of Clap argument structures and `thiserror` library + errors with preserved sources and sanitized diagnostics. +2. Keep guest apply/profile composition and encrypted credential installation + in guest-facing libraries. Consolidate guest credential path/name validation, + state inspection and atomic installation. A guest apply/credential path must + not depend on a source checkout, workstation vault, or external API access. + Preserve the generic CLI facade and thin host wrappers while documenting + the actual dependency boundaries; do not duplicate the profile registry. +3. Encapsulate the existing KDBX reader/writer and kernel cache. Preserve XDG + defaults, symlink handling, exact entry lookup, conflict checks, encrypted + replacement verification, use-or-create safeguards, and the three-hour TTL. + Provide testable unlock/cache boundaries without exposing master passwords + through command arguments, logs, manifests or chat. +4. Use workstream 1's `GuestTransport` for production and disposable targets. + Implement one credential-delivery operation taking host/profile, credential, + consumer unit, activation policy and payload. Send payload only through stdin; + verify host keys and use focused remote executable invocations with validated + arguments. Keep multi-credential installation deferred until the set is + complete. Remove both old parallel SSH/install implementations. +5. Model production/test differences as explicit policy: durable versus + disposable values, tags, token lifetime, staging and cleanup ownership. + Share environment lookup, zone/domain resolution, site/DNS derivation and + delivery workflows. Preserve the current naming and conflict rules. Do not + infer environment from a target name or expand credential scope. +6. Keep Cloudflare's official SDK transport and local HTTP fixture pattern. + Replace Tailscale's curl HTTP adapter with a Rust HTTP client compatible with + the build constraints, and inject its base endpoint for local protocol tests. + Introduce narrow provider interfaces only for orchestration decisions that + need fakes. Retain existing provider retry/ownership protections. +7. Connect the shared providers to workstream 1's lifecycle orchestrator. + Persist pending enrollment/issuance and cleanup ownership before mutations; + honor reuse/explicit rotation and partial credential-install recovery. + Cleanup remains recoverable if the guest is absent. Remove API operations + hidden in functions named for only one provider when they own multiple + resources. Update usage and designs with completed behavior. + +## Validation and exit criteria + +- Local orchestration tests with dummy vaults and fake transports/providers + cover declared/unsupported services, existing/missing secrets, vault conflict, + partial delivery, deferred activation, API failure and cleanup retry. The + selected policy, rather than host/environment branches, determines behavior. +- Local HTTP tests cover Tailscale auth-key/device-list/removal requests and + failures, plus the existing Cloudflare regression cases. CI contacts no live + provider and prompts for no private password. +- Verify three-hour expiry and cache clearing through the cache contract and, + where available, a local dummy-key check; unavailable kernel checks are + explicitly unverified. Do not test expiry by waiting three hours. +- Guest library dependency inspection and entry-point tests establish that + guest apply/credential commands work without workstation state or checkout. + Generic/per-host entry points retain the same behavior. +- On a named disposable VM verify delivery, repeat apply, rotation and recovery + after deferred installation. Run enrollment/UI/disposal using the existing + vault workflow when authorized credentials are available; preserve sanitized + evidence and mark unavailable live checks honestly. +- Pass roadmap checks; no duplicated production/VM credential workflow remains, + and runtime/CLI/library boundaries are reflected in designs and AGENTS. diff --git a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md new file mode 100644 index 00000000..efd37a7c --- /dev/null +++ b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md @@ -0,0 +1,65 @@ +# 5. Podman configuration with explicit host policy + +Status: planned; follows workstreams 2–4 in +[the prerequisite roadmap](SKILLET-REFACTOR.md). +Further feature milestones wait for that roadmap's completion. + +## Read and locate + +- `../skillet/crates/podman/src/{lib,tests}.rs` and its Quadlet template. +- Application crates' container definitions and workstream 2's shared network + and storage declarations; workstream 3's account/ownership interfaces. +- `../design/{storage,pihole-network,syncthing,unifi,private-ui-access}.md` and + `../butane/includes/data-storage.bu`. + +Current defects: runtime user/network settings can come from both typed fields +and raw directives; unused user fields misdescribe runtime identity. Per-container +apply also owns a global DNS-port policy and converges shared networks repeatedly. + +## Implementation sequence + +1. Inventory actual resource usage before changing the model. Define explicit + image-default/named/numeric process identity, separate host volume ownership, + and optional namespace mapping. Reuse workstream 3's identity types. Remove + inert fields and implicit user creation; retain mapping support only with an + explicit validated caller and meaningful tests. +2. Make commonly used container settings typed, including identity, network + attachments and declared port publications. Keep an extension section for + unsupported directives with documented constraints. Reject conflicts with + typed values and invalid input before effects. Validate repeated directives + according to their cardinality; preserve order where it affects semantics. +3. Add explicit shared network/global Podman baseline convergence selected by + the host profile. Move DNS listener policy out of generic container apply. + Services reference an already-declared network rather than each owning its + full definition. Preserve bridge DNS, dual stack, host networking and network + change refusal until an explicit safe replacement workflow exists. +4. Add reusable storage dependency helpers for application units and shared + data validation. Preserve the installation/runtime split: Ignition prepares + the filesystem and graphroot; recipes validate and prepare application data. + Keep application-owned volume metadata out of generic volume creation. +5. Migrate every existing service, including Tailscale and the smoke fixture, + preserving images, ports, capabilities, SELinux labels, identities, paths, + secret directives, auto-update and startup dependencies. Explain intentional + definition differences in the design; do not make service-policy changes + merely to simplify a type or get a test passing. +6. Keep revision/activation behavior compatible with workstream 6. Document + what omitted services/options mean: an omitted recipe leaves existing state + unmanaged unless an explicit deactivation is requested. Do not remove data + during the refactor or silently interpret omission as destructive cleanup. + +## Validation and exit criteria + +- Rendering/validation tests cover image-default and named/numeric users, + explicit mappings, bridge and host networks, dual-stack publications, secrets, + typed/raw conflicts and semantically ordered repeated directives. +- Baseline tests prove global/network policy is selected once from profile + inputs and independent of application apply order. Network definition changes + still fail safely instead of replacing a network in use. +- Unit and real-VM tests establish mount dependencies, volume root ownership, + preserved descendant metadata, secret/config consumption and repeat apply. + Compare representative old/new Quadlets and document intended changes. +- A named VM verifies actual process identity, DNS publications, service-to- + service resolution, host-network services, and unchanged application state. + Include stop/start and reboot checks for definitions that changed. +- Pass roadmap checks, update affected service designs and acceptance evidence, + and retain separate process/mapping/ownership and global-policy AGENTS rules. diff --git a/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md b/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md new file mode 100644 index 00000000..b21753af --- /dev/null +++ b/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md @@ -0,0 +1,64 @@ +# 6. Explicit convergence outcomes and failure-safe recording + +Status: planned; follows workstreams 3–5 in +[the prerequisite roadmap](SKILLET-REFACTOR.md). +Further feature milestones wait for that roadmap's completion. + +## Read and locate + +- `../skillet/crates/core/src/{recorder,resource_op,test_utils}.rs` and the + resource traits revised in workstream 3. +- `../skillet/crates/hardening/src/lib.rs`, Podman revision/activation logic, + btrbk unit convergence, and apply/record handling in the CLI libraries. +- `../design/{skillet-architecture,smoke-vms}.md` and existing failure-recovery + unit/VM fixture tests. + +Current defects: activation markers are implemented separately in hardening +and Podman; returned booleans can describe only file changes despite a restart. +Recordings are saved only after successful apply and lack results/complete inputs. + +## Implementation sequence + +1. Specify result contracts before editing callers. Low-level file/secret + results describe their own convergence; aggregate apply outcomes distinguish + definition change, start/restart/recovery and no-op. Use a small documented + result type where a boolean cannot express the operation. Audit every caller + and test that currently interprets the boolean. +2. Extract a focused activation helper for desired revision, pending applied + state, required daemon reload, consumer action, and successful marker commit. + Keep resource-specific revision inputs and desired active-state policy in + the caller. Distinguish oneshot actions from persistent services; do not + start completed oneshots repeatedly merely because they are inactive. +3. Preserve interruption behavior: files may already match after a failed reload + or restart, but pending activation must retry. Write applied state only after + success. Account for network-global policy and application input/secret + revisions. Import existing markers or document a bounded migration; never + erase state merely to make a second apply appear clean. +4. Update recording to capture attempted operation plus sanitized result, + relevant non-secret parameters and overall apply outcome. Include account IDs + and consistent ownership types. Record failure without raw payloads, credential + bytes or unsafe subprocess output; fingerprints are not plaintext substitutes + to log indiscriminately. Do not record private configuration contents. +5. Persist the recording even when apply fails. Preserve the original apply + error if recording persistence also fails, with a separate useful diagnostic. + Use atomic writes or another explicitly recoverable format. Version format + changes and document compatibility for existing diagnostic consumers. +6. Update consumers, fakes, tests and README descriptions together. Recording + remains observational: it executes the real resources and is neither a dry + run nor acceptance proof. Update AGENTS result/recording prevention rules. + +## Validation and exit criteria + +- Tests cover changed definition, consumed-input and secret-only changes, + stopped service, genuine no-op, failed reload/start/restart and successful + retry. Assert markers remain pending on failure and commit after success. +- Oneshot and persistent consumers obey different activity policies. Repeat + apply leaves an unchanged running container's identity/start time intact. +- A failed apply produces a readable diagnostic recording containing the failed + operation and prior results. No-op and successful changes are distinguishable; + combined apply/record-write failure retains the original cause. +- Tests using distinctive dummy payloads verify secrets and private configuration + bytes never appear in recordings or returned diagnostic messages. +- Run the real VM fixture's configuration/secret/failure/reboot cases and an + affected host repeat apply. Pass roadmap checks, retain evidence, and document + implemented marker/record format behavior before completion. diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md new file mode 100644 index 00000000..c7a9c142 --- /dev/null +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -0,0 +1,82 @@ +# 7. Test layers, fixtures, build artifacts and CI + +Status: planned; final workstream in [the prerequisite roadmap](SKILLET-REFACTOR.md). +Earlier slices still validate their changes as they land. Further feature +milestones wait for the full roadmap's completion. + +## Read and locate + +- `../skillet/integration_tests/{smoke-ssh,smoke-guest}.sh`, the synthetic host + fixture, CLI container runner and `src/test_entrypoint.sh`. +- Revised resource fakes, VM manifest/transport, profiles, provisioning and + recording interfaces from workstreams 1–6. +- `../../.github/workflows/ci.yml`, `../butane/tests/bootstrap.sh`, both READMEs, + `../design/smoke-vms.md` and `../butane/ACCEPTANCE.md`. + +Current defects: fixture smoke, host acceptance and mocked container checks +are conflated; target/artifact selection assumes default paths/instances; fakes +disagree with adapter behavior; extensionless helpers and Butane-only changes +miss relevant CI. A shell program remains embedded in a production Rust fixture. + +## Implementation sequence + +1. Document four acceptance scopes: pure/adapter-contract tests, binary apply + checks in a container with fake runtime commands, real VM convergence fixture, + and actual host application/live-provider acceptance. Recording is a diagnostic + aid, not a fifth acceptance layer. Preserve useful distinct layers; remove a + redundant check only after equivalent meaningful coverage is demonstrated. +2. Move the synthetic application into a separate test fixture binary/package + outside production host dispatch. Keep its guest program in a standalone + script file; no Rust shell string constant. Use the production resource engine + so configuration, secret, failure-retry and reboot tests remain representative. + Guest assertions may stay standalone Bash and get explicit validated inputs. +3. Select VM smoke targets using host plus instance and the validated manifest + from workstream 1. Resolve key, host-key file, port and installed artifacts + together. Preserve existing default command behavior through delegation; keep + any explicit remote-target mode clearly opted into a disposable target. + Fixed production-name exclusions are not sufficient ownership validation. +4. Consolidate artifact building/discovery for create, update, smoke and container + tests. Parse Cargo JSON artifact output and honor target/profile/target-directory + configuration. Do not select a stale release/debug artifact by directory scan. + Record hashes; make captured readiness artifacts versus current-code update + explicit and verify generic/host/fixture versions used by a scenario. +5. Add focused fake/adapter contract tests. Account IDs and directory metadata + must be observable; directory/subvolume checks and service/secret failures + cannot silently pass. Shell stubs must match production exit/error semantics. + Full host composition with injected inputs belongs in fast CI even though + a Fedora sandbox cannot provide the real service data mount or systemd. +6. Add deterministic host acceptance scenarios selected by the profile's actual + capabilities. Check running services, mounts/ownership, network mode and + declared listeners, unchanged repeat apply, and persistence after reboot. + Keep external ACME/enrollment scenarios opt-in and state what loopback probes + establish. Do not silently label fixture success as actual application success. +7. Update CI to cover shared Rust, Butane and helper inputs; explicitly lint + extensionless shell helpers and extracted fixtures. Add workspace formatting, + pedantic Clippy including test targets, unit/contract checks, bootstrap state + tests, and a clearly named container binary integration check. Routine CI + needs no vault, live providers, hypervisor, or production network. +8. Update command docs and acceptance evidence. Correct stale status text and + preserve deferred Pi-hole LAN, external-network UI, production ACME, backup + restore and hardware checks in their existing plans. Test cases unavailable + on a backend are unverified, not equivalent to a passing mock. + +## Validation and exit criteria + +- With a non-default target directory/profile and a named non-default instance, + commands select the exact newly built artifacts and recorded SSH target. + Missing/unsupported artifacts or ownership fail before guest mutation. +- Every important fake/adapter contract added in 3–6 has meaningful behavioral + coverage, including changed observations and failed effects. Private state is + unnecessary for routine CI; synthetic profiles are absent from production + dispatch and no shell programs are embedded in Rust. +- CI selection includes relevant Butane/helper changes, all executable shell + helpers are linted, and format/Clippy/unit/bootstrap/container commands pass + locally. Update AGENTS with exact final commands and general coverage rules. +- A named fresh VM passes create/ready/provision, real convergence fixture, + profile-selected host acceptance, reboot, repeat apply and cleanup. Record + artifact versions and outcomes; external resources created for this run are + removed through the single orchestrator. Prove retained-run compatibility + and interrupted recovery as required by workstream 1. +- Finish only after earlier workstreams' exit criteria also pass. Update the + roadmap gate and migration entry point so the next agent can resume feature + work without losing deferred acceptance requirements. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md new file mode 100644 index 00000000..5a47e0d7 --- /dev/null +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -0,0 +1,69 @@ +# Skillet refactoring prerequisite + +Status: planned, 2026-10-03. No implementation or new acceptance is claimed. +**Complete these seven workstreams before proceeding with further feature +milestones, service migrations, encryption implementation, or production +cutover.** Preserve the pending tasks in the existing plans. Acceptance and +repairs needed to validate this refactoring may proceed within its scope. + +Read `../AGENTS.md`, `../skillet/AGENTS.md`, and +[the architecture decision](../design/skillet-architecture.md) first. Then read +the designs and source files listed in the selected workstream. Start from the +current working tree; do not assume the review's commits or line numbers are +still current. Inspect changes before editing and preserve unrelated work. + +## Execution order + +The numbers match the seven review findings. Complete one reviewable slice at +a time in this order. These are coordinated workstreams, not seven independent +assignments to execute concurrently against the same interfaces. + +| Order | Workstream | Depends on | Status | +| --- | --- | --- | --- | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Planned | +| 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Planned | +| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Planned | +| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | +| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Planned | +| 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Planned | +| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Planned | + +Each earlier slice includes its own regression checks; testing does not wait +for workstream 7. Workstream 1 creates the minimal VM/transport contracts used +by workstream 4; workstream 4 consolidates delivery on those contracts rather +than inventing another VM transport or lifecycle owner. Preserve the existing +public command syntax unless a required change is documented with migration. + +## Scope and completion gate + +- Keep existing service crates and canonical public/private configuration + boundaries. Do not add new services, change storage layouts, broaden token + scope, install workstation packages, or migrate production data in this work. +- Preserve supported native and Flatpak VM backends, static guest artifacts, + retained VMs, SSH verification, atomic vault writes, and credential lifetimes. +- Routine CI uses dummy secrets and local fakes/HTTP fixtures. Live checks use + named disposable VMs and the existing vault unlock workflow, never chat + passwords or committed credentials. Preserve evidence and ownership on failure. +- For Rust slices run workspace format, pedantic Clippy, tests, and the current + CI integration command; for shell slices run ShellCheck on every affected + helper, including extensionless files. After workstream 7 use the updated CI + commands. Run affected VM checks as each plan requires. +- Record exact commands, code/artifact versions, outcomes, and unavailable + checks in `../butane/ACCEPTANCE.md`. Unavailable live/backend checks stay + unverified; do not mark a workstream complete while its required evidence is + missing. Distinguish unchanged, explicitly deferred production acceptance + from the checks required to validate this refactor. +- Make focused commits after validation and document incomplete validation + honestly. Do not push without authorization. Update this table and each + workstream's progress, design decisions, README usage, and general AGENTS + prevention rules in the corresponding implementation slice. +- Completion requires all seven exit criteria and a final fresh VM cycle: + create → ready → provision → fixture and host acceptance → reboot → repeat + apply → destroy, with external cleanup where resources were provisioned. + Also verify retained-manifest compatibility and interrupted recovery. + +Resume [the migration plan](CLAMPS-MIGRATION.md) only after this gate passes. +The existing private-UI, storage/credential, UniFi, and TPM plans retain their +unfinished work; this prerequisite changes their order, not their acceptance +requirements. Remove completed refactoring plans only after preserving decisions, +evidence, and any remaining tasks as required by AGENTS. diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md index c85fd91b..ffd7ced1 100644 --- a/ublue/plan/TPM-ENCRYPTED-ROOT.md +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -1,7 +1,11 @@ # TPM-encrypted uCore root +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + Status: planned, 2026-10-02. No encrypted-root implementation or acceptance -is claimed. Start after the current private-UI acceptance work. +is claimed. Start after the refactoring prerequisite and private-UI acceptance work. ## Scope and decisions diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 4885c26f..00043d18 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -39,6 +39,63 @@ This document defines the architectural mandates and project structure for `skil the installed Rust target and compiler before reporting musl as unavailable; a missing binary artifact alone does not indicate a missing toolchain. +## Architecture and refactoring rules + +Follow [the refactoring roadmap](../plan/SKILLET-REFACTOR.md) before further +feature milestones. These rules describe the target architecture; the roadmap +tracks existing violations and their migration, not completed implementation. + +- Route effectful observations and mutations through the same explicit + boundary. Recipe and orchestration code must not bypass an injected resource + interface to inspect the live filesystem, account database, process + environment, or runtime. Concrete adapters own those operations; load + credentials and ambient configuration at the entry point and pass them in. +- Define traits around cohesive capabilities needed by consumers. Add an + operation to the capability that owns it, not an unrelated omnibus trait. + Use ordinary data and functions for pure validation, rendering, and static + recipe composition; do not introduce a trait for every recipe. +- Keep one authoritative host capability declaration. Application composition, + UI exposure, credential requirements, and provisioning eligibility must agree + with it. Reject unknown profiles for profile-dependent operations. +- Keep binary entry points thin. Host composition, guest runtime adapters, + workstation provisioning, and test fixtures have distinct responsibilities. + Extract modules first and crates where dependency or deployment boundaries + justify them; production dispatch must not include synthetic test profiles. +- Share delivery and lifecycle workflows across environments. Supply explicit + policy and target inputs rather than duplicating algorithms or branching on + particular deployment names. Keep secret values off command lines and out of + manifests, recordings, and errors. +- Use a common ownership representation for named and numeric identities. + Model application identity, host filesystem ownership, and namespace mapping + separately. Existing numeric ownership must not require a named host account. +- Give common configuration fields one authoritative representation. Reject + conflicts between typed fields and extension directives; preserve ordering + where repeated directives are semantically ordered. Global runtime policy + belongs to the host baseline, not an arbitrary container resource. +- State what every convergence result means. Distinguish definition changes, + activation, recovery, and no-op outcomes. Persist applied state only after the + consuming operation succeeds and retry pending activation after interruption. +- Define whether omission means leaving a resource unmanaged or ensuring its + absence. Deactivation must be explicit and must preserve application data + unless its deletion is authorized. +- Diagnostic recording must survive apply failures and describe operation + outcomes without exposing payloads. Do not treat an attempted operation as + proof of a change, success, or idempotence. +- Fakes and command stubs must honor production contracts for state, metadata, + identities, and error/exit semantics. Unsupported behavior must fail explicitly + rather than silently succeeding. Use shared contract checks for important + adapters and fakes; a mocked runtime does not prove real runtime acceptance. +- Select build artifacts from Cargo's reported outputs and honor its target, + profile, and configured target directory. Do not discover the new build by + scanning potentially stale binaries at conventional paths. +- Keep subprocess adapters focused: construct executable arguments and + environments directly, observe results, and bound waits with diagnostics. + Preserve supported workstation backends and static guest builds without + installing tools as part of a refactor. +- CI must cover executable helpers regardless of filename extension and must + run relevant checks when their shared inputs change. Keep routine CI free of + private credentials and external API access; record live acceptance separately. + ## Testing Philosophy Skillet uses a multi-layered testing approach to ensure reliability and idempotency: @@ -80,6 +137,6 @@ skillet/ ``` ## Module Design -- **Modules as Cookbooks**: Each library crate under `crates/` (besides `core`) represents a "module" or "cookbook" (e.g., `skillet_hardening`). -- **Binary per Host**: The idea is to have one binary per host type that picks up these modules and reuses core primitives. +- **Service Modules**: Service crates compose shared primitives and own their application configuration. Supporting libraries own cohesive runtime, host-profile, or workstation responsibilities. +- **Binary per Host**: Host binaries select a shared canonical profile. Keep their behavior consistent with the generic entry point; do not duplicate recipe composition between binaries. - **Core Primitives**: Found in `skillet_core`, providing the building blocks for all modules. From 544b8e5afcc4c841c88a00ca3b2678ba635f8a18 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 12:20:23 +0200 Subject: [PATCH 124/252] refactor(vm): add typed manifests and strict legacy import --- ublue/butane/ACCEPTANCE.md | 20 + ublue/design/skillet-architecture.md | 4 + ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 16 +- ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/skillet/AGENTS.md | 3 + ublue/skillet/Cargo.lock | 13 + ublue/skillet/Cargo.toml | 4 + ublue/skillet/crates/cli/Cargo.toml | 1 + ublue/skillet/crates/cli/src/main.rs | 22 +- ublue/skillet/crates/vm/Cargo.toml | 16 + ublue/skillet/crates/vm/src/lib.rs | 19 + ublue/skillet/crates/vm/src/manifest.rs | 424 ++++++++++++++++++ ublue/skillet/crates/vm/src/manifest/tests.rs | 188 ++++++++ 13 files changed, 709 insertions(+), 23 deletions(-) create mode 100644 ublue/skillet/crates/vm/Cargo.toml create mode 100644 ublue/skillet/crates/vm/src/lib.rs create mode 100644 ublue/skillet/crates/vm/src/manifest.rs create mode 100644 ublue/skillet/crates/vm/src/manifest/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 522ec0c7..c2b725e9 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,25 @@ # Clamps VM acceptance log +## VM refactoring foundation, 2026-10-03 + +- Source slice: after roadmap commit `471068f`, added `skillet_vm` manifest + types and shared CLI identity validation. No guest service behavior changed. +- Passed on the static-musl target, with the existing cross compiler on PATH: + `cargo fmt --all --check`, + `cargo clippy --offline --workspace --all-targets -- -D warnings`, + `cargo test --offline --workspace`, and `cargo build --offline`, followed by + `./target/x86_64-unknown-linux-musl/debug/skillet test run beezelbot --phase base --image fedora:latest`. + The isolated Fedora container passed both applies with no repeat start/restart + and was removed afterward. Eight new manifest tests cover strict import, + preserved journals/hashes, metadata permissions and ownership/path refusal. +- Initial GNU-target checks under `/tmp` ran out of space. Only this task's + temporary build directory was removed; subsequent builds used `/home`. + The cross compiler was present outside the restricted filesystem view; no + software was installed. +- Live creation/import/readiness/destruction checks are deferred. The user is + creating a smoke VM in another panel; it and its artifacts were left alone. + No vault, Cloudflare or Tailscale request was needed for this slice. + Status: clean disposable VM create, readiness, and the real Skillet smoke scenario passed on 2026-09-26. A subsequent fresh VM reached the signed deployment and installed the full user environment. Bootstrap interruption diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 8348cd5d..67f2a144 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -5,6 +5,10 @@ service migrations, encryption implementation, or production cutover. Existing service behavior and unfinished acceptance requirements remain authoritative. Status: planned; the review and plans do not establish runtime acceptance. +Implementation progress: `skillet_vm` now supplies validated run identity and +versioned manifests with explicit, non-destructive legacy import. VM lifecycle +commands remain on their existing paths until their migration slices land. + Keep application crates as reusable recipes. Separate canonical host profiles and composition from guest runtime adapters, CLI parsing, workstation provisioning, and synthetic test fixtures. Effects need injectable capability diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 020fd5da..4480be36 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -1,8 +1,22 @@ # 1. One VM lifecycle owner; migrate Bash management to Rust -Status: planned. First workstream in [the prerequisite roadmap](SKILLET-REFACTOR.md). +Status: in progress. First workstream in [the prerequisite roadmap](SKILLET-REFACTOR.md). Further feature milestones wait for that roadmap's completion. +## Progress, 2026-10-03 + +Implemented foundation: `skillet_vm` owns validated host/instance identity, +backend/connection and SSH types, version-1 `vm.json`, atomic mode-0600 writes, +and explicit legacy import. Reads do not rewrite retained runs. Legacy files, +captured/deployed hashes and provider journals remain intact. The generic CLI +uses the shared identity validator. Eight manifest regression tests pass. + +Creation, readiness, inspection and disposal still use existing Bash/CLI paths; +the library does not yet replace them. No live VM was changed or imported. +Next: backend/transport contracts and shared Rust inspection, then recoverable +creation/readiness/disposal and public-helper delegation. See +[validation evidence](../butane/ACCEPTANCE.md#vm-refactoring-foundation-2026-10-03). + ## Read and locate - `../design/{skillet-architecture,smoke-vms,secrets,cloudflare-ui-lifecycle}.md`. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 5a47e0d7..ef93de34 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -20,7 +20,7 @@ assignments to execute concurrently against the same interfaces. | Order | Workstream | Depends on | Status | | --- | --- | --- | --- | -| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Planned | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifest foundation | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Planned | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Planned | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 00043d18..ab88eee9 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -88,6 +88,9 @@ tracks existing violations and their migration, not completed implementation. - Select build artifacts from Cargo's reported outputs and honor its target, profile, and configured target directory. Do not discover the new build by scanning potentially stale binaries at conventional paths. +- Reading retained state must not implicitly migrate it. Import legacy formats + explicitly, preserve original evidence, and use atomic creation that cannot + overwrite a concurrently created authoritative manifest. - Keep subprocess adapters focused: construct executable arguments and environments directly, observe results, and bound waits with diagnostics. Preserve supported workstation backends and static guest builds without diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 39cbe175..dfaeb3ad 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2289,6 +2289,7 @@ dependencies = [ "sha2 0.10.9", "skillet_caddy", "skillet_cli_common", + "skillet_vm", "tempfile", "tracing", "tracing-subscriber", @@ -2431,6 +2432,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "skillet_vm" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "users", + "uuid", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 88e0a5dc..70867974 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/hosts/beezelbot", "crates/hosts/clamps", "crates/cli-common", + "crates/vm", ] [workspace.dependencies] @@ -25,6 +26,7 @@ skillet_btrbk = { path = "crates/btrbk" } skillet_caddy = { path = "crates/caddy" } skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } +skillet_vm = { path = "crates/vm" } thiserror = "1.0" sha2 = "0.10" users = "0.11" @@ -43,6 +45,8 @@ keepass = { version = "0.13.25", features = ["save_kdbx4"] } rpassword = "7.5" cloudflare = { version = "0.14.0", default-features = false, features = ["blocking", "rustls-tls"] } keyutils = "0.4" +uuid = { version = "1.26", features = ["serde", "v4"] } +quick-xml = { version = "0.41", features = ["serialize"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 4454d2c1..4a7987ce 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] skillet_cli_common.workspace = true skillet_caddy.workspace = true +skillet_vm.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 79472f63..5e6b6240 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -382,27 +382,7 @@ fn run_vm_list(args: &VmListArgs) -> Result<()> { } fn vm_name(hostname: &str, instance: &str) -> Result { - let valid = |value: &str| { - !value.is_empty() - && value.starts_with(|character: char| character.is_ascii_lowercase()) - && value - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - }; - if !valid(hostname) { - return Err(anyhow!("invalid VM host name: {hostname}")); - } - if !instance - .bytes() - .next() - .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) - || !instance - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - { - return Err(anyhow!("invalid VM instance: {instance}")); - } - Ok(format!("{hostname}-test-{instance}")) + Ok(skillet_vm::RunIdentity::new(hostname, instance)?.domain_name()) } fn run_helper(path: &Path, args: &[&str]) -> Result<()> { diff --git a/ublue/skillet/crates/vm/Cargo.toml b/ublue/skillet/crates/vm/Cargo.toml new file mode 100644 index 00000000..35e38188 --- /dev/null +++ b/ublue/skillet/crates/vm/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "skillet_vm" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true +uuid.workspace = true +users.workspace = true + diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs new file mode 100644 index 00000000..67b81dfe --- /dev/null +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -0,0 +1,19 @@ +//! Workstation VM identity and durable ownership. No vault or guest resources. + +pub mod manifest; + +pub use manifest::{ + Backend, Connection, Environment, ManifestStore, Phase, RunIdentity, SshTarget, VmRun, +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid VM state: {0}")] + Invalid(String), + #[error("VM artifact I/O: {0}")] + Io(#[from] std::io::Error), + #[error("VM manifest JSON: {0}")] + Json(#[from] serde_json::Error), +} + +pub type Result = std::result::Result; diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs new file mode 100644 index 00000000..6e1a4e87 --- /dev/null +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -0,0 +1,424 @@ +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs::{self, File}, + io::Write as _, + os::unix::fs::{MetadataExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, +}; +use uuid::Uuid; + +pub const MANIFEST_VERSION: u32 = 1; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunIdentity { + host: String, + instance: String, +} + +impl RunIdentity { + pub fn new(host: &str, instance: &str) -> Result { + if !valid_label(host, false) || !valid_label(instance, true) { + return Err(Error::Invalid( + "host or instance contains invalid characters".into(), + )); + } + Ok(Self { + host: host.into(), + instance: instance.into(), + }) + } + + pub fn host(&self) -> &str { + &self.host + } + pub fn instance(&self) -> &str { + &self.instance + } + pub fn domain_name(&self) -> String { + format!("{}-test-{}", self.host, self.instance) + } +} + +fn valid_label(value: &str, digit_first: bool) -> bool { + value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || (digit_first && byte.is_ascii_digit())) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Backend { + Native, + Flatpak, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Environment { + Test, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Connection { + pub backend: Backend, + pub uri: String, + pub runtime_dir: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SshTarget { + pub user: String, + pub address: String, + pub port: u16, + pub identity: PathBuf, + pub known_hosts: PathBuf, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Preparing, + Defined, + Started, + Ready, + ExternalCleanupComplete, + DomainRemoved, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactHashes { + pub host: String, + pub generic: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VmRun { + pub version: u32, + pub owner_uid: u32, + pub identity: RunIdentity, + pub environment: Environment, + pub guest_hostname: String, + pub uuid: Uuid, + pub connection: Connection, + pub ssh: SshTarget, + pub disk: PathBuf, + pub ignition: PathBuf, + pub source_commit: String, + pub captured: Option, + pub deployed: Option, + pub phase: Phase, +} + +/// The manifest contains only ownership/configuration, never secret payloads. +/// External provider journals remain separate and are preserved during import. +pub struct ManifestStore { + root: PathBuf, + owner_uid: u32, +} + +impl ManifestStore { + pub fn new(root: &Path, owner_uid: u32) -> Result { + if !root.is_absolute() + || root + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err(Error::Invalid("artifact root must be absolute".into())); + } + reject_symlinks(root)?; + Ok(Self { + root: root.into(), + owner_uid, + }) + } + + pub fn run_dir(&self, identity: &RunIdentity) -> PathBuf { + self.root.join(identity.domain_name()) + } + + /// Read without migrating or contacting a runtime; safe for retained runs. + pub fn load(&self, identity: &RunIdentity) -> Result { + let dir = self.run_dir(identity); + self.validate_directory(&dir)?; + let json = dir.join("vm.json"); + reject_symlinks(&json)?; + let run = if json.exists() { + serde_json::from_reader(File::open(json)?)? + } else { + self.read_legacy(identity, &dir)? + }; + self.validate(&run, identity)?; + Ok(run) + } + + /// Explicit, atomic import. Keep all legacy files and provider journals. + pub fn import(&self, identity: &RunIdentity) -> Result { + let run = self.load(identity)?; + let path = self.run_dir(identity).join("vm.json"); + if !path.exists() { + match self.write(&run, false) { + Ok(()) => (), + Err(Error::Io(error)) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return self.load(identity); + } + Err(error) => return Err(error), + } + } + Ok(run) + } + + pub fn save(&self, run: &VmRun) -> Result<()> { + self.write(run, true) + } + + fn write(&self, run: &VmRun, replace: bool) -> Result<()> { + self.validate(run, &run.identity)?; + let dir = self.run_dir(&run.identity); + self.validate_directory(&dir)?; + let path = dir.join("vm.json"); + reject_symlinks(&path)?; + let mut file = tempfile::NamedTempFile::new_in(&dir)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(&serde_json::to_vec_pretty(run)?)?; + file.write_all(b"\n")?; + file.as_file().sync_all()?; + if replace { + file.persist(&path) + .map_err(|error| Error::Io(error.error))?; + } else { + file.persist_noclobber(&path) + .map_err(|error| Error::Io(error.error))?; + } + File::open(dir)?.sync_all()?; + Ok(()) + } + + fn validate_directory(&self, dir: &Path) -> Result<()> { + reject_symlinks(dir)?; + let metadata = fs::metadata(dir)?; + if !metadata.is_dir() || metadata.uid() != self.owner_uid { + return Err(Error::Invalid( + "run directory is not owned by the current user".into(), + )); + } + Ok(()) + } + + pub fn validate(&self, run: &VmRun, identity: &RunIdentity) -> Result<()> { + RunIdentity::new(run.identity.host(), run.identity.instance())?; + if run.version != MANIFEST_VERSION + || run.owner_uid != self.owner_uid + || &run.identity != identity + || run.guest_hostname != identity.domain_name() + || run.uuid.is_nil() + { + return Err(Error::Invalid( + "manifest version, identity or owner mismatch".into(), + )); + } + let dir = self.run_dir(identity); + if run.disk != dir.join(format!("{}.qcow2", identity.domain_name())) + || run.ignition + != dir + .join("ignition") + .join(format!("{}.ign", identity.host())) + || run.ssh.identity != dir.join("ssh/id_ed25519") + || run.ssh.known_hosts != dir.join("ssh/known_hosts") + || run.ssh.user != "giacomo" + || run.ssh.address != "127.0.0.1" + || !(2200..=2299).contains(&run.ssh.port) + { + return Err(Error::Invalid( + "manifest artifact paths or SSH target mismatch".into(), + )); + } + run.connection.validate(self.owner_uid)?; + if run.phase != Phase::Preparing && run.captured.is_none() { + return Err(Error::Invalid( + "prepared runs require captured artifact hashes".into(), + )); + } + for path in [ + &run.disk, + &run.ignition, + &run.ssh.identity, + &run.ssh.known_hosts, + ] { + reject_symlinks(path)?; + } + for hashes in [&run.captured, &run.deployed].into_iter().flatten() { + if !valid_hash(&hashes.host) || !valid_hash(&hashes.generic) { + return Err(Error::Invalid("artifact hashes must be SHA256".into())); + } + } + Ok(()) + } + + fn read_legacy(&self, identity: &RunIdentity, dir: &Path) -> Result { + let config = parse_legacy(&read_file(&dir.join("run.conf"))?)?; + let field = |name: &str| { + config + .get(name) + .cloned() + .ok_or_else(|| Error::Invalid(format!("legacy manifest lacks {name}"))) + }; + if field("vm")? != identity.domain_name() || field("host")? != identity.host() { + return Err(Error::Invalid("legacy manifest identity mismatch".into())); + } + let backend = match config.get("backend").map(String::as_str) { + None | Some("" | "native") => Backend::Native, + Some("flatpak") => Backend::Flatpak, + _ => return Err(Error::Invalid("unknown legacy VM backend".into())), + }; + let uuid = Uuid::parse_str(read_file(&dir.join("domain.uuid"))?.trim()).map_err(|_| { + Error::Invalid( + "legacy UUID is absent or invalid; inspect partial creation before adoption".into(), + ) + })?; + let captured = Some(ArtifactHashes { + host: read_hash(&dir.join("skillet.sha256"))?, + generic: read_hash(&dir.join("skillet-generic.sha256"))?, + }); + let host_deployed = dir.join("deployed-skillet.sha256"); + let generic_deployed = dir.join("deployed-skillet-generic.sha256"); + let deployed = match (host_deployed.exists(), generic_deployed.exists()) { + (false, false) => None, + (true, true) => Some(ArtifactHashes { + host: read_hash(&host_deployed)?, + generic: read_hash(&generic_deployed)?, + }), + _ => return Err(Error::Invalid("incomplete deployed artifact hashes".into())), + }; + Ok(VmRun { + version: MANIFEST_VERSION, + owner_uid: self.owner_uid, + identity: identity.clone(), + environment: Environment::Test, + guest_hostname: identity.domain_name(), + uuid, + connection: Connection { + backend, + uri: field("uri")?, + runtime_dir: field("runtime_dir")?.into(), + }, + ssh: SshTarget { + user: "giacomo".into(), + address: "127.0.0.1".into(), + port: field("ssh_port")? + .parse() + .map_err(|_| Error::Invalid("invalid legacy SSH port".into()))?, + identity: dir.join("ssh/id_ed25519"), + known_hosts: dir.join("ssh/known_hosts"), + }, + disk: dir.join(format!("{}.qcow2", identity.domain_name())), + ignition: dir + .join("ignition") + .join(format!("{}.ign", identity.host())), + source_commit: field("source_commit")?, + captured, + deployed, + phase: Phase::Started, + }) + } +} + +impl Connection { + pub fn validate(&self, owner_uid: u32) -> Result<()> { + let base = PathBuf::from(format!("/run/user/{owner_uid}")); + if self.uri != "qemu:///session" + || !self.runtime_dir.is_absolute() + || self.runtime_dir.components().any(|part| { + matches!( + part, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + || (self.backend == Backend::Native && !self.runtime_dir.starts_with(&base)) + || (self.backend == Backend::Flatpak && self.runtime_dir != base.join("skvm")) + { + return Err(Error::Invalid( + "unsupported libvirt connection or runtime path".into(), + )); + } + reject_symlinks(&self.runtime_dir) + } +} + +fn parse_legacy(contents: &str) -> Result> { + let mut values = BTreeMap::new(); + for line in contents.lines() { + let (key, value) = line + .split_once('=') + .ok_or_else(|| Error::Invalid("malformed legacy manifest line".into()))?; + if !matches!( + key, + "vm" | "host" + | "uri" + | "ssh_port" + | "image" + | "artifact" + | "runtime_dir" + | "backend" + | "source_commit" + ) || values.insert(key.into(), value.into()).is_some() + { + return Err(Error::Invalid( + "unknown or duplicate legacy manifest field".into(), + )); + } + } + Ok(values) +} + +fn valid_hash(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} +fn read_hash(path: &Path) -> Result { + let content = read_file(path)?; + let hash = content + .split_whitespace() + .next() + .ok_or_else(|| Error::Invalid("empty artifact hash file".into()))?; + if !valid_hash(hash) { + return Err(Error::Invalid("invalid artifact hash file".into())); + } + Ok(hash.to_ascii_lowercase()) +} +fn read_file(path: &Path) -> Result { + reject_symlinks(path)?; + Ok(fs::read_to_string(path)?) +} + +/// Refuse links in ancestors as well as the final component; missing leaves are +/// allowed for preparation and interrupted cleanup, but never adopted as owned. +pub(crate) fn reject_symlinks(path: &Path) -> Result<()> { + for ancestor in path.ancestors() { + match fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(Error::Invalid( + "symlinked VM artifact or runtime path".into(), + )) + } + Ok(_) => (), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "manifest/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/manifest/tests.rs b/ublue/skillet/crates/vm/src/manifest/tests.rs new file mode 100644 index 00000000..cf0fdb80 --- /dev/null +++ b/ublue/skillet/crates/vm/src/manifest/tests.rs @@ -0,0 +1,188 @@ +use super::*; +use std::os::unix::fs::symlink; + +fn legacy_run() -> (tempfile::TempDir, ManifestStore, RunIdentity) { + let tmp = tempfile::tempdir().unwrap(); + let identity = RunIdentity::new("fixture", "retained-2").unwrap(); + let store = ManifestStore::new(tmp.path(), users::get_current_uid()).unwrap(); + let dir = store.run_dir(&identity); + fs::create_dir(&dir).unwrap(); + fs::write(dir.join("run.conf"), format!( + "vm={}\nhost=fixture\nuri=qemu:///session\nssh_port=2207\nimage=/original/image\nartifact=/original/binary\nruntime_dir=/run/user/{}\nbackend=native\nsource_commit=original-revision\n", + identity.domain_name(), users::get_current_uid() + )).unwrap(); + fs::write( + dir.join("domain.uuid"), + "188ecb9b-3606-4f0f-9868-abcf366c824b\n", + ) + .unwrap(); + fs::write( + dir.join("skillet.sha256"), + format!("{} /original/host\n", "a".repeat(64)), + ) + .unwrap(); + fs::write( + dir.join("skillet-generic.sha256"), + format!("{} /original/generic\n", "b".repeat(64)), + ) + .unwrap(); + (tmp, store, identity) +} + +#[test] +fn identity_separates_profile_and_instance_and_rejects_path_input() { + let identity = RunIdentity::new("fixture", "2-smoke").unwrap(); + assert_eq!(identity.host(), "fixture"); + assert_eq!(identity.instance(), "2-smoke"); + assert_eq!(identity.domain_name(), "fixture-test-2-smoke"); + for invalid in ["", "../escape", "two words", "UPPER", "-bad", "a\n"] { + assert!(RunIdentity::new(invalid, "smoke").is_err()); + assert!(RunIdentity::new("fixture", invalid).is_err()); + } +} + +#[test] +fn legacy_read_is_non_mutating_and_preserves_original_artifacts() { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let before = fs::read(dir.join("run.conf")).unwrap(); + let run = store.load(&identity).unwrap(); + assert_eq!(run.ssh.port, 2207); + assert_eq!(run.source_commit, "original-revision"); + assert_eq!(run.captured.unwrap().host, "a".repeat(64)); + assert!(!dir.join("vm.json").exists()); + assert_eq!(fs::read(dir.join("run.conf")).unwrap(), before); +} + +#[test] +fn explicit_import_is_private_repeatable_and_preserves_external_journals() { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + fs::write(dir.join("cloudflare.json"), "opaque external ownership").unwrap(); + fs::write(dir.join("tailscale-pending"), "pending").unwrap(); + let first = store.import(&identity).unwrap(); + assert_eq!(store.import(&identity).unwrap(), first); + assert_eq!(store.load(&identity).unwrap(), first); + assert_eq!( + fs::metadata(dir.join("vm.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::read_to_string(dir.join("cloudflare.json")).unwrap(), + "opaque external ownership" + ); + assert!(dir.join("tailscale-pending").exists()); + assert!(dir.join("run.conf").exists()); +} + +#[test] +fn legacy_import_rejects_ambiguous_or_partial_ownership_without_writing() { + for corrupt in [ + "duplicate", + "uuid", + "identity", + "port", + "hash", + "backend", + "deployed", + ] { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let config_path = dir.join("run.conf"); + let config = fs::read_to_string(&config_path).unwrap(); + match corrupt { + "duplicate" => fs::write(&config_path, format!("{config}ssh_port=2201\n")).unwrap(), + "uuid" => fs::remove_file(dir.join("domain.uuid")).unwrap(), + "identity" => { + fs::write(&config_path, config.replace("host=fixture", "host=other")).unwrap(); + } + "port" => { + fs::write(&config_path, config.replace("ssh_port=2207", "ssh_port=22")).unwrap(); + } + "hash" => fs::write(dir.join("skillet.sha256"), "bad hash").unwrap(), + "backend" => fs::write( + &config_path, + config.replace("backend=native", "backend=unknown"), + ) + .unwrap(), + "deployed" => fs::write(dir.join("deployed-skillet.sha256"), "c".repeat(64)).unwrap(), + _ => unreachable!(), + } + assert!(store.import(&identity).is_err(), "accepted {corrupt}"); + assert!(!dir.join("vm.json").exists()); + } +} + +#[test] +fn json_manifest_rejects_owner_identity_version_and_paths() { + let (_tmp, store, identity) = legacy_run(); + let original = store.load(&identity).unwrap(); + for field in [ + "version", "owner", "identity", "disk", "ssh", "runtime", "nil", "hash", + ] { + let mut run = original.clone(); + match field { + "version" => run.version = 99, + "owner" => run.owner_uid = run.owner_uid.wrapping_add(1), + "identity" => run.identity = RunIdentity::new("other", "retained-2").unwrap(), + "disk" => run.disk = "/outside/data.qcow2".into(), + "ssh" => run.ssh.known_hosts = "/outside/known_hosts".into(), + "runtime" => run.connection.runtime_dir = "/run/user/999999/../other".into(), + "nil" => run.uuid = Uuid::nil(), + "hash" => run.captured.as_mut().unwrap().host = "bad".into(), + _ => unreachable!(), + } + assert!(store.validate(&run, &identity).is_err(), "accepted {field}"); + } +} + +#[test] +fn symlinks_in_manifest_and_artifacts_are_refused() { + for component in ["run.conf", "domain.uuid", "vm.json", "disk", "ancestor"] { + let (tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let target = tmp.path().join("unrelated"); + fs::write(&target, "not owned").unwrap(); + let path = match component { + "disk" => dir.join(format!("{}.qcow2", identity.domain_name())), + "ancestor" => dir.join("ssh"), + _ => dir.join(component), + }; + if path.exists() { + fs::remove_file(&path).unwrap(); + } + symlink(&target, &path).unwrap(); + assert!(store.load(&identity).is_err(), "accepted link {component}"); + assert_eq!(fs::read_to_string(target).unwrap(), "not owned"); + } +} + +#[test] +fn partial_cleanup_can_load_when_owned_artifacts_are_already_absent() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.import(&identity).unwrap(); + run.phase = Phase::DomainRemoved; + store.save(&run).unwrap(); + assert_eq!(store.load(&identity).unwrap().phase, Phase::DomainRemoved); +} + +#[test] +fn flatpak_and_native_connections_have_distinct_validated_runtime_paths() { + let uid = users::get_current_uid(); + let mut connection = Connection { + backend: Backend::Flatpak, + uri: "qemu:///session".into(), + runtime_dir: format!("/run/user/{uid}/skvm").into(), + }; + assert!(connection.validate(uid).is_ok()); + connection.runtime_dir = format!("/run/user/{uid}/other").into(); + assert!(connection.validate(uid).is_err()); + connection.backend = Backend::Native; + assert!(connection.validate(uid).is_ok()); + connection.uri = "qemu:///system".into(); + assert!(connection.validate(uid).is_err()); +} From d799ace60536d6f7dcd0a5d13033c1144ba10b17 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 12:31:43 +0200 Subject: [PATCH 125/252] fix(vm): centralize owned disposal and recover absent guests --- ublue/butane/ACCEPTANCE.md | 16 + ublue/butane/README.md | 12 + ublue/butane/bin/test-vm | 24 +- ublue/design/skillet-architecture.md | 5 +- ublue/design/smoke-vms.md | 10 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 14 +- ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/skillet/AGENTS.md | 4 + ublue/skillet/Cargo.lock | 2 + ublue/skillet/crates/cli/src/main.rs | 21 +- .../skillet/crates/cli/src/secret_delivery.rs | 5 +- ublue/skillet/crates/cli/src/vm.rs | 48 +++ ublue/skillet/crates/vm/Cargo.toml | 3 +- ublue/skillet/crates/vm/src/backend.rs | 290 ++++++++++++++++++ ublue/skillet/crates/vm/src/backend/tests.rs | 195 ++++++++++++ ublue/skillet/crates/vm/src/lib.rs | 19 ++ ublue/skillet/crates/vm/src/lifecycle.rs | 75 +++++ .../skillet/crates/vm/src/lifecycle/tests.rs | 204 ++++++++++++ ublue/skillet/crates/vm/src/manifest.rs | 2 +- ublue/skillet/crates/vm/src/manifest/tests.rs | 2 +- 20 files changed, 915 insertions(+), 38 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/vm.rs create mode 100644 ublue/skillet/crates/vm/src/backend.rs create mode 100644 ublue/skillet/crates/vm/src/backend/tests.rs create mode 100644 ublue/skillet/crates/vm/src/lifecycle.rs create mode 100644 ublue/skillet/crates/vm/src/lifecycle/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index c2b725e9..f533fa0c 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -20,6 +20,22 @@ creating a smoke VM in another panel; it and its artifacts were left alone. No vault, Cloudflare or Tailscale request was needed for this slice. +## Rust VM inspection and disposal, 2026-10-03 + +- Source slice: follows `544b8e5`. `test vm status` and both destroy entry + points use `skillet_vm`. Native and Flatpak command contracts are covered + without contacting libvirt. Destructive actions use the recorded UUID. +- Twenty-one VM regression tests pass, including absent guests, failed runtime + queries, reused names/renamed UUIDs, wrong disks, literal subprocess arguments, + bounded command timeout, concurrent disposal, external cleanup failure/retry, + and local cleanup retry after the external phase was already completed. +- Passed the same static-musl workspace format, all-target pedantic Clippy, + workspace tests/build and Fedora base integration commands listed above; + also passed `shellcheck -x butane/bin/test-vm` and `git diff --check`. +- No live VM mutation, retained-run import, provider call or vault unlock was + attempted. Live cleanup and Flatpak runtime acceptance remain unverified. + The user's concurrent VM creation was left untouched. + Status: clean disposable VM create, readiness, and the real Skillet smoke scenario passed on 2026-09-26. A subsequent fresh VM reached the signed deployment and installed the full user environment. Bootstrap interruption diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 7149d157..cd8aec13 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -83,10 +83,22 @@ create and ready: ```bash cargo run --release -p skillet -- test vm create clamps smoke cargo run --release -p skillet -- test vm list clamps +cargo run --release -p skillet -- test vm status clamps smoke cargo run --release -p skillet -- test vm provision clamps smoke cargo run --release -p skillet -- test vm destroy clamps smoke ``` +`status` and `destroy` now use the Rust ownership/cleanup implementation; +the corresponding `test-vm` commands delegate to it. Reading status does not +import or rewrite a legacy run. Disposal creates a private versioned manifest +while retaining original files until cleanup finishes. If the owned guest is +already absent, disposal still cleans recorded external resources before +removing local artifacts. A stopped/inaccessible libvirt runtime is reported +as an error; retry after making its recorded connection available. An enrolled +VM may require the normal KeePassXC unlock to remove its provider resources. +Failed cleanup retains recovery metadata. Other helper commands are still +being migrated; see [the roadmap](../plan/SKILLET-REFACTOR.md). + The helper calls `coreos-install`, which builds the generic `skillet` CLI and the `skillet-clamps` host binary. The lower-level launcher accepts `--artifact PATH` to use a specific host binary; the generic CLI is built from the workspace. diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 4865de94..9ff61218 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -167,7 +167,8 @@ fi host="$1" operation="$2" shift 2 -if [[ ! "$host" =~ ^[a-z][a-z0-9-]*$ ]] || ! host_exists "$host"; then +if [[ ! "$host" =~ ^[a-z][a-z0-9-]*$ ]] || + { [[ "$operation" != destroy && "$operation" != status ]] && ! host_exists "$host"; }; then echo "Unknown host: $host" >&2 exit 2 fi @@ -185,6 +186,13 @@ shift name="$host-test-$instance" run_dir="$root/$name" [[ ! -L "$root" && ! -L "$run_dir" ]] || { echo "Symlinked artifact path refused" >&2; exit 1; } +# Migrate commands one slice at a time. All disposal now uses the Rust owner, +# including provider cleanup when the owned domain has already disappeared. +if [[ "$operation" == destroy || "$operation" == status ]]; then + (($# == 0)) || usage + cd "$script_dir/../../skillet" + exec cargo run --quiet --release -p skillet -- test vm "$operation" "$host" "$instance" +fi inspect_owned() { [[ -d "$run_dir" && -f "$run_dir/run.conf" && -f "$run_dir/domain.uuid" ]] || { echo "Missing owned run manifest for $name" >&2; exit 1; @@ -278,11 +286,6 @@ case "$operation" in sha256sum "$artifact" > "$run_dir/deployed-skillet.sha256" sha256sum "$generic_artifact" > "$run_dir/deployed-skillet-generic.sha256" echo "Updated skillet and skillet-$host in $name" ;; - status) - (($# == 0)) || usage - inspect_owned - vm_virsh dominfo "$name" - vm_virsh domblklist "$name" ;; ssh) (($# == 0)) || usage inspect_owned @@ -298,14 +301,5 @@ case "$operation" in (($# == 0)) || usage inspect_owned vm_virsh reboot "$name" ;; - destroy) - (($# == 0)) || usage - inspect_owned - if [[ "$(vm_virsh domstate "$name")" != 'shut off' ]]; then - vm_virsh destroy "$name" - fi - vm_virsh undefine "$name" - [[ "$(realpath -e "$run_dir")" == "$run_dir" && "$run_dir" == "$root"/"$host"-test-* ]] || exit 1 - rm -rf -- "$run_dir" ;; *) usage ;; esac diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 67f2a144..f2008cc3 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -6,8 +6,9 @@ service behavior and unfinished acceptance requirements remain authoritative. Status: planned; the review and plans do not establish runtime acceptance. Implementation progress: `skillet_vm` now supplies validated run identity and -versioned manifests with explicit, non-destructive legacy import. VM lifecycle -commands remain on their existing paths until their migration slices land. +versioned manifests with explicit, non-destructive legacy import. Rust status +and disposal use a focused libvirt adapter and a locked cleanup orchestrator; +remaining VM commands stay on their existing paths until their slices land. Keep application crates as reusable recipes. Separate canonical host profiles and composition from guest runtime adapters, CLI parsing, workstation diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 5e8b1689..355b89e9 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -30,10 +30,12 @@ Move VM lifecycle management from Bash to one Rust orchestrator, as recorded in [the architecture decision](skillet-architecture.md) and [the migration plan](../plan/REFACTOR-01-VM-LIFECYCLE.md). Preserve public commands, retained manifests, native/Flatpak support, and guest assertions. -The current Bash destruction path does not perform external cleanup; the -target architecture routes all disposal through one owner and preserves -recovery metadata until cleanup succeeds. This migration is planned, not -implemented or accepted. +Status and destruction now delegate to Rust. Disposal validates identity, +cleans external resources, then removes the UUID-addressed domain and local +artifacts. It also works with an already-absent guest; connection failures and +partial cleanup preserve journals for retry. A per-run lock prevents concurrent +disposal. Remaining lifecycle commands still use Bash. Unit/adapter checks pass; +live migration acceptance is deferred while the user's VM creation runs. ## States and recovery diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 4480be36..5f04534f 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -11,10 +11,16 @@ and explicit legacy import. Reads do not rewrite retained runs. Legacy files, captured/deployed hashes and provider journals remain intact. The generic CLI uses the shared identity validator. Eight manifest regression tests pass. -Creation, readiness, inspection and disposal still use existing Bash/CLI paths; -the library does not yet replace them. No live VM was changed or imported. -Next: backend/transport contracts and shared Rust inspection, then recoverable -creation/readiness/disposal and public-helper delegation. See +Rust now owns status inspection and disposal through `VmBackend`, a bounded +native/Flatpak libvirt adapter, and one locked cleanup orchestrator. Both +public destroy paths use it. Successful lists distinguish an absent guest +from a failed connection; UUID and disk ownership are checked before cleanup +and again after external calls. Failed cleanup retains its manifest/journals. +Twenty-one VM tests pass. No live VM was changed or imported. + +Creation, readiness, list/update/reboot and SSH still use Bash. Next: shared +transport, catalog/artifact handling, recoverable creation/readiness and the +remaining public-helper delegation. Disposal live acceptance remains deferred. See [validation evidence](../butane/ACCEPTANCE.md#vm-refactoring-foundation-2026-10-03). ## Read and locate diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index ef93de34..7031796c 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -20,7 +20,7 @@ assignments to execute concurrently against the same interfaces. | Order | Workstream | Depends on | Status | | --- | --- | --- | --- | -| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifest foundation | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifests, status, disposal | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Planned | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Planned | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index ab88eee9..8c89861d 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -91,6 +91,10 @@ tracks existing violations and their migration, not completed implementation. - Reading retained state must not implicitly migrate it. Import legacy formats explicitly, preserve original evidence, and use atomic creation that cannot overwrite a concurrently created authoritative manifest. +- Serialize lifecycle mutations per run. A successful runtime query must + establish absence; transport failure is not absence. Revalidate ownership + after long external calls and address destructive runtime actions by the + recorded immutable identity, not only a reusable name. - Keep subprocess adapters focused: construct executable arguments and environments directly, observe results, and bound waits with diagnostics. Preserve supported workstation backends and static guest builds without diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index dfaeb3ad..e0e6e660 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2436,6 +2436,8 @@ dependencies = [ name = "skillet_vm" version = "0.1.0" dependencies = [ + "nix", + "quick-xml", "serde", "serde_json", "tempfile", diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 5e6b6240..a1c6a8f6 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -12,6 +12,7 @@ use tracing_subscriber::FmtSubscriber; mod cloudflare; mod secret_delivery; mod tailscale; +mod vm; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -124,6 +125,8 @@ enum VmCommands { Destroy(VmDestroyArgs), /// List available host templates and their recorded disposable VMs List(VmListArgs), + /// Inspect the UUID and disks recorded for an owned disposable VM + Status(VmTargetArgs), /// Provision Pi-hole and enroll the disposable VM in Tailscale Provision(VmProvisionArgs), /// Install the current host binary on a retained disposable VM @@ -138,6 +141,12 @@ struct VmCreateArgs { port: u16, } +#[derive(clap::Args, Debug)] +struct VmTargetArgs { + hostname: String, + instance: String, +} + #[derive(clap::Args, Debug)] struct VmDestroyArgs { hostname: String, @@ -260,6 +269,12 @@ fn main() -> Result<()> { command: VmCommands::List(args), }, } => run_vm_list(&args)?, + Commands::Test { + command: + TestCommands::Vm { + command: VmCommands::Status(args), + }, + } => vm::status(&args)?, Commands::Test { command: TestCommands::Vm { @@ -349,11 +364,7 @@ fn run_vm_create(args: &VmCreateArgs) -> Result<()> { } fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { - vm_name(&args.hostname, &args.instance)?; - let helper = butane_root()?.join("bin/test-vm"); - run_helper(&helper, &[&args.hostname, "status", &args.instance])?; - secret_delivery::remove_vm_from_tailscale(args)?; - run_helper(&helper, &[&args.hostname, "destroy", &args.instance]) + vm::destroy(args) } fn run_vm_update(args: &VmDestroyArgs) -> Result<()> { diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 3dbc827a..fc89ee82 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -378,10 +378,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { Ok(()) } -pub(super) fn remove_vm_from_tailscale(args: &VmDestroyArgs) -> Result<()> { - if args.hostname != "clamps" { - return Ok(()); - } +pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { let name = vm_name(&args.hostname, &args.instance)?; let butane = butane_root()?; let run_dir = butane.join("runs").join(&name); diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs new file mode 100644 index 00000000..026e2300 --- /dev/null +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -0,0 +1,48 @@ +//! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. +use super::{butane_root, secret_delivery, VmDestroyArgs, VmTargetArgs}; +use anyhow::{anyhow, Result}; +use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + ManifestStore, RunIdentity, +}; + +pub(super) fn status(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load(&identity)?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let domain = backend.inspect(&run)?.ok_or_else(|| { + anyhow!( + "owned domain is absent; recovery metadata remains at {}", + store.run_dir(&identity).display() + ) + })?; + domain.validate_owned(&run)?; + println!( + "Name: {}\nUUID: {}\nState: {}\nSSH: {}@{}:{}", + domain.name, domain.uuid, domain.state, run.ssh.user, run.ssh.address, run.ssh.port + ); + for disk in domain.disks { + println!("Disk: {}", disk.display()); + } + Ok(()) +} + +pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load(&identity)?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + skillet_vm::lifecycle::destroy(&store, &identity, &backend, |_| { + secret_delivery::remove_vm_external_resources(args) + .map_err(|error| skillet_vm::Error::ExternalCleanup(error.to_string())) + })?; + println!("Disposed {}", identity.domain_name()); + Ok(()) +} + +fn current_uid() -> u32 { + skillet_vm::current_uid() +} diff --git a/ublue/skillet/crates/vm/Cargo.toml b/ublue/skillet/crates/vm/Cargo.toml index 35e38188..a0822744 100644 --- a/ublue/skillet/crates/vm/Cargo.toml +++ b/ublue/skillet/crates/vm/Cargo.toml @@ -13,4 +13,5 @@ serde_json.workspace = true tempfile.workspace = true uuid.workspace = true users.workspace = true - +quick-xml.workspace = true +nix = { workspace = true, features = ["signal", "process"] } diff --git a/ublue/skillet/crates/vm/src/backend.rs b/ublue/skillet/crates/vm/src/backend.rs new file mode 100644 index 00000000..c8f5ed4d --- /dev/null +++ b/ublue/skillet/crates/vm/src/backend.rs @@ -0,0 +1,290 @@ +//! Focused libvirt adapter. A failed connection is never an absent domain. +use crate::{Backend, Connection, Error, Result, VmRun}; +use serde::Deserialize; +use std::{ + collections::BTreeMap, + ffi::OsString, + fs, + os::unix::process::CommandExt as _, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; +use uuid::Uuid; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DomainSnapshot { + pub name: String, + pub uuid: Uuid, + pub state: String, + pub disks: Vec, +} + +impl DomainSnapshot { + pub fn validate_owned(&self, run: &VmRun) -> Result<()> { + let mut actual = self.disks.clone(); + actual.sort(); + let mut expected = vec![run.disk.clone(), run.ignition.clone()]; + expected.sort(); + if self.uuid != run.uuid || self.name != run.identity.domain_name() || actual != expected { + return Err(Error::Invalid( + "domain UUID, name or disks do not match owned run".into(), + )); + } + Ok(()) + } +} + +pub trait VmBackend { + fn inspect(&self, run: &VmRun) -> Result>; + fn define(&self, run: &VmRun, xml: &Path) -> Result<()>; + fn start(&self, run: &VmRun) -> Result<()>; + fn reboot(&self, run: &VmRun) -> Result<()>; + fn stop(&self, run: &VmRun) -> Result<()>; + fn undefine(&self, run: &VmRun) -> Result<()>; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VirshInvocation { + pub program: PathBuf, + pub arguments: Vec, + pub environment: BTreeMap, +} + +#[derive(Debug)] +pub struct VirshOutput { + pub success: bool, + pub code: Option, + pub stdout: String, +} + +/// Execution boundary belongs only to the libvirt adapter, not host recipes. +pub trait VirshExecutor { + fn execute(&self, invocation: &VirshInvocation) -> Result; +} + +pub struct ProcessExecutor { + pub timeout: Duration, +} + +impl Default for ProcessExecutor { + fn default() -> Self { + Self { + timeout: Duration::from_mins(1), + } + } +} + +impl VirshExecutor for ProcessExecutor { + fn execute(&self, invocation: &VirshInvocation) -> Result { + let stdout = tempfile::NamedTempFile::new()?; + let stderr = tempfile::NamedTempFile::new()?; + let mut child = Command::new(&invocation.program) + .args(&invocation.arguments) + .envs(&invocation.environment) + .stdin(Stdio::null()) + .stdout(stdout.as_file().try_clone()?) + .stderr(stderr.as_file().try_clone()?) + .process_group(0) + .spawn()?; + let deadline = Instant::now() + self.timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(20)); + } + result => { + if let Ok(id) = i32::try_from(child.id()) { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(id), + nix::sys::signal::Signal::SIGKILL, + ); + } + let _ = child.kill(); + let _ = child.wait(); + return match result { + Err(error) => Err(error.into()), + _ => Err(Error::Timeout), + }; + } + } + }; + Ok(VirshOutput { + success: status.success(), + code: status.code(), + stdout: fs::read_to_string(stdout.path())?, + }) + } +} + +pub struct VirshBackend { + connection: Connection, + program: PathBuf, + executor: E, +} + +impl VirshBackend { + pub fn for_run(run: &VmRun, flatpak_wrapper: &Path) -> Result { + Self::new( + run.connection.clone(), + run.owner_uid, + flatpak_wrapper, + ProcessExecutor::default(), + ) + } +} + +impl VirshBackend { + pub fn new( + connection: Connection, + owner_uid: u32, + flatpak_wrapper: &Path, + executor: E, + ) -> Result { + connection.validate(owner_uid)?; + let program = match connection.backend { + Backend::Native => "/usr/bin/virsh".into(), + Backend::Flatpak => flatpak_wrapper.into(), + }; + Ok(Self { + connection, + program, + executor, + }) + } + + fn invoke(&self, arguments: &[&str]) -> Result { + let runtime_key = match self.connection.backend { + Backend::Native => "XDG_RUNTIME_DIR", + Backend::Flatpak => "TEST_VM_LIBVIRT_RUNTIME_DIR", + }; + let invocation = VirshInvocation { + program: self.program.clone(), + arguments: ["-c", self.connection.uri.as_str()] + .into_iter() + .chain(arguments.iter().copied()) + .map(OsString::from) + .collect(), + environment: BTreeMap::from([( + runtime_key.into(), + self.connection.runtime_dir.as_os_str().into(), + )]), + }; + let output = self.executor.execute(&invocation)?; + if !output.success { + return Err(Error::Command { + operation: arguments.first().copied().unwrap_or("inspect").into(), + code: output.code, + }); + } + Ok(output.stdout) + } + + fn action(&self, action: &str, run: &VmRun) -> Result<()> { + self.validate_connection(run)?; + self.invoke(&[action, &run.uuid.to_string()])?; + Ok(()) + } + + fn validate_connection(&self, run: &VmRun) -> Result<()> { + if run.connection != self.connection { + return Err(Error::Invalid( + "adapter connection differs from the run manifest".into(), + )); + } + Ok(()) + } +} + +impl VmBackend for VirshBackend { + fn inspect(&self, run: &VmRun) -> Result> { + self.validate_connection(run)?; + let uuids = self.invoke(&["list", "--all", "--uuid"])?; + let uuid = run.uuid.to_string(); + let name = run.identity.domain_name(); + let target = if uuids.lines().any(|line| line.trim() == uuid) { + uuid.as_str() + } else { + let names = self.invoke(&["list", "--all", "--name"])?; + if !names.lines().any(|line| line.trim() == name) { + return Ok(None); + } + name.as_str() + }; + let xml = self.invoke(&["dumpxml", target])?; + let state = self.invoke(&["domstate", target])?.trim().to_string(); + Ok(Some(parse_domain(&xml, state)?)) + } + + fn define(&self, run: &VmRun, xml: &Path) -> Result<()> { + self.validate_connection(run)?; + crate::manifest::reject_symlinks(xml)?; + let definition = parse_domain(&fs::read_to_string(xml)?, "defined".into())?; + definition.validate_owned(run)?; + let xml = xml + .to_str() + .ok_or_else(|| Error::Invalid("non-UTF8 domain XML path".into()))?; + self.invoke(&["define", xml])?; + Ok(()) + } + fn start(&self, run: &VmRun) -> Result<()> { + self.action("start", run) + } + fn reboot(&self, run: &VmRun) -> Result<()> { + self.action("reboot", run) + } + fn stop(&self, run: &VmRun) -> Result<()> { + self.action("destroy", run) + } + fn undefine(&self, run: &VmRun) -> Result<()> { + self.action("undefine", run) + } +} + +#[derive(Deserialize)] +struct DomainXml { + name: String, + uuid: Uuid, + devices: DevicesXml, +} +#[derive(Deserialize)] +struct DevicesXml { + #[serde(rename = "disk", default)] + disks: Vec, +} +#[derive(Deserialize)] +struct DiskXml { + #[serde(rename = "@device")] + kind: String, + source: Option, +} +#[derive(Deserialize)] +struct DiskSourceXml { + #[serde(rename = "@file")] + file: Option, +} + +fn parse_domain(xml: &str, state: String) -> Result { + let domain: DomainXml = quick_xml::de::from_str(xml)?; + let mut disks = Vec::new(); + for disk in domain.devices.disks { + if disk.kind == "disk" { + let source = disk + .source + .and_then(|source| source.file) + .ok_or_else(|| Error::Invalid("VM has a non-file disk source".into()))?; + disks.push(source); + } + } + Ok(DomainSnapshot { + name: domain.name, + uuid: domain.uuid, + state, + disks, + }) +} + +#[cfg(test)] +#[path = "backend/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/backend/tests.rs b/ublue/skillet/crates/vm/src/backend/tests.rs new file mode 100644 index 00000000..2f32f44b --- /dev/null +++ b/ublue/skillet/crates/vm/src/backend/tests.rs @@ -0,0 +1,195 @@ +use super::*; +use crate::manifest::tests::legacy_run; +use std::{cell::RefCell, collections::VecDeque}; + +#[derive(Default)] +struct FakeExecutor { + responses: RefCell>, + calls: RefCell>, +} + +impl VirshExecutor for FakeExecutor { + fn execute(&self, invocation: &VirshInvocation) -> Result { + self.calls.borrow_mut().push(invocation.clone()); + Ok(self + .responses + .borrow_mut() + .pop_front() + .expect("unexpected virsh call")) + } +} + +fn output(stdout: &str) -> VirshOutput { + VirshOutput { + success: true, + code: Some(0), + stdout: stdout.into(), + } +} +fn executor(responses: Vec) -> FakeExecutor { + FakeExecutor { + responses: RefCell::new(responses.into()), + calls: RefCell::default(), + } +} +fn xml(run: &VmRun) -> String { + format!("{}{}", run.identity.domain_name(), run.uuid, run.disk.display(), run.ignition.display()) +} + +#[test] +fn inspect_owned_domain_uses_uuid_and_validates_disk_sources() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![ + output(&run.uuid.to_string()), + output(&xml(&run)), + output("running\n"), + ]), + ) + .unwrap(); + let snapshot = backend.inspect(&run).unwrap().unwrap(); + snapshot.validate_owned(&run).unwrap(); + assert_eq!(snapshot.state, "running"); + let calls = backend.executor.calls.borrow(); + assert_eq!(calls[0].program, Path::new("/usr/bin/virsh")); + assert_eq!( + calls[1].arguments.last().unwrap(), + &OsString::from(run.uuid.to_string()) + ); + assert_eq!( + calls[0].environment[&OsString::from("XDG_RUNTIME_DIR")], + run.connection.runtime_dir.as_os_str() + ); +} + +#[test] +fn missing_domain_requires_successful_lists_not_an_error_message() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![output(""), output("another-domain\n")]), + ) + .unwrap(); + assert!(backend.inspect(&run).unwrap().is_none()); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![VirshOutput { + success: false, + code: Some(1), + stdout: String::new(), + }]), + ) + .unwrap(); + assert!(matches!(backend.inspect(&run), Err(Error::Command { .. }))); +} + +#[test] +fn reused_name_and_renamed_uuid_are_not_owned() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let mut stranger = run.clone(); + stranger.uuid = Uuid::new_v4(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![ + output(""), + output(&run.identity.domain_name()), + output(&xml(&stranger)), + output("running"), + ]), + ) + .unwrap(); + assert!(backend + .inspect(&run) + .unwrap() + .unwrap() + .validate_owned(&run) + .is_err()); + let renamed = xml(&run).replace(&run.identity.domain_name(), "renamed-domain"); + assert!(parse_domain(&renamed, "running".into()) + .unwrap() + .validate_owned(&run) + .is_err()); + let wrong_disk = xml(&run).replace(&run.disk.display().to_string(), "/unrelated/disk"); + assert!(parse_domain(&wrong_disk, "running".into()) + .unwrap() + .validate_owned(&run) + .is_err()); +} + +#[test] +fn flatpak_adapter_uses_wrapper_and_recorded_runtime_and_uuid_actions() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.load(&identity).unwrap(); + run.connection.backend = Backend::Flatpak; + run.connection.runtime_dir = format!("/run/user/{}/skvm", run.owner_uid).into(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/source/bin/virsh"), + executor(vec![output(""), output(""), output(""), output("")]), + ) + .unwrap(); + backend.start(&run).unwrap(); + backend.reboot(&run).unwrap(); + backend.stop(&run).unwrap(); + backend.undefine(&run).unwrap(); + for call in backend.executor.calls.borrow().iter() { + assert_eq!(call.program, Path::new("/source/bin/virsh")); + assert_eq!( + call.environment[&OsString::from("TEST_VM_LIBVIRT_RUNTIME_DIR")], + run.connection.runtime_dir.as_os_str() + ); + assert_eq!( + call.arguments.last().unwrap(), + &OsString::from(run.uuid.to_string()) + ); + } +} + +#[test] +fn malformed_or_non_file_disks_fail_closed() { + assert!(parse_domain("not XML", "running".into()).is_err()); + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let block = xml(&run).replace("source file=", "source dev="); + assert!(parse_domain(&block, "running".into()).is_err()); +} + +#[test] +fn subprocess_arguments_are_literal_and_timeout_is_bounded() { + let executor = ProcessExecutor { + timeout: Duration::from_secs(2), + }; + let invocation = VirshInvocation { + program: "/usr/bin/printf".into(), + arguments: vec!["%s".into(), "literal $(not-a-command); words".into()], + environment: BTreeMap::new(), + }; + assert_eq!( + executor.execute(&invocation).unwrap().stdout, + "literal $(not-a-command); words" + ); + let executor = ProcessExecutor { + timeout: Duration::from_millis(30), + }; + let invocation = VirshInvocation { + program: "/usr/bin/sleep".into(), + arguments: vec!["5".into()], + environment: BTreeMap::new(), + }; + let start = Instant::now(); + assert!(matches!(executor.execute(&invocation), Err(Error::Timeout))); + assert!(start.elapsed() < Duration::from_secs(2)); +} diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index 67b81dfe..41b28743 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -1,5 +1,7 @@ //! Workstation VM identity and durable ownership. No vault or guest resources. +pub mod backend; +pub mod lifecycle; pub mod manifest; pub use manifest::{ @@ -14,6 +16,23 @@ pub enum Error { Io(#[from] std::io::Error), #[error("VM manifest JSON: {0}")] Json(#[from] serde_json::Error), + #[error("invalid libvirt XML: {0}")] + Xml(#[from] quick_xml::DeError), + #[error("libvirt command {operation} failed (exit {code:?}); inspect the selected runtime")] + Command { + operation: String, + code: Option, + }, + #[error("libvirt command exceeded its timeout")] + Timeout, + #[error("external VM cleanup failed: {0}")] + ExternalCleanup(String), + #[error("VM run is busy; retry after its current operation finishes")] + Busy, } pub type Result = std::result::Result; + +pub fn current_uid() -> u32 { + users::get_current_uid() +} diff --git a/ublue/skillet/crates/vm/src/lifecycle.rs b/ublue/skillet/crates/vm/src/lifecycle.rs new file mode 100644 index 00000000..08b93760 --- /dev/null +++ b/ublue/skillet/crates/vm/src/lifecycle.rs @@ -0,0 +1,75 @@ +use crate::{backend::VmBackend, Error, ManifestStore, Phase, Result, RunIdentity, VmRun}; +use nix::fcntl::{Flock, FlockArg}; +use std::{ + fs::{self, File, OpenOptions}, + os::unix::fs::OpenOptionsExt as _, +}; + +/// Dispose external resources, the UUID-verified domain, then local artifacts. +/// The guest need not exist; runtime errors and cleanup failures retain state. +pub fn destroy( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + mut external_cleanup: impl FnMut(&VmRun) -> Result<()>, +) -> Result<()> { + store.load(identity)?; + let dir = store.run_dir(identity); + let lock_path = dir.join(".vm.lock"); + crate::manifest::reject_symlinks(&lock_path)?; + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .mode(0o600) + .open(lock_path)?; + let _lock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, error)| { + if error == nix::errno::Errno::EWOULDBLOCK { + Error::Busy + } else { + Error::Io(std::io::Error::from_raw_os_error(error as i32)) + } + })?; + let run = store.load(identity)?; + if let Some(domain) = backend.inspect(&run)? { + domain.validate_owned(&run)?; + } + let mut run = store.import(identity)?; + if !matches!( + run.phase, + Phase::ExternalCleanupComplete | Phase::DomainRemoved + ) { + external_cleanup(&run)?; + run.phase = Phase::ExternalCleanupComplete; + store.save(&run)?; + } + // Recheck after potentially long provider calls. UUID addressing prevents a + // name reused by another domain from being destroyed by a stale observation. + if let Some(domain) = backend.inspect(&run)? { + domain.validate_owned(&run)?; + if domain.state != "shut off" { + backend.stop(&run)?; + } + if let Some(domain) = backend.inspect(&run)? { + domain.validate_owned(&run)?; + backend.undefine(&run)?; + } + } + if backend.inspect(&run)?.is_some() { + return Err(Error::Invalid("domain still exists after disposal".into())); + } + run.phase = Phase::DomainRemoved; + store.save(&run)?; + // Validate once more before deleting only the owned run directory. + store.load(identity)?; + fs::remove_dir_all(&dir)?; + if let Some(parent) = dir.parent() { + File::open(parent)?.sync_all()?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "lifecycle/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/lifecycle/tests.rs b/ublue/skillet/crates/vm/src/lifecycle/tests.rs new file mode 100644 index 00000000..6788a191 --- /dev/null +++ b/ublue/skillet/crates/vm/src/lifecycle/tests.rs @@ -0,0 +1,204 @@ +use super::*; +use crate::{backend::DomainSnapshot, manifest::tests::legacy_run}; +use std::cell::{Cell, RefCell}; + +struct FakeBackend { + domain: RefCell>, + unavailable: Cell, + fail_undefine: Cell, + calls: RefCell>, +} +impl FakeBackend { + fn new(run: &VmRun, present: bool) -> Self { + Self { + domain: RefCell::new(present.then(|| DomainSnapshot { + name: run.identity.domain_name(), + uuid: run.uuid, + state: "running".into(), + disks: vec![run.disk.clone(), run.ignition.clone()], + })), + unavailable: Cell::new(false), + fail_undefine: Cell::new(false), + calls: RefCell::default(), + } + } +} +impl VmBackend for FakeBackend { + fn inspect(&self, _: &VmRun) -> Result> { + if self.unavailable.get() { + return Err(Error::Command { + operation: "list".into(), + code: Some(1), + }); + } + Ok(self.domain.borrow().clone()) + } + fn define(&self, _: &VmRun, _: &std::path::Path) -> Result<()> { + unreachable!() + } + fn start(&self, _: &VmRun) -> Result<()> { + unreachable!() + } + fn reboot(&self, _: &VmRun) -> Result<()> { + unreachable!() + } + fn stop(&self, run: &VmRun) -> Result<()> { + self.domain.borrow().as_ref().unwrap().validate_owned(run)?; + self.calls.borrow_mut().push("stop"); + self.domain.borrow_mut().as_mut().unwrap().state = "shut off".into(); + Ok(()) + } + fn undefine(&self, run: &VmRun) -> Result<()> { + self.domain.borrow().as_ref().unwrap().validate_owned(run)?; + if self.fail_undefine.get() { + return Err(Error::Command { + operation: "undefine".into(), + code: Some(1), + }); + } + self.calls.borrow_mut().push("undefine"); + *self.domain.borrow_mut() = None; + Ok(()) + } +} + +#[test] +fn destruction_orders_external_domain_then_owned_artifacts() { + let (tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + let unrelated = tmp.path().join("unrelated"); + fs::write(&unrelated, "keep").unwrap(); + destroy(&store, &identity, &backend, |_| { + assert!(backend.calls.borrow().is_empty()); + assert!(store.run_dir(&identity).exists()); + Ok(()) + }) + .unwrap(); + assert_eq!(*backend.calls.borrow(), ["stop", "undefine"]); + assert!(!store.run_dir(&identity).exists()); + assert_eq!(fs::read_to_string(unrelated).unwrap(), "keep"); +} + +#[test] +fn already_absent_domain_still_runs_external_cleanup() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, false); + let cleaned = Cell::new(false); + destroy(&store, &identity, &backend, |_| { + cleaned.set(true); + Ok(()) + }) + .unwrap(); + assert!(cleaned.get()); + assert!(backend.calls.borrow().is_empty()); + assert!(!store.run_dir(&identity).exists()); +} + +#[test] +fn failed_external_cleanup_preserves_state_and_retry_converges() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + let journal = store.run_dir(&identity).join("tailscale-pending"); + fs::write(&journal, "pending").unwrap(); + assert!(destroy(&store, &identity, &backend, |_| Err( + Error::ExternalCleanup("fixture failure".into()) + )) + .is_err()); + assert_eq!(store.load(&identity).unwrap().phase, Phase::Started); + assert!(journal.exists()); + assert!(backend.calls.borrow().is_empty()); + destroy(&store, &identity, &backend, |_| { + fs::remove_file(&journal)?; + Ok(()) + }) + .unwrap(); + assert!(!store.run_dir(&identity).exists()); +} + +#[test] +fn local_cleanup_retry_does_not_repeat_completed_external_cleanup() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + backend.fail_undefine.set(true); + let cleanup_calls = Cell::new(0); + assert!(destroy(&store, &identity, &backend, |_| { + cleanup_calls.set(cleanup_calls.get() + 1); + Ok(()) + }) + .is_err()); + assert_eq!( + store.load(&identity).unwrap().phase, + Phase::ExternalCleanupComplete + ); + assert_eq!(cleanup_calls.get(), 1); + backend.fail_undefine.set(false); + destroy(&store, &identity, &backend, |_| { + cleanup_calls.set(cleanup_calls.get() + 1); + Ok(()) + }) + .unwrap(); + assert_eq!(cleanup_calls.get(), 1); + assert!(!store.run_dir(&identity).exists()); +} + +#[test] +fn inaccessible_or_mismatched_domain_refuses_external_and_local_cleanup() { + for mismatch in [false, true] { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + if mismatch { + backend.domain.borrow_mut().as_mut().unwrap().uuid = uuid::Uuid::new_v4(); + } else { + backend.unavailable.set(true); + } + assert!(destroy(&store, &identity, &backend, |_| panic!( + "must not clean unverified ownership" + )) + .is_err()); + assert!(store.run_dir(&identity).join("run.conf").exists()); + assert!(!store.run_dir(&identity).join("vm.json").exists()); + assert!(backend.calls.borrow().is_empty()); + } +} + +#[test] +fn changed_ownership_after_external_cleanup_is_refused() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + assert!(destroy(&store, &identity, &backend, |_| { + backend.domain.borrow_mut().as_mut().unwrap().uuid = uuid::Uuid::new_v4(); + Ok(()) + }) + .is_err()); + assert_eq!( + store.load(&identity).unwrap().phase, + Phase::ExternalCleanupComplete + ); + assert!(backend.calls.borrow().is_empty()); +} + +#[test] +fn concurrent_disposal_is_refused_before_effects() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(store.run_dir(&identity).join(".vm.lock")) + .unwrap(); + let _lock = Flock::lock(file, FlockArg::LockExclusiveNonblock).unwrap(); + assert!(matches!( + destroy(&store, &identity, &backend, |_| panic!("busy")), + Err(Error::Busy) + )); + assert!(backend.calls.borrow().is_empty()); +} diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs index 6e1a4e87..2ea0f28a 100644 --- a/ublue/skillet/crates/vm/src/manifest.rs +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -421,4 +421,4 @@ pub(crate) fn reject_symlinks(path: &Path) -> Result<()> { #[cfg(test)] #[path = "manifest/tests.rs"] -mod tests; +pub(crate) mod tests; diff --git a/ublue/skillet/crates/vm/src/manifest/tests.rs b/ublue/skillet/crates/vm/src/manifest/tests.rs index cf0fdb80..b896bb3f 100644 --- a/ublue/skillet/crates/vm/src/manifest/tests.rs +++ b/ublue/skillet/crates/vm/src/manifest/tests.rs @@ -1,7 +1,7 @@ use super::*; use std::os::unix::fs::symlink; -fn legacy_run() -> (tempfile::TempDir, ManifestStore, RunIdentity) { +pub(crate) fn legacy_run() -> (tempfile::TempDir, ManifestStore, RunIdentity) { let tmp = tempfile::tempdir().unwrap(); let identity = RunIdentity::new("fixture", "retained-2").unwrap(); let store = ManifestStore::new(tmp.path(), users::get_current_uid()).unwrap(); From cbfa1c24cf6fce976a9d194b46e23e1f2ed9981d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 12:44:52 +0200 Subject: [PATCH 126/252] refactor(vm): share catalog and Cargo artifact discovery --- ublue/butane/ACCEPTANCE.md | 15 ++ ublue/butane/README.md | 6 +- ublue/butane/bin/test-vm | 67 +-------- ublue/design/skillet-architecture.md | 4 +- ublue/design/smoke-vms.md | 2 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 16 ++- ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/skillet/AGENTS.md | 3 + ublue/skillet/crates/cli/src/main.rs | 39 ++---- ublue/skillet/crates/cli/src/vm.rs | 54 +++++++- ublue/skillet/crates/vm/src/artifacts.rs | 128 ++++++++++++++++++ .../skillet/crates/vm/src/artifacts/tests.rs | 64 +++++++++ ublue/skillet/crates/vm/src/backend.rs | 48 ++----- ublue/skillet/crates/vm/src/backend/tests.rs | 2 +- ublue/skillet/crates/vm/src/catalog.rs | 64 +++++++++ ublue/skillet/crates/vm/src/catalog/tests.rs | 53 ++++++++ ublue/skillet/crates/vm/src/lib.rs | 10 +- ublue/skillet/crates/vm/src/manifest.rs | 4 + ublue/skillet/crates/vm/src/process.rs | 47 +++++++ 19 files changed, 485 insertions(+), 143 deletions(-) create mode 100644 ublue/skillet/crates/vm/src/artifacts.rs create mode 100644 ublue/skillet/crates/vm/src/artifacts/tests.rs create mode 100644 ublue/skillet/crates/vm/src/catalog.rs create mode 100644 ublue/skillet/crates/vm/src/catalog/tests.rs create mode 100644 ublue/skillet/crates/vm/src/process.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index f533fa0c..a1a33440 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -20,6 +20,21 @@ creating a smoke VM in another panel; it and its artifacts were left alone. No vault, Cloudflare or Tailscale request was needed for this slice. +## Rust catalog and Cargo artifact selection, 2026-10-03 + +- Source slice: follows `d799ace`. Both list entry points delegate to Rust + catalog/inspection, without rewriting manifests. Cargo JSON selection now + supplies the container runner. Twenty-six VM tests cover catalog availability, + partial-run discovery, non-default artifact paths/profiles, missing/duplicate + artifacts, and existing ownership/cleanup cases. +- Passed static-musl formatting, workspace all-target pedantic Clippy, + workspace tests/build, helper ShellCheck and whitespace checks. Also passed: + `CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target ./target/x86_64-unknown-linux-musl/debug/skillet test run beezelbot --phase base --image fedora:latest`. + The runner selected the cache directory's Cargo-reported host binary rather + than a conventional workspace path; both applies passed and cleanup completed. +- Live VM catalog/runtime checks and retained-run lifecycle acceptance remain + deferred. No VM mutation, vault prompt or external provider call was attempted. + ## Rust VM inspection and disposal, 2026-10-03 - Source slice: follows `544b8e5`. `test vm status` and both destroy entry diff --git a/ublue/butane/README.md b/ublue/butane/README.md index cd8aec13..f289a919 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -77,7 +77,9 @@ hyphens. The helper records `clamps` plus `smoke` as the internal domain `list` reports each host as `available` when both its Butane config and host crate exist. Instance rows show the libvirt state; `missing`, `mismatch`, or -`invalid` identify runs needing inspection. The Skillet wrapper runs both +`invalid` identify runs needing inspection. `unavailable` means the recorded +runtime could not be queried; it does not establish that a guest is absent. +The Skillet wrapper runs both create and ready: ```bash @@ -88,7 +90,7 @@ cargo run --release -p skillet -- test vm provision clamps smoke cargo run --release -p skillet -- test vm destroy clamps smoke ``` -`status` and `destroy` now use the Rust ownership/cleanup implementation; +`list`, `status` and `destroy` now use the Rust ownership/cleanup implementation; the corresponding `test-vm` commands delegate to it. Reading status does not import or rewrite a legacy run. Disposal creates a private versioned manifest while retaining original files until cleanup finishes. If the owned guest is diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 9ff61218..25d279d5 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -104,80 +104,23 @@ host_exists() { host_available() { [[ -f "$script_dir/../$1.bu" && -f "$script_dir/../../skillet/crates/hosts/$1/Cargo.toml" ]] } -list_host() { - local listed_host="$1" availability=unavailable found=false listed_run listed_name listed_instance listed_state listed_port listed_runtime listed_backend recorded_uuid actual_uuid - local -a listed_virsh - if host_available "$listed_host"; then availability=available; fi - for listed_run in "$root"/"$listed_host"-test-*/run.conf; do - [[ -f "$listed_run" ]] || continue - found=true - run_dir="$(dirname "$listed_run")" - listed_name="$(basename "$run_dir")" - listed_instance="${listed_name#"$listed_host"-test-}" - listed_state=invalid - listed_port=- - if [[ ! -L "$run_dir" && ! -L "$listed_run" && "$(config_value vm)" == "$listed_name" && "$(config_value host)" == "$listed_host" && "$(config_value uri)" == "$uri" ]]; then - listed_port="$(config_value ssh_port)" - listed_runtime="$(config_value runtime_dir)" - listed_backend="$(config_value backend)" - [[ -n "$listed_backend" ]] || listed_backend=native - if [[ "$listed_port" =~ ^22[0-9][0-9]$ && -f "$run_dir/domain.uuid" && ( ( "$listed_backend" == flatpak && "$listed_runtime" == "/run/user/$(id -u)/skvm" ) || ( "$listed_backend" == native && ( "$listed_runtime" == "/run/user/$(id -u)" || "$listed_runtime" == /run/user/"$(id -u)"/* ) ) ) ]]; then - recorded_uuid="$(cat "$run_dir/domain.uuid")" - if [[ "$listed_backend" == flatpak ]]; then - listed_virsh=(env TEST_VM_LIBVIRT_RUNTIME_DIR="$listed_runtime" "$script_dir/virsh") - else - listed_virsh=(env XDG_RUNTIME_DIR="$listed_runtime" /usr/bin/virsh) - fi - if [[ "$recorded_uuid" =~ ^[0-9a-f-]{36}$ ]] && actual_uuid="$("${listed_virsh[@]}" -c "$uri" domuuid "$listed_name" 2>/dev/null)"; then - if [[ "$actual_uuid" == "$recorded_uuid" ]]; then - listed_state="$("${listed_virsh[@]}" -c "$uri" domstate "$listed_name" 2>/dev/null)" || listed_state=unknown - else - listed_state=mismatch - fi - else - listed_state=missing - fi - fi - fi - printf '%-12s %-12s %-16s %-12s %s\n' "$listed_host" "$availability" "$listed_instance" "$listed_state" "$listed_port" - done - if [[ "$found" == false ]]; then - printf '%-12s %-12s %-16s %-12s %s\n' "$listed_host" "$availability" - none - - fi -} -list_header() { printf '%-12s %-12s %-16s %-12s %s\n' HOST TEMPLATE INSTANCE STATE PORT; } - if (($# == 1)) && [[ "$1" == list ]]; then - [[ ! -L "$root" ]] || { echo "Symlinked artifact root refused" >&2; exit 1; } - mapfile -t hosts < <( - { - for candidate in "$script_dir"/../*.bu; do - if [[ -f "$candidate" ]]; then basename "$candidate" .bu; fi - done - for candidate in "$script_dir"/../../skillet/crates/hosts/*/Cargo.toml; do - if [[ -f "$candidate" ]]; then basename "$(dirname "$candidate")"; fi - done - } | sort -u - ) - list_header - for candidate in "${hosts[@]}"; do list_host "$candidate"; done - exit 0 + cd "$script_dir/../../skillet" + exec cargo run --quiet --release -p skillet -- test vm list fi (($# >= 2)) || usage host="$1" operation="$2" shift 2 if [[ ! "$host" =~ ^[a-z][a-z0-9-]*$ ]] || - { [[ "$operation" != destroy && "$operation" != status ]] && ! host_exists "$host"; }; then + { [[ "$operation" != destroy && "$operation" != status && "$operation" != list ]] && ! host_exists "$host"; }; then echo "Unknown host: $host" >&2 exit 2 fi if [[ "$operation" == list ]]; then (($# == 0)) || usage - [[ ! -L "$root" ]] || { echo "Symlinked artifact root refused" >&2; exit 1; } - list_header - list_host "$host" - exit 0 + cd "$script_dir/../../skillet" + exec cargo run --quiet --release -p skillet -- test vm list "$host" fi (($# >= 1)) || usage instance="$1" diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index f2008cc3..a5728b43 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -8,7 +8,9 @@ Status: planned; the review and plans do not establish runtime acceptance. Implementation progress: `skillet_vm` now supplies validated run identity and versioned manifests with explicit, non-destructive legacy import. Rust status and disposal use a focused libvirt adapter and a locked cleanup orchestrator; -remaining VM commands stay on their existing paths until their slices land. +listing is also in Rust. Shared Cargo JSON artifact discovery selects the exact +reported binary and now serves the container runner. Remaining VM commands +stay on their existing paths until their slices land. Keep application crates as reusable recipes. Separate canonical host profiles and composition from guest runtime adapters, CLI parsing, workstation diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 355b89e9..98245550 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -30,7 +30,7 @@ Move VM lifecycle management from Bash to one Rust orchestrator, as recorded in [the architecture decision](skillet-architecture.md) and [the migration plan](../plan/REFACTOR-01-VM-LIFECYCLE.md). Preserve public commands, retained manifests, native/Flatpak support, and guest assertions. -Status and destruction now delegate to Rust. Disposal validates identity, +Listing, status and destruction now delegate to Rust. Disposal validates identity, cleans external resources, then removes the UUID-addressed domain and local artifacts. It also works with an already-absent guest; connection failures and partial cleanup preserve journals for retry. A per-run lock prevents concurrent diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 5f04534f..3672efd6 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -16,10 +16,13 @@ native/Flatpak libvirt adapter, and one locked cleanup orchestrator. Both public destroy paths use it. Successful lists distinguish an absent guest from a failed connection; UUID and disk ownership are checked before cleanup and again after external calls. Failed cleanup retains its manifest/journals. -Twenty-one VM tests pass. No live VM was changed or imported. +Rust also owns template/run listing. Shared Cargo JSON artifact discovery is +implemented and used by the routine container runner; a custom target-directory +integration check selects the reported binary. Twenty-six VM tests pass. +No live VM was changed or imported. -Creation, readiness, list/update/reboot and SSH still use Bash. Next: shared -transport, catalog/artifact handling, recoverable creation/readiness and the +Creation, readiness, update/reboot and SSH still use Bash. Next: shared +transport, recoverable creation/readiness and the remaining public-helper delegation. Disposal live acceptance remains deferred. See [validation evidence](../butane/ACCEPTANCE.md#vm-refactoring-foundation-2026-10-03). @@ -31,10 +34,9 @@ remaining public-helper delegation. Disposal live acceptance remains deferred. S - `../skillet/crates/cli/src/main.rs` VM dispatch and `secret_delivery.rs` provisioning, external ownership, cleanup, and VM SSH. -Current defects: Bash destruction skips external cleanup and deletes its -metadata; Rust destruction requires a live domain before cleanup; failed -creation can leave artifacts without a usable lifecycle record. Readiness, -ownership checks, manifest parsing, and target selection are duplicated. +Review findings: disposal used to bypass external cleanup or require a live +guest; those paths are now fixed. Remaining: failed creation can leave artifacts +without a usable lifecycle record; readiness and target selection are duplicated. ## Implementation sequence diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 7031796c..5944b68f 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -20,7 +20,7 @@ assignments to execute concurrently against the same interfaces. | Order | Workstream | Depends on | Status | | --- | --- | --- | --- | -| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifests, status, disposal | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifests, list/status/disposal, Cargo artifacts | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Planned | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Planned | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 8c89861d..78ed409f 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -88,6 +88,9 @@ tracks existing violations and their migration, not completed implementation. - Select build artifacts from Cargo's reported outputs and honor its target, profile, and configured target directory. Do not discover the new build by scanning potentially stale binaries at conventional paths. +- Compilation and artifact lookup belong to one operation. Consumers use + that operation's reported executables and reject incomplete or ambiguous + results; do not rebuild in one path and rediscover binaries in another. - Reading retained state must not implicitly migrate it. Import legacy formats explicitly, preserve original evidence, and use atomic creation that cannot overwrite a concurrently created authoritative manifest. diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index a1c6a8f6..2dbe8634 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -384,12 +384,7 @@ fn run_vm_update(args: &VmDestroyArgs) -> Result<()> { } fn run_vm_list(args: &VmListArgs) -> Result<()> { - let helper = butane_root()?.join("bin/test-vm"); - if let Some(hostname) = &args.hostname { - run_helper(&helper, &[hostname, "list"]) - } else { - run_helper(&helper, &["list"]) - } + vm::list(args) } fn vm_name(hostname: &str, instance: &str) -> Result { @@ -443,28 +438,16 @@ fn run_container_test(args: &ContainerArgs) -> Result<()> { } let root = workspace_root()?; let package = format!("skillet-{}", args.hostname); - let status = Command::new("cargo") - .current_dir(&root) - .args(["build", "--package", &package]) - .status() - .context("building host binary failed")?; - if !status.success() { - return Err(anyhow!("cargo build --package {package} failed")); - } - let mut binary = None; - for subdir in [ - "target/x86_64-unknown-linux-musl/debug", - "target/debug", - "target/x86_64-unknown-linux-musl/release", - "target/release", - ] { - let candidate = root.join(subdir).join(&package); - if candidate.is_file() { - binary = Some(fs::canonicalize(candidate)?); - break; - } - } - let binary = binary.ok_or_else(|| anyhow!("built binary {package} not found under target"))?; + let artifacts = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace: &root, + packages: &[&package], + target: "x86_64-unknown-linux-musl", + profile: "dev", + })?; + let binary = artifacts + .get(&package) + .ok_or_else(|| anyhow!("Cargo did not report {package}"))?; + info!(artifact = %binary.display(), "Using Cargo-reported host binary"); let id = format!( "{}-{}", std::process::id(), diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 026e2300..20ae5a4a 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -1,5 +1,7 @@ //! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. -use super::{butane_root, secret_delivery, VmDestroyArgs, VmTargetArgs}; +use super::{ + butane_root, secret_delivery, workspace_root, VmDestroyArgs, VmListArgs, VmTargetArgs, +}; use anyhow::{anyhow, Result}; use skillet_vm::{ backend::{VirshBackend, VmBackend}, @@ -46,3 +48,53 @@ pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { fn current_uid() -> u32 { skillet_vm::current_uid() } + +pub(super) fn list(args: &VmListArgs) -> Result<()> { + let butane = butane_root()?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let mut templates = skillet_vm::catalog::templates(&butane, &workspace_root()?)?; + if let Some(host) = &args.hostname { + RunIdentity::new(host, "catalog")?; + let available = templates.get(host).copied(); + if available.is_none() && skillet_vm::catalog::recorded_runs(&store, host)?.is_empty() { + return Err(anyhow!("unknown host: {host}")); + } + templates = std::collections::BTreeMap::from([(host.clone(), available.unwrap_or(false))]); + } + println!( + "{:<12} {:<12} {:<16} {:<12} PORT", + "HOST", "TEMPLATE", "INSTANCE", "STATE" + ); + for (host, available) in templates { + let availability = if available { + "available" + } else { + "unavailable" + }; + let runs = skillet_vm::catalog::recorded_runs(&store, &host)?; + if runs.is_empty() { + println!("{host:<12} {availability:<12} {:<16} {:<12} -", "-", "none"); + } + for identity in runs { + let (state, port) = match store.load(&identity) { + Err(_) => ("invalid".into(), "-".into()), + Ok(run) => { + let result = VirshBackend::for_run(&run, &butane.join("bin/virsh")) + .and_then(|backend| backend.inspect(&run)); + let state = match result { + Ok(None) => "missing".into(), + Ok(Some(domain)) if domain.validate_owned(&run).is_ok() => domain.state, + Ok(Some(_)) => "mismatch".into(), + Err(_) => "unavailable".into(), + }; + (state, run.ssh.port.to_string()) + } + }; + println!( + "{host:<12} {availability:<12} {:<16} {state:<12} {port}", + identity.instance() + ); + } + } + Ok(()) +} diff --git a/ublue/skillet/crates/vm/src/artifacts.rs b/ublue/skillet/crates/vm/src/artifacts.rs new file mode 100644 index 00000000..f403ae06 --- /dev/null +++ b/ublue/skillet/crates/vm/src/artifacts.rs @@ -0,0 +1,128 @@ +//! Select binaries only from the successful Cargo invocation's JSON output. +use crate::{Error, Result}; +use serde::Deserialize; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + process::Command, +}; + +pub struct BuildRequest<'a> { + pub workspace: &'a Path, + pub packages: &'a [&'a str], + pub target: &'a str, + pub profile: &'a str, +} + +pub fn build(request: &BuildRequest<'_>) -> Result> { + let mut command = Command::new("cargo"); + command.current_dir(request.workspace).args([ + "build", + "--message-format=json-render-diagnostics", + "--target", + request.target, + "--profile", + request.profile, + ]); + for package in request.packages { + command.args(["--package", package]); + } + let output = crate::process::capture(command, std::time::Duration::from_mins(30))?; + if !output.status.success() { + // Compiler diagnostics contain source details, not credential payloads. + // Return the rendered diagnostic without dumping arbitrary tool output. + let rendered = diagnostics(&output.stdout); + let diagnostic = if rendered.is_empty() { + String::from_utf8_lossy(&output.stderr).into_owned() + } else { + rendered + }; + return Err(Error::Build { + code: output.status.code(), + diagnostic, + }); + } + let artifacts = reported_artifacts(&output.stdout, request.packages, request.workspace)?; + artifacts + .into_iter() + .map(|(name, path)| { + if !path.is_file() { + return Err(Error::Invalid("Cargo-reported executable is absent".into())); + } + Ok((name, std::fs::canonicalize(path)?)) + }) + .collect() +} + +#[derive(Deserialize)] +struct CargoMessage { + reason: String, + target: Option, + executable: Option, +} +#[derive(Deserialize)] +struct CargoTarget { + name: String, + kind: Vec, +} + +pub fn reported_artifacts( + output: &[u8], + packages: &[&str], + workspace: &Path, +) -> Result> { + let mut artifacts = BTreeMap::new(); + for line in output + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + { + let message: CargoMessage = serde_json::from_slice(line)?; + if message.reason != "compiler-artifact" { + continue; + } + if let (Some(target), Some(path)) = (message.target, message.executable) { + if target.kind.iter().any(|kind| kind == "bin") + && packages.contains(&target.name.as_str()) + { + let path = if path.is_absolute() { + path + } else { + workspace.join(path) + }; + if artifacts.insert(target.name, path).is_some() { + return Err(Error::Invalid( + "multiple Cargo executables match one requested binary".into(), + )); + } + } + } + } + if packages + .iter() + .any(|package| !artifacts.contains_key(*package)) + { + return Err(Error::Invalid( + "successful Cargo build did not report every requested executable".into(), + )); + } + Ok(artifacts) +} + +fn diagnostics(output: &[u8]) -> String { + output + .split(|byte| *byte == b'\n') + .filter_map(|line| serde_json::from_slice::(line).ok()) + .filter_map(|message| { + message + .get("message") + .and_then(|message| message.get("rendered")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +#[path = "artifacts/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/artifacts/tests.rs b/ublue/skillet/crates/vm/src/artifacts/tests.rs new file mode 100644 index 00000000..3731b369 --- /dev/null +++ b/ublue/skillet/crates/vm/src/artifacts/tests.rs @@ -0,0 +1,64 @@ +use super::*; + +fn artifact(name: &str, path: &str) -> String { + serde_json::json!({ "reason": "compiler-artifact", "target": { "name": name, "kind": ["bin"] }, "executable": path }).to_string() +} + +#[test] +fn selects_reported_custom_target_and_profile_without_scanning_stale_binaries() { + let output = format!( + "{}\n{}\n{{\"reason\":\"build-finished\",\"success\":true}}\n", + artifact("skillet", "/custom/target/other-profile/skillet"), + artifact( + "skillet-fixture", + "/custom/target/other-profile/skillet-fixture" + ) + ); + let result = reported_artifacts( + output.as_bytes(), + &["skillet", "skillet-fixture"], + Path::new("/workspace"), + ) + .unwrap(); + assert_eq!( + result["skillet"], + Path::new("/custom/target/other-profile/skillet") + ); + assert_eq!( + result["skillet-fixture"], + Path::new("/custom/target/other-profile/skillet-fixture") + ); +} + +#[test] +fn missing_ambiguous_and_malformed_artifacts_fail() { + assert!(reported_artifacts( + b"{\"reason\":\"build-finished\"}\n", + &["skillet"], + Path::new("/workspace") + ) + .is_err()); + let duplicate = format!( + "{}\n{}", + artifact("skillet", "/a/skillet"), + artifact("skillet", "/b/skillet") + ); + assert!( + reported_artifacts(duplicate.as_bytes(), &["skillet"], Path::new("/workspace")).is_err() + ); + assert!(reported_artifacts(b"not JSON", &["skillet"], Path::new("/workspace")).is_err()); +} + +#[test] +fn relative_artifacts_resolve_against_the_cargo_working_directory() { + let result = reported_artifacts( + artifact("skillet", "custom/debug/skillet").as_bytes(), + &["skillet"], + Path::new("/workspace"), + ) + .unwrap(); + assert_eq!( + result["skillet"], + Path::new("/workspace/custom/debug/skillet") + ); +} diff --git a/ublue/skillet/crates/vm/src/backend.rs b/ublue/skillet/crates/vm/src/backend.rs index c8f5ed4d..1b96b55b 100644 --- a/ublue/skillet/crates/vm/src/backend.rs +++ b/ublue/skillet/crates/vm/src/backend.rs @@ -5,10 +5,9 @@ use std::{ collections::BTreeMap, ffi::OsString, fs, - os::unix::process::CommandExt as _, path::{Path, PathBuf}, - process::{Command, Stdio}, - time::{Duration, Instant}, + process::Command, + time::Duration, }; use uuid::Uuid; @@ -77,43 +76,16 @@ impl Default for ProcessExecutor { impl VirshExecutor for ProcessExecutor { fn execute(&self, invocation: &VirshInvocation) -> Result { - let stdout = tempfile::NamedTempFile::new()?; - let stderr = tempfile::NamedTempFile::new()?; - let mut child = Command::new(&invocation.program) + let mut command = Command::new(&invocation.program); + command .args(&invocation.arguments) - .envs(&invocation.environment) - .stdin(Stdio::null()) - .stdout(stdout.as_file().try_clone()?) - .stderr(stderr.as_file().try_clone()?) - .process_group(0) - .spawn()?; - let deadline = Instant::now() + self.timeout; - let status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) if Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(20)); - } - result => { - if let Ok(id) = i32::try_from(child.id()) { - let _ = nix::sys::signal::killpg( - nix::unistd::Pid::from_raw(id), - nix::sys::signal::Signal::SIGKILL, - ); - } - let _ = child.kill(); - let _ = child.wait(); - return match result { - Err(error) => Err(error.into()), - _ => Err(Error::Timeout), - }; - } - } - }; + .envs(&invocation.environment); + let output = crate::process::capture(command, self.timeout)?; Ok(VirshOutput { - success: status.success(), - code: status.code(), - stdout: fs::read_to_string(stdout.path())?, + success: output.status.success(), + code: output.status.code(), + stdout: String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid("libvirt output is not UTF8".into()))?, }) } } diff --git a/ublue/skillet/crates/vm/src/backend/tests.rs b/ublue/skillet/crates/vm/src/backend/tests.rs index 2f32f44b..968bf206 100644 --- a/ublue/skillet/crates/vm/src/backend/tests.rs +++ b/ublue/skillet/crates/vm/src/backend/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::manifest::tests::legacy_run; -use std::{cell::RefCell, collections::VecDeque}; +use std::{cell::RefCell, collections::VecDeque, time::Instant}; #[derive(Default)] struct FakeExecutor { diff --git a/ublue/skillet/crates/vm/src/catalog.rs b/ublue/skillet/crates/vm/src/catalog.rs new file mode 100644 index 00000000..c9052bd2 --- /dev/null +++ b/ublue/skillet/crates/vm/src/catalog.rs @@ -0,0 +1,64 @@ +//! Source templates and recorded runs, without importing or mutating them. +use crate::{manifest::reject_symlinks, ManifestStore, Result, RunIdentity}; +use std::{collections::BTreeMap, fs, path::Path}; + +pub fn templates(butane: &Path, workspace: &Path) -> Result> { + let mut hosts = BTreeMap::new(); + for entry in fs::read_dir(butane)? { + let path = entry?.path(); + if path.extension().is_some_and(|extension| extension == "bu") { + if let Some(host) = path.file_stem().and_then(|name| name.to_str()) { + if RunIdentity::new(host, "catalog").is_ok() { + hosts.insert(host.to_string(), false); + } + } + } + } + let crates = workspace.join("crates/hosts"); + if crates.exists() { + for entry in fs::read_dir(&crates)? { + let entry = entry?; + if entry.path().join("Cargo.toml").is_file() { + if let Some(host) = entry.file_name().to_str() { + if RunIdentity::new(host, "catalog").is_ok() { + hosts.insert(host.to_string(), false); + } + } + } + } + } + for (host, available) in &mut hosts { + *available = butane.join(format!("{host}.bu")).is_file() + && crates.join(host).join("Cargo.toml").is_file(); + } + Ok(hosts) +} + +pub fn recorded_runs(store: &ManifestStore, host: &str) -> Result> { + RunIdentity::new(host, "catalog")?; + let root = store.root(); + reject_symlinks(root)?; + if !root.exists() { + return Ok(Vec::new()); + } + let mut runs = Vec::new(); + let prefix = format!("{host}-test-"); + for entry in fs::read_dir(root)? { + let entry = entry?; + if let Some(instance) = entry + .file_name() + .to_str() + .and_then(|name| name.strip_prefix(&prefix)) + { + if let Ok(identity) = RunIdentity::new(host, instance) { + runs.push(identity); + } + } + } + runs.sort_by(|left, right| left.instance().cmp(right.instance())); + Ok(runs) +} + +#[cfg(test)] +#[path = "catalog/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/catalog/tests.rs b/ublue/skillet/crates/vm/src/catalog/tests.rs new file mode 100644 index 00000000..8643a104 --- /dev/null +++ b/ublue/skillet/crates/vm/src/catalog/tests.rs @@ -0,0 +1,53 @@ +use super::*; + +#[test] +fn template_availability_requires_both_sources_and_is_sorted() { + let dir = tempfile::tempdir().unwrap(); + let butane = dir.path().join("butane"); + let workspace = dir.path().join("workspace"); + fs::create_dir(&butane).unwrap(); + fs::create_dir_all(workspace.join("crates/hosts/complete")).unwrap(); + fs::create_dir_all(workspace.join("crates/hosts/crate-only")).unwrap(); + fs::write(butane.join("complete.bu"), "fixture").unwrap(); + fs::write(butane.join("template-only.bu"), "fixture").unwrap(); + fs::write(butane.join("Invalid.bu"), "fixture").unwrap(); + fs::write( + workspace.join("crates/hosts/complete/Cargo.toml"), + "fixture", + ) + .unwrap(); + fs::write( + workspace.join("crates/hosts/crate-only/Cargo.toml"), + "fixture", + ) + .unwrap(); + assert_eq!( + templates(&butane, &workspace).unwrap(), + BTreeMap::from([ + ("complete".into(), true), + ("crate-only".into(), false), + ("template-only".into(), false) + ]) + ); +} + +#[test] +fn runs_are_discovered_without_reading_or_rewriting_partial_manifests() { + let dir = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(dir.path(), users::get_current_uid()).unwrap(); + for name in [ + "fixture-test-2", + "fixture-test-a", + "other-test-a", + "fixture-test-Invalid", + ] { + fs::create_dir(dir.path().join(name)).unwrap(); + } + let runs = recorded_runs(&store, "fixture").unwrap(); + assert_eq!( + runs.iter().map(RunIdentity::instance).collect::>(), + ["2", "a"] + ); + assert!(!store.run_dir(&runs[0]).join("vm.json").exists()); + assert!(recorded_runs(&store, "../escape").is_err()); +} diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index 41b28743..9feb8809 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -1,8 +1,11 @@ //! Workstation VM identity and durable ownership. No vault or guest resources. +pub mod artifacts; pub mod backend; +pub mod catalog; pub mod lifecycle; pub mod manifest; +mod process; pub use manifest::{ Backend, Connection, Environment, ManifestStore, Phase, RunIdentity, SshTarget, VmRun, @@ -23,12 +26,17 @@ pub enum Error { operation: String, code: Option, }, - #[error("libvirt command exceeded its timeout")] + #[error("VM tooling command exceeded its timeout")] Timeout, #[error("external VM cleanup failed: {0}")] ExternalCleanup(String), #[error("VM run is busy; retry after its current operation finishes")] Busy, + #[error("Cargo build failed (exit {code:?}): {diagnostic}")] + Build { + code: Option, + diagnostic: String, + }, } pub type Result = std::result::Result; diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs index 2ea0f28a..6f3055b2 100644 --- a/ublue/skillet/crates/vm/src/manifest.rs +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -147,6 +147,10 @@ impl ManifestStore { self.root.join(identity.domain_name()) } + pub fn root(&self) -> &Path { + &self.root + } + /// Read without migrating or contacting a runtime; safe for retained runs. pub fn load(&self, identity: &RunIdentity) -> Result { let dir = self.run_dir(identity); diff --git a/ublue/skillet/crates/vm/src/process.rs b/ublue/skillet/crates/vm/src/process.rs new file mode 100644 index 00000000..14fefbca --- /dev/null +++ b/ublue/skillet/crates/vm/src/process.rs @@ -0,0 +1,47 @@ +//! Bounded direct subprocess capture; callers own their typed tool contracts. +use crate::{Error, Result}; +use std::{ + fs, + os::unix::process::CommandExt as _, + process::{Command, Output, Stdio}, + time::{Duration, Instant}, +}; + +pub(crate) fn capture(mut command: Command, timeout: Duration) -> Result { + let stdout = tempfile::NamedTempFile::new()?; + let stderr = tempfile::NamedTempFile::new()?; + let mut child = command + .stdin(Stdio::null()) + .stdout(stdout.as_file().try_clone()?) + .stderr(stderr.as_file().try_clone()?) + .process_group(0) + .spawn()?; + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(20)); + } + result => { + if let Ok(id) = i32::try_from(child.id()) { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(id), + nix::sys::signal::Signal::SIGKILL, + ); + } + let _ = child.kill(); + let _ = child.wait(); + return match result { + Err(error) => Err(error.into()), + _ => Err(Error::Timeout), + }; + } + } + }; + Ok(Output { + status, + stdout: fs::read(stdout.path())?, + stderr: fs::read(stderr.path())?, + }) +} From 67d8f540680ceaf811df4681b660ee9c40587dbf Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 12:49:45 +0200 Subject: [PATCH 127/252] fix(vm): preserve manifests through artifact cleanup failures --- ublue/butane/ACCEPTANCE.md | 10 +++++ ublue/skillet/AGENTS.md | 3 ++ ublue/skillet/crates/vm/src/lifecycle.rs | 42 +++++++++++++++++++ .../skillet/crates/vm/src/lifecycle/tests.rs | 30 +++++++++++++ 4 files changed, 85 insertions(+) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index a1a33440..7582a44e 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -20,6 +20,16 @@ creating a smoke VM in another panel; it and its artifacts were left alone. No vault, Cloudflare or Tailscale request was needed for this slice. +## Interrupted local artifact removal, 2026-10-03 + +- Follow-up to `d799ace`: disposal now removes application artifacts before + deleting authoritative/legacy recovery records. A deterministic local + removal failure leaves both the versioned manifest and original identity/ + hash records readable; retry finishes artifact deletion. +- Twenty-seven VM tests, workspace tests, all-target pedantic Clippy, + formatting, routine Fedora container integration and whitespace checks pass. + This is local fault injection; live VM disposal remains deferred. + ## Rust catalog and Cargo artifact selection, 2026-10-03 - Source slice: follows `d799ace`. Both list entry points delegate to Rust diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 78ed409f..c1c450a8 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -98,6 +98,9 @@ tracks existing violations and their migration, not completed implementation. establish absence; transport failure is not absence. Revalidate ownership after long external calls and address destructive runtime actions by the recorded immutable identity, not only a reusable name. +- Remove recovery metadata only after owned artifact deletion succeeds, too. + A recursive directory deletion must not erase its own retry journal before + discovering that a later child cannot be removed. - Keep subprocess adapters focused: construct executable arguments and environments directly, observe results, and bound waits with diagnostics. Preserve supported workstation backends and static guest builds without diff --git a/ublue/skillet/crates/vm/src/lifecycle.rs b/ublue/skillet/crates/vm/src/lifecycle.rs index 08b93760..e973cb23 100644 --- a/ublue/skillet/crates/vm/src/lifecycle.rs +++ b/ublue/skillet/crates/vm/src/lifecycle.rs @@ -63,6 +63,9 @@ pub fn destroy( store.save(&run)?; // Validate once more before deleting only the owned run directory. store.load(identity)?; + remove_artifacts(&dir, remove_entry)?; + // Only recovery records remain. Application artifacts have all been removed + // successfully before deleting the authoritative manifest. fs::remove_dir_all(&dir)?; if let Some(parent) = dir.parent() { File::open(parent)?.sync_all()?; @@ -70,6 +73,45 @@ pub fn destroy( Ok(()) } +fn remove_artifacts( + dir: &std::path::Path, + mut remove: impl FnMut(&std::path::Path) -> Result<()>, +) -> Result<()> { + let mut entries = fs::read_dir(dir)?.collect::>>()?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + if matches!( + entry.file_name().to_str(), + Some( + "vm.json" + | "run.conf" + | "domain.uuid" + | ".vm.lock" + | "skillet.sha256" + | "skillet-generic.sha256" + | "deployed-skillet.sha256" + | "deployed-skillet-generic.sha256" + | "tailscale-pending" + | "tailscale.json" + | "cloudflare.json" + ) + ) { + continue; + } + remove(&entry.path())?; + } + Ok(()) +} + +fn remove_entry(path: &std::path::Path) -> Result<()> { + if fs::symlink_metadata(path)?.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + Ok(()) +} + #[cfg(test)] #[path = "lifecycle/tests.rs"] mod tests; diff --git a/ublue/skillet/crates/vm/src/lifecycle/tests.rs b/ublue/skillet/crates/vm/src/lifecycle/tests.rs index 6788a191..accaaa2c 100644 --- a/ublue/skillet/crates/vm/src/lifecycle/tests.rs +++ b/ublue/skillet/crates/vm/src/lifecycle/tests.rs @@ -202,3 +202,33 @@ fn concurrent_disposal_is_refused_before_effects() { )); assert!(backend.calls.borrow().is_empty()); } + +#[test] +fn partial_artifact_removal_preserves_authoritative_and_legacy_recovery_records() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.import(&identity).unwrap(); + run.phase = Phase::DomainRemoved; + store.save(&run).unwrap(); + let dir = store.run_dir(&identity); + fs::write(dir.join("aa-artifact"), "owned data").unwrap(); + fs::write(dir.join("zz-artifact"), "owned data").unwrap(); + let result = remove_artifacts(&dir, |path| { + if path.file_name().unwrap() == "zz-artifact" { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected removal failure", + ) + .into()); + } + remove_entry(path) + }); + assert!(result.is_err()); + assert!(!dir.join("aa-artifact").exists()); + assert!(dir.join("zz-artifact").exists()); + assert_eq!(store.load(&identity).unwrap().phase, Phase::DomainRemoved); + assert!(dir.join("run.conf").exists()); + assert!(dir.join("domain.uuid").exists()); + assert!(dir.join("skillet.sha256").exists()); + remove_artifacts(&dir, remove_entry).unwrap(); + assert!(dir.join("vm.json").exists()); +} From fc88433c9505e330ea4fca1b60c34669a4b7c1d8 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 12:59:48 +0200 Subject: [PATCH 128/252] refactor(vm): add bounded shared SSH transport --- ublue/butane/ACCEPTANCE.md | 15 ++ ublue/design/skillet-architecture.md | 4 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 10 +- ublue/skillet/AGENTS.md | 3 + ublue/skillet/Cargo.lock | 1 + ublue/skillet/crates/vm/Cargo.toml | 1 + ublue/skillet/crates/vm/src/lib.rs | 8 + ublue/skillet/crates/vm/src/process.rs | 159 +++++++++++--- ublue/skillet/crates/vm/src/process/tests.rs | 40 ++++ ublue/skillet/crates/vm/src/transport.rs | 198 ++++++++++++++++++ .../skillet/crates/vm/src/transport/tests.rs | 126 +++++++++++ 11 files changed, 530 insertions(+), 35 deletions(-) create mode 100644 ublue/skillet/crates/vm/src/process/tests.rs create mode 100644 ublue/skillet/crates/vm/src/transport.rs create mode 100644 ublue/skillet/crates/vm/src/transport/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 7582a44e..6f9e6655 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -20,6 +20,21 @@ creating a smoke VM in another panel; it and its artifacts were left alone. No vault, Cloudflare or Tailscale request was needed for this slice. +## Shared SSH transport contract, 2026-10-03 + +- Source slice: follows `67d8f54`. Added `GuestTransport` and `SshTransport` + for literal executable/argument requests, explicit target and host-key policy, + noninteractive SSH/SCP, and stdin delivery. No live SSH command was run. +- Subprocess I/O is memory-backed with concurrent pipe readers/writer, bounded + output and complete-operation timeout. Tests cover a 1 MiB round trip, a + blocked stdin writer, unconsumed input on nonzero exit, key/target validation, + shell metacharacter quoting, upload traversal refusal, DNS/IPv6 targets and + explicit host-key enrollment versus verification. Thirty-five VM tests pass. +- Passed static-musl workspace format, all-target pedantic Clippy, + workspace tests/build and the routine Fedora base container integration. + Readiness/delivery migration and live transport acceptance remain pending; + no vault unlock, provider mutation or change to the user's VM was attempted. + ## Interrupted local artifact removal, 2026-10-03 - Follow-up to `d799ace`: disposal now removes application artifacts before diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index a5728b43..c04e88de 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -10,7 +10,9 @@ versioned manifests with explicit, non-destructive legacy import. Rust status and disposal use a focused libvirt adapter and a locked cleanup orchestrator; listing is also in Rust. Shared Cargo JSON artifact discovery selects the exact reported binary and now serves the container runner. Remaining VM commands -stay on their existing paths until their slices land. +stay on their existing paths until their slices land. The shared SSH transport +is implemented but not yet wired into readiness or credential delivery. Its +bounded I/O stays in memory; payload copies are zeroized after stdin transfer. Keep application crates as reusable recipes. Separate canonical host profiles and composition from guest runtime adapters, CLI parsing, workstation diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 3672efd6..bb623cf5 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -18,11 +18,15 @@ from a failed connection; UUID and disk ownership are checked before cleanup and again after external calls. Failed cleanup retains its manifest/journals. Rust also owns template/run listing. Shared Cargo JSON artifact discovery is implemented and used by the routine container runner; a custom target-directory -integration check selects the reported binary. Twenty-six VM tests pass. +integration check selects the reported binary. `GuestTransport`/`SshTransport` +now provide explicit target/key policy, literal remote executable arguments, +noninteractive execution and upload. Subprocess stdin/output stay in memory, +with bounded concurrent I/O and zeroized copied stdin. Thirty-five VM tests pass. No live VM was changed or imported. -Creation, readiness, update/reboot and SSH still use Bash. Next: shared -transport, recoverable creation/readiness and the +Creation, readiness, update/reboot and interactive SSH still use Bash. The +shared transport is not yet wired into their operations or credential delivery. +Next: migrate update/readiness to that transport, recoverable creation and the remaining public-helper delegation. Disposal live acceptance remains deferred. See [validation evidence](../butane/ACCEPTANCE.md#vm-refactoring-foundation-2026-10-03). diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index c1c450a8..4f097e42 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -65,6 +65,9 @@ tracks existing violations and their migration, not completed implementation. policy and target inputs rather than duplicating algorithms or branching on particular deployment names. Keep secret values off command lines and out of manifests, recordings, and errors. +- Credential-capable transport keeps stdin and captured output in memory. + Drain stdout/stderr concurrently with stdin, bound the complete operation + including stream completion, and zeroize owned payload copies after transfer. - Use a common ownership representation for named and numeric identities. Model application identity, host filesystem ownership, and namespace mapping separately. Existing numeric ownership must not require a named host account. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index e0e6e660..6e43ab66 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2444,6 +2444,7 @@ dependencies = [ "thiserror 1.0.69", "users", "uuid", + "zeroize", ] [[package]] diff --git a/ublue/skillet/crates/vm/Cargo.toml b/ublue/skillet/crates/vm/Cargo.toml index a0822744..27e0bb02 100644 --- a/ublue/skillet/crates/vm/Cargo.toml +++ b/ublue/skillet/crates/vm/Cargo.toml @@ -15,3 +15,4 @@ uuid.workspace = true users.workspace = true quick-xml.workspace = true nix = { workspace = true, features = ["signal", "process"] } +zeroize = "1.9" diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index 9feb8809..ecb734b4 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -6,6 +6,7 @@ pub mod catalog; pub mod lifecycle; pub mod manifest; mod process; +pub mod transport; pub use manifest::{ Backend, Connection, Environment, ManifestStore, Phase, RunIdentity, SshTarget, VmRun, @@ -37,6 +38,13 @@ pub enum Error { code: Option, diagnostic: String, }, + #[error( + "guest operation {operation} failed (exit {code:?}); inspect retained guest diagnostics" + )] + Guest { + operation: String, + code: Option, + }, } pub type Result = std::result::Result; diff --git a/ublue/skillet/crates/vm/src/process.rs b/ublue/skillet/crates/vm/src/process.rs index 14fefbca..76f35afd 100644 --- a/ublue/skillet/crates/vm/src/process.rs +++ b/ublue/skillet/crates/vm/src/process.rs @@ -1,47 +1,144 @@ -//! Bounded direct subprocess capture; callers own their typed tool contracts. +//! Bounded direct subprocess capture. Payloads and output stay in memory. use crate::{Error, Result}; use std::{ - fs, + io::{Read, Write as _}, os::unix::process::CommandExt as _, - process::{Command, Output, Stdio}, + process::{Child, Command, Output, Stdio}, + sync::mpsc::{self, Receiver}, time::{Duration, Instant}, }; -pub(crate) fn capture(mut command: Command, timeout: Duration) -> Result { - let stdout = tempfile::NamedTempFile::new()?; - let stderr = tempfile::NamedTempFile::new()?; - let mut child = command - .stdin(Stdio::null()) - .stdout(stdout.as_file().try_clone()?) - .stderr(stderr.as_file().try_clone()?) +const OUTPUT_LIMIT: u64 = 64 * 1024 * 1024; + +pub(crate) fn capture(command: Command, timeout: Duration) -> Result { + capture_with_input(command, timeout, None) +} + +pub(crate) fn capture_with_input( + mut command: Command, + timeout: Duration, + input: Option<&[u8]>, +) -> Result { + let child = command + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .process_group(0) .spawn()?; + let mut managed = ManagedChild { + child, + complete: false, + }; + let stdout = read_stream( + managed + .child + .stdout + .take() + .ok_or_else(|| Error::Invalid("tool stdout unavailable".into()))?, + ); + let stderr = read_stream( + managed + .child + .stderr + .take() + .ok_or_else(|| Error::Invalid("tool stderr unavailable".into()))?, + ); + let writer = if let Some(input) = input { + let mut pipe = managed + .child + .stdin + .take() + .ok_or_else(|| Error::Invalid("tool stdin unavailable".into()))?; + let bytes = zeroize::Zeroizing::new(input.to_vec()); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(pipe.write_all(&bytes)); + }); + Some(receiver) + } else { + None + }; let deadline = Instant::now() + timeout; let status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) if Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(20)); - } - result => { - if let Ok(id) = i32::try_from(child.id()) { - let _ = nix::sys::signal::killpg( - nix::unistd::Pid::from_raw(id), - nix::sys::signal::Signal::SIGKILL, - ); - } - let _ = child.kill(); - let _ = child.wait(); - return match result { - Err(error) => Err(error.into()), - _ => Err(Error::Timeout), - }; - } + if let Some(status) = managed.child.try_wait()? { + break status; + } + if Instant::now() >= deadline { + return Err(Error::Timeout); } + std::thread::sleep(Duration::from_millis(20)); }; + let stdout = receive(&stdout, deadline)??; + let stderr = receive(&stderr, deadline)??; + if let Some(writer) = writer { + let written = receive(&writer, deadline)?; + if status.success() { + written?; + } + } + managed.complete = true; Ok(Output { status, - stdout: fs::read(stdout.path())?, - stderr: fs::read(stderr.path())?, + stdout, + stderr, }) } + +fn read_stream(stream: impl Read + Send + 'static) -> Receiver>> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let result = (|| { + let mut bytes = Vec::new(); + stream.take(OUTPUT_LIMIT + 1).read_to_end(&mut bytes)?; + if u64::try_from(bytes.len()) + .map_err(|_| Error::Invalid("tool output size overflow".into()))? + > OUTPUT_LIMIT + { + return Err(Error::Invalid( + "tool output exceeded the memory limit".into(), + )); + } + Ok(bytes) + })(); + let _ = sender.send(result); + }); + receiver +} + +fn receive(receiver: &Receiver, deadline: Instant) -> Result { + receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => Error::Timeout, + mpsc::RecvTimeoutError::Disconnected => { + Error::Invalid("tool stream worker stopped".into()) + } + }) +} + +struct ManagedChild { + child: Child, + complete: bool, +} +impl Drop for ManagedChild { + fn drop(&mut self) { + if !self.complete { + if let Ok(id) = i32::try_from(self.child.id()) { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(id), + nix::sys::signal::Signal::SIGKILL, + ); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +#[cfg(test)] +#[path = "process/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/process/tests.rs b/ublue/skillet/crates/vm/src/process/tests.rs new file mode 100644 index 00000000..4432e21e --- /dev/null +++ b/ublue/skillet/crates/vm/src/process/tests.rs @@ -0,0 +1,40 @@ +use super::*; + +#[test] +fn stdin_and_large_output_are_memory_backed_and_do_not_deadlock() { + let input = vec![b'x'; 1024 * 1024]; + let output = capture_with_input( + Command::new("/usr/bin/cat"), + Duration::from_secs(5), + Some(&input), + ) + .unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout, input); + assert!(output.stderr.is_empty()); +} + +#[test] +fn blocked_stdin_writer_and_noninteractive_child_are_bounded() { + let mut command = Command::new("/usr/bin/sleep"); + command.arg("5"); + let input = vec![b'x'; 1024 * 1024]; + let start = Instant::now(); + assert!(matches!( + capture_with_input(command, Duration::from_millis(30), Some(&input)), + Err(Error::Timeout) + )); + assert!(start.elapsed() < Duration::from_secs(2)); +} + +#[test] +fn nonzero_exit_is_preserved_when_the_child_does_not_consume_stdin() { + let output = capture_with_input( + Command::new("/usr/bin/false"), + Duration::from_secs(2), + Some(&vec![b'x'; 1024 * 1024]), + ) + .unwrap(); + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(1)); +} diff --git a/ublue/skillet/crates/vm/src/transport.rs b/ublue/skillet/crates/vm/src/transport.rs new file mode 100644 index 00000000..b370fa04 --- /dev/null +++ b/ublue/skillet/crates/vm/src/transport.rs @@ -0,0 +1,198 @@ +//! Verified SSH execution/file delivery with explicit target and trust policy. +use crate::{Error, Result, SshTarget}; +use std::{ + path::Path, + process::{Command, Output}, + time::Duration, +}; + +pub struct GuestCommand<'a> { + pub program: &'a str, + pub arguments: &'a [&'a str], +} + +pub trait GuestTransport { + fn execute(&self, command: &GuestCommand<'_>, input: Option<&[u8]>) -> Result; + fn upload(&self, source: &Path, destination: &str) -> Result<()>; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HostKeyPolicy { + Enroll, + Verify, +} + +pub struct SshTransport { + target: SshTarget, + policy: HostKeyPolicy, + pub timeout: Duration, +} + +impl SshTransport { + pub fn new(target: SshTarget, policy: HostKeyPolicy) -> Result { + if target.user.is_empty() + || target.user.starts_with('-') + || !target + .user + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + || !valid_address(&target.address) + || target.port == 0 + { + return Err(Error::Invalid("invalid SSH user, address or port".into())); + } + for path in [&target.identity, &target.known_hosts] { + crate::manifest::reject_symlinks(path)?; + if !path.is_absolute() { + return Err(Error::Invalid("SSH key paths must be absolute".into())); + } + } + if !target.identity.is_file() + || (policy == HostKeyPolicy::Verify && !target.known_hosts.is_file()) + { + return Err(Error::Invalid( + "SSH identity or recorded host key is absent".into(), + )); + } + Ok(Self { + target, + policy, + timeout: Duration::from_mins(10), + }) + } + + fn options(&self, command: &mut Command, port_flag: &str) { + command + .args([ + "-o", + "BatchMode=yes", + "-o", + "IdentitiesOnly=yes", + "-o", + "ConnectTimeout=5", + "-o", + "ConnectionAttempts=1", + "-o", + ]) + .arg(format!( + "StrictHostKeyChecking={}", + match self.policy { + HostKeyPolicy::Enroll => "accept-new", + HostKeyPolicy::Verify => "yes", + } + )) + .arg("-o") + .arg(format!( + "UserKnownHostsFile={}", + self.target.known_hosts.display() + )) + .arg("-i") + .arg(&self.target.identity) + .arg(port_flag) + .arg(self.target.port.to_string()); + } + + fn remote_target(&self) -> String { + format!("{}@{}", self.target.user, self.target.address) + } + + fn command(&self, request: &GuestCommand<'_>) -> Result { + if request.program.is_empty() + || request.program.contains('\0') + || request + .arguments + .iter() + .any(|argument| argument.contains('\0')) + { + return Err(Error::Invalid( + "empty or NUL-containing guest command".into(), + )); + } + let mut command = Command::new("ssh"); + command.arg("-T"); + self.options(&mut command, "-p"); + // OpenSSH uses a remote shell to launch executables. Quote each argument + // literally; never construct a shell program or interpolate payloads. + let remote = std::iter::once(request.program) + .chain(request.arguments.iter().copied()) + .map(quote_argument) + .collect::>() + .join(" "); + command.arg(self.remote_target()).arg(remote); + Ok(command) + } + + fn upload_command(&self, source: &Path, destination: &str) -> Result { + if !destination.starts_with("/var/tmp/") + || !destination.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.') + }) + || destination + .split('/') + .any(|part| matches!(part, "." | "..")) + { + return Err(Error::Invalid( + "upload destination must be a literal staging path".into(), + )); + } + crate::manifest::reject_symlinks(source)?; + if !source.is_absolute() || !source.is_file() { + return Err(Error::Invalid( + "upload source must be an existing absolute file".into(), + )); + } + let mut command = Command::new("scp"); + command.arg("-O"); + self.options(&mut command, "-P"); + let target = match self.target.address.parse::() { + Ok(std::net::IpAddr::V6(_)) => { + format!("{}@[{}]", self.target.user, self.target.address) + } + _ => self.remote_target(), + }; + command.arg(source).arg(format!("{target}:{destination}")); + Ok(command) + } +} + +impl GuestTransport for SshTransport { + fn execute(&self, command: &GuestCommand<'_>, input: Option<&[u8]>) -> Result { + crate::process::capture_with_input(self.command(command)?, self.timeout, input) + } + fn upload(&self, source: &Path, destination: &str) -> Result<()> { + let output = + crate::process::capture(self.upload_command(source, destination)?, self.timeout)?; + if !output.status.success() { + return Err(Error::Guest { + operation: "upload".into(), + code: output.status.code(), + }); + } + Ok(()) + } +} + +fn quote_argument(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn valid_address(value: &str) -> bool { + if value.parse::().is_ok() { + return true; + } + !value.is_empty() + && value.len() <= 253 + && value.trim_end_matches('.').split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +#[cfg(test)] +#[path = "transport/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/transport/tests.rs b/ublue/skillet/crates/vm/src/transport/tests.rs new file mode 100644 index 00000000..9780f544 --- /dev/null +++ b/ublue/skillet/crates/vm/src/transport/tests.rs @@ -0,0 +1,126 @@ +use super::*; +use std::{fs, os::unix::fs::symlink}; + +fn target(dir: &Path) -> SshTarget { + let identity = dir.join("id_ed25519"); + let known_hosts = dir.join("known_hosts"); + fs::write(&identity, "dummy fixture key").unwrap(); + fs::write(&known_hosts, "dummy fixture host key").unwrap(); + SshTarget { + user: "fixture".into(), + address: "127.0.0.1".into(), + port: 2209, + identity, + known_hosts, + } +} + +#[test] +fn remote_executable_arguments_are_quoted_and_transport_is_noninteractive() { + let dir = tempfile::tempdir().unwrap(); + let transport = SshTransport::new(target(dir.path()), HostKeyPolicy::Verify).unwrap(); + let command = transport + .command(&GuestCommand { + program: "sudo", + arguments: &["-n", "printf", "$(touch nope); 'value'\n"], + }) + .unwrap(); + let arguments: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(arguments.contains(&"BatchMode=yes")); + assert!(arguments.contains(&"StrictHostKeyChecking=yes")); + assert!(arguments.contains(&"-T")); + assert!(arguments.contains(&"2209")); + assert_eq!(arguments[arguments.len() - 2], "fixture@127.0.0.1"); + assert_eq!( + arguments.last().unwrap(), + &"'sudo' '-n' 'printf' '$(touch nope); '\\''value'\\''\n'" + ); +} + +#[test] +fn enroll_policy_does_not_replace_a_recorded_host_key_and_verify_requires_it() { + let dir = tempfile::tempdir().unwrap(); + let target = target(dir.path()); + fs::remove_file(&target.known_hosts).unwrap(); + assert!(SshTransport::new(target.clone(), HostKeyPolicy::Verify).is_err()); + let transport = SshTransport::new(target, HostKeyPolicy::Enroll).unwrap(); + let command = transport + .command(&GuestCommand { + program: "true", + arguments: &[], + }) + .unwrap(); + assert!(command + .get_args() + .any(|arg| arg == "StrictHostKeyChecking=accept-new")); +} + +#[test] +fn upload_uses_recorded_port_and_refuses_shell_or_path_traversal() { + let dir = tempfile::tempdir().unwrap(); + let transport = SshTransport::new(target(dir.path()), HostKeyPolicy::Verify).unwrap(); + let source = dir.path().join("binary"); + fs::write(&source, "fixture").unwrap(); + let command = transport + .upload_command(&source, "/var/tmp/skillet-update") + .unwrap(); + let arguments: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(arguments.windows(2).any(|pair| pair == ["-P", "2209"])); + assert_eq!( + arguments.last().unwrap(), + &"fixture@127.0.0.1:/var/tmp/skillet-update" + ); + for destination in [ + "/etc/binary", + "/var/tmp/../etc/binary", + "/var/tmp/a;command", + "/var/tmp/$(command)", + "/var/tmp/a b", + ] { + assert!(transport.upload_command(&source, destination).is_err()); + } +} + +#[test] +fn malformed_targets_and_linked_keys_are_refused_before_execution() { + let dir = tempfile::tempdir().unwrap(); + let original = target(dir.path()); + let mut malformed = original.clone(); + malformed.user = "user;command".into(); + assert!(SshTransport::new(malformed, HostKeyPolicy::Verify).is_err()); + let mut malformed = original.clone(); + malformed.address = "host;command".into(); + assert!(SshTransport::new(malformed, HostKeyPolicy::Verify).is_err()); + let mut linked = original.clone(); + linked.identity = dir.path().join("linked"); + symlink(&original.identity, &linked.identity).unwrap(); + assert!(SshTransport::new(linked, HostKeyPolicy::Verify).is_err()); +} + +#[test] +fn generic_transport_supports_dns_and_ipv6_targets_without_option_injection() { + let dir = tempfile::tempdir().unwrap(); + let mut address = target(dir.path()); + address.address = "host.example.invalid".into(); + assert!(SshTransport::new(address.clone(), HostKeyPolicy::Verify).is_ok()); + address.user = "-option".into(); + assert!(SshTransport::new(address, HostKeyPolicy::Verify).is_err()); + let mut address = target(dir.path()); + address.address = "::1".into(); + let transport = SshTransport::new(address, HostKeyPolicy::Verify).unwrap(); + let source = dir.path().join("binary"); + fs::write(&source, "fixture").unwrap(); + let command = transport + .upload_command(&source, "/var/tmp/skillet") + .unwrap(); + assert_eq!( + command.get_args().last().unwrap(), + "fixture@[::1]:/var/tmp/skillet" + ); +} From 4c7092e43ea1bb799f722599b8a8052ab9d45537 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 13:13:02 +0200 Subject: [PATCH 129/252] refactor(vm): migrate retained binary updates to Rust --- ublue/butane/ACCEPTANCE.md | 17 ++ ublue/butane/README.md | 13 +- ublue/butane/bin/test-vm | 27 +--- ublue/design/skillet-architecture.md | 5 +- ublue/design/smoke-vms.md | 7 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 11 +- ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/skillet/AGENTS.md | 3 + ublue/skillet/Cargo.lock | 2 + ublue/skillet/crates/cli/src/main.rs | 14 +- ublue/skillet/crates/cli/src/vm.rs | 52 ++++++ ublue/skillet/crates/vm/Cargo.toml | 2 + ublue/skillet/crates/vm/src/delivery.rs | 143 +++++++++++++++++ ublue/skillet/crates/vm/src/delivery/tests.rs | 151 ++++++++++++++++++ ublue/skillet/crates/vm/src/lib.rs | 1 + ublue/skillet/crates/vm/src/lifecycle.rs | 23 +-- .../skillet/crates/vm/src/lifecycle/tests.rs | 6 +- ublue/skillet/crates/vm/src/manifest.rs | 43 ++++- ublue/skillet/crates/vm/src/manifest/tests.rs | 27 ++++ 19 files changed, 472 insertions(+), 77 deletions(-) create mode 100644 ublue/skillet/crates/vm/src/delivery.rs create mode 100644 ublue/skillet/crates/vm/src/delivery/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 6f9e6655..fed418ea 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,22 @@ # Clamps VM acceptance log +## Retained VM update migration, 2026-10-03 + +- Rust now builds both binaries from Cargo-reported paths, checks recorded + domain ownership, and delivers through the shared noninteractive SSH adapter. + Updates and disposal use the same per-run lock. Original captured hashes are + preserved; deployed hashes are saved only after both installed files verify. +- Adapter tests cover repeated delivery without upload/install, interrupted + installation and retry, hash mismatch, cleanup-phase refusal, shared lock + contention and rejection of nonregular manifest files. Forty VM tests pass. +- Passed `cargo fmt --all --check`, offline workspace all-target pedantic + Clippy with `-D warnings`, offline workspace tests and static musl build. + Passed `skillet test run beezelbot --phase base --image fedora:latest`: + both applies succeeded and the repeat issued no service start/restart. + ShellCheck for `bin/test-vm` and `git diff --check` passed. +- No live VM was contacted or changed: VM creation is running in the user's + other panel. Live update, boot, and migration acceptance remain unverified. + ## VM refactoring foundation, 2026-10-03 - Source slice: after roadmap commit `471068f`, added `skillet_vm` manifest diff --git a/ublue/butane/README.md b/ublue/butane/README.md index f289a919..49c3c421 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -71,7 +71,7 @@ hyphens. The helper records `clamps` plus `smoke` as the internal domain ./bin/test-vm clamps ssh smoke # interactive guest shell ./bin/test-vm clamps reboot smoke # reboot the guest ./bin/test-vm clamps ready smoke # rerun readiness after a reboot or repair -./bin/test-vm clamps update smoke # install the latest built host binary +./bin/test-vm clamps update smoke # build and install both current binaries ./bin/test-vm clamps destroy smoke # delete the VM, disk, test key, and run artifacts ``` @@ -90,7 +90,7 @@ cargo run --release -p skillet -- test vm provision clamps smoke cargo run --release -p skillet -- test vm destroy clamps smoke ``` -`list`, `status` and `destroy` now use the Rust ownership/cleanup implementation; +`list`, `status`, `update` and `destroy` now use the Rust ownership implementation; the corresponding `test-vm` commands delegate to it. Reading status does not import or rewrite a legacy run. Disposal creates a private versioned manifest while retaining original files until cleanup finishes. If the owned guest is @@ -109,10 +109,11 @@ both binaries over SSH after first boot and installs them at `/var/usrlocal/bin/skillet` and `/var/usrlocal/bin/skillet-clamps`. `ready` uses those captured binaries on every run. The shared base unit reads the stable host profile from `/etc/skillet/host`, so a test VM hostname can differ -from its host profile. After editing Skillet, build both binaries with -`cargo build --release --target x86_64-unknown-linux-musl -p skillet -p skillet-clamps` -and use `cargo run --release -p skillet -- test vm update clamps smoke` to -install them on a retained VM. `provision` delivers a disposable +from its host profile. After editing Skillet, use +`cargo run --release -p skillet -- test vm update clamps smoke` to build and +install both binaries on a retained VM. This uses Cargo's reported executables, +including a configured target directory, and verifies installed hashes and +permissions before updating `vm.json`. `provision` delivers a disposable Pi-hole password and runs the credential-loaded full apply; `--rotate` replaces that disposable password. The shared uCore bootstrap script also lives in `/var/usrlocal/bin`. diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 25d279d5..4f9f3769 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -113,7 +113,7 @@ host="$1" operation="$2" shift 2 if [[ ! "$host" =~ ^[a-z][a-z0-9-]*$ ]] || - { [[ "$operation" != destroy && "$operation" != status && "$operation" != list ]] && ! host_exists "$host"; }; then + { [[ "$operation" != destroy && "$operation" != status && "$operation" != list && "$operation" != update ]] && ! host_exists "$host"; }; then echo "Unknown host: $host" >&2 exit 2 fi @@ -131,7 +131,7 @@ run_dir="$root/$name" [[ ! -L "$root" && ! -L "$run_dir" ]] || { echo "Symlinked artifact path refused" >&2; exit 1; } # Migrate commands one slice at a time. All disposal now uses the Rust owner, # including provider cleanup when the owned domain has already disappeared. -if [[ "$operation" == destroy || "$operation" == status ]]; then +if [[ "$operation" == destroy || "$operation" == status || "$operation" == update ]]; then (($# == 0)) || usage cd "$script_dir/../../skillet" exec cargo run --quiet --release -p skillet -- test vm "$operation" "$host" "$instance" @@ -206,29 +206,6 @@ case "$operation" in (($# == 0)) || usage inspect_owned "$script_dir/test-vm-ready" "$run_dir" ;; - update) - (($# == 0)) || usage - inspect_owned - artifact_target_dir="${CARGO_TARGET_DIR:-$script_dir/../../skillet/target}" - if [[ "$artifact_target_dir" != /* ]]; then - artifact_target_dir="$script_dir/../../skillet/$artifact_target_dir" - fi - artifact="$(realpath -e "$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet-$host")" - generic_artifact="$(realpath -e "$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet")" - [[ -f "$artifact" && ! -L "$artifact" && -f "$generic_artifact" && ! -L "$generic_artifact" ]] || { echo "Build skillet and skillet-$host first" >&2; exit 1; } - ssh_args - scp -O -i "$run_dir/ssh/id_ed25519" -P "$port" -o BatchMode=yes \ - -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$run_dir/ssh/known_hosts" \ - "$artifact" giacomo@127.0.0.1:/var/tmp/skillet-update - scp -O -i "$run_dir/ssh/id_ed25519" -P "$port" -o BatchMode=yes \ - -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$run_dir/ssh/known_hosts" \ - "$generic_artifact" giacomo@127.0.0.1:/var/tmp/skillet-generic-update - # shellcheck disable=SC2029 - ssh "${args[@]}" giacomo@127.0.0.1 \ - "sudo -n install -m 0755 /var/tmp/skillet-update /var/usrlocal/bin/skillet-$host && sudo -n install -m 0755 /var/tmp/skillet-generic-update /var/usrlocal/bin/skillet && rm -f /var/tmp/skillet-update /var/tmp/skillet-generic-update" - sha256sum "$artifact" > "$run_dir/deployed-skillet.sha256" - sha256sum "$generic_artifact" > "$run_dir/deployed-skillet-generic.sha256" - echo "Updated skillet and skillet-$host in $name" ;; ssh) (($# == 0)) || usage inspect_owned diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index c04e88de..8dfd526a 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -9,7 +9,10 @@ Implementation progress: `skillet_vm` now supplies validated run identity and versioned manifests with explicit, non-destructive legacy import. Rust status and disposal use a focused libvirt adapter and a locked cleanup orchestrator; listing is also in Rust. Shared Cargo JSON artifact discovery selects the exact -reported binary and now serves the container runner. Remaining VM commands +reported binary and now serves the container runner and retained VM updates. +Updates share the ownership lock and SSH transport, preserve captured hashes, +and record deployed hashes only after verifying both installed artifacts. +Remaining VM commands stay on their existing paths until their slices land. The shared SSH transport is implemented but not yet wired into readiness or credential delivery. Its bounded I/O stays in memory; payload copies are zeroized after stdin transfer. diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 98245550..52e59e13 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -30,11 +30,14 @@ Move VM lifecycle management from Bash to one Rust orchestrator, as recorded in [the architecture decision](skillet-architecture.md) and [the migration plan](../plan/REFACTOR-01-VM-LIFECYCLE.md). Preserve public commands, retained manifests, native/Flatpak support, and guest assertions. -Listing, status and destruction now delegate to Rust. Disposal validates identity, +Listing, status, updates and destruction now delegate to Rust. Updates build +both binaries, use verified recorded SSH trust, and save deployed hashes after +checking installed bytes and metadata. Original capture evidence is retained. +Disposal validates identity, cleans external resources, then removes the UUID-addressed domain and local artifacts. It also works with an already-absent guest; connection failures and partial cleanup preserve journals for retry. A per-run lock prevents concurrent -disposal. Remaining lifecycle commands still use Bash. Unit/adapter checks pass; +updates and disposal. Remaining lifecycle commands still use Bash. Unit/adapter checks pass; live migration acceptance is deferred while the user's VM creation runs. ## States and recovery diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index bb623cf5..242e500c 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -21,12 +21,15 @@ implemented and used by the routine container runner; a custom target-directory integration check selects the reported binary. `GuestTransport`/`SshTransport` now provide explicit target/key policy, literal remote executable arguments, noninteractive execution and upload. Subprocess stdin/output stay in memory, -with bounded concurrent I/O and zeroized copied stdin. Thirty-five VM tests pass. +with bounded concurrent I/O and zeroized copied stdin. Forty VM tests pass. No live VM was changed or imported. -Creation, readiness, update/reboot and interactive SSH still use Bash. The -shared transport is not yet wired into their operations or credential delivery. -Next: migrate update/readiness to that transport, recoverable creation and the +Retained binary updates now use Rust's shared lock, ownership checks, Cargo +artifact discovery and SSH transport. Both installed artifacts are verified +before deployed hashes are saved; original capture evidence is preserved. +Creation, readiness, reboot and interactive SSH still use Bash. Credential +delivery has not yet adopted the shared transport. +Next: migrate readiness to that transport, recoverable creation and the remaining public-helper delegation. Disposal live acceptance remains deferred. See [validation evidence](../butane/ACCEPTANCE.md#vm-refactoring-foundation-2026-10-03). diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 5944b68f..95eb51c1 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -20,7 +20,7 @@ assignments to execute concurrently against the same interfaces. | Order | Workstream | Depends on | Status | | --- | --- | --- | --- | -| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifests, list/status/disposal, Cargo artifacts | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifests, list/status/disposal/update, Cargo artifacts | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Planned | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Planned | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 4f097e42..18ae0cd1 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -101,6 +101,9 @@ tracks existing violations and their migration, not completed implementation. establish absence; transport failure is not absence. Revalidate ownership after long external calls and address destructive runtime actions by the recorded immutable identity, not only a reusable name. +- Verify deployed artifact bytes and required metadata before recording delivery + success. Keep original capture evidence distinct from subsequent deployments; + a partially successful transfer must remain safely retryable. - Remove recovery metadata only after owned artifact deletion succeeds, too. A recursive directory deletion must not erase its own retry journal before discovering that a later child cannot be removed. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 6e43ab66..c6a38435 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2436,10 +2436,12 @@ dependencies = [ name = "skillet_vm" version = "0.1.0" dependencies = [ + "hex", "nix", "quick-xml", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "users", diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 2dbe8634..6f0db0a4 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -368,19 +368,7 @@ fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { } fn run_vm_update(args: &VmDestroyArgs) -> Result<()> { - vm_name(&args.hostname, &args.instance)?; - let root = workspace_root()?; - let package = format!("skillet-{}", args.hostname); - let status = Command::new("cargo") - .current_dir(&root) - .args(["build", "--release", "-p", &package]) - .status() - .context("building host binary for VM update")?; - if !status.success() { - return Err(anyhow!("building {package} failed")); - } - let helper = butane_root()?.join("bin/test-vm"); - run_helper(&helper, &[&args.hostname, "update", &args.instance]) + vm::update(args) } fn run_vm_list(args: &VmListArgs) -> Result<()> { diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 20ae5a4a..368ff8df 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -49,6 +49,58 @@ fn current_uid() -> u32 { skillet_vm::current_uid() } +pub(super) fn update(args: &VmDestroyArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let _lock = store.lock(&identity)?; + let mut run = store.load(&identity)?; + if !matches!( + run.phase, + skillet_vm::Phase::Started | skillet_vm::Phase::Ready + ) { + return Err(anyhow!( + "finish VM creation or disposal before updating its binaries" + )); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("owned domain is absent"))? + .validate_owned(&run)?; + let root = workspace_root()?; + let package = format!("skillet-{}", identity.host()); + let artifacts = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace: &root, + packages: &["skillet", &package], + target: "x86_64-unknown-linux-musl", + profile: "release", + })?; + let host = artifacts + .get(&package) + .ok_or_else(|| anyhow!("Cargo did not report {package}"))?; + let generic = artifacts + .get("skillet") + .ok_or_else(|| anyhow!("Cargo did not report skillet"))?; + let transport = skillet_vm::transport::SshTransport::new( + run.ssh.clone(), + skillet_vm::transport::HostKeyPolicy::Verify, + )?; + run = store.import(&identity)?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("owned domain disappeared during the build"))? + .validate_owned(&run)?; + let hashes = skillet_vm::delivery::deliver(&run, &transport, host, generic)?; + run.deployed = Some(hashes); + store.save(&run)?; + println!( + "Updated skillet and {package} in {}", + identity.domain_name() + ); + Ok(()) +} + pub(super) fn list(args: &VmListArgs) -> Result<()> { let butane = butane_root()?; let store = ManifestStore::new(&butane.join("runs"), current_uid())?; diff --git a/ublue/skillet/crates/vm/Cargo.toml b/ublue/skillet/crates/vm/Cargo.toml index 27e0bb02..f58bd531 100644 --- a/ublue/skillet/crates/vm/Cargo.toml +++ b/ublue/skillet/crates/vm/Cargo.toml @@ -16,3 +16,5 @@ users.workspace = true quick-xml.workspace = true nix = { workspace = true, features = ["signal", "process"] } zeroize = "1.9" +sha2.workspace = true +hex.workspace = true diff --git a/ublue/skillet/crates/vm/src/delivery.rs b/ublue/skillet/crates/vm/src/delivery.rs new file mode 100644 index 00000000..adf21c8f --- /dev/null +++ b/ublue/skillet/crates/vm/src/delivery.rs @@ -0,0 +1,143 @@ +//! Captured/current binaries share one delivery and verification operation. +use crate::{ + manifest::{reject_symlinks, ArtifactHashes}, + transport::{GuestCommand, GuestTransport}, + Error, Phase, Result, VmRun, +}; +use sha2::{Digest as _, Sha256}; +use std::{fs::File, io::Read as _, path::Path}; + +pub fn deliver( + run: &VmRun, + transport: &impl GuestTransport, + host_artifact: &Path, + generic_artifact: &Path, +) -> Result { + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(Error::Invalid( + "VM is not available for binary delivery; finish creation or disposal first".into(), + )); + } + let hashes = ArtifactHashes { + host: sha256(host_artifact)?, + generic: sha256(generic_artifact)?, + }; + let host_staging = format!("/var/tmp/skillet-{}-update", run.identity.host()); + let generic_staging = "/var/tmp/skillet-generic-update"; + let host_destination = format!("/var/usrlocal/bin/skillet-{}", run.identity.host()); + let generic_destination = "/var/usrlocal/bin/skillet"; + for (artifact, source, destination, expected) in [ + ( + host_artifact, + &host_staging[..], + &host_destination[..], + &hashes.host[..], + ), + ( + generic_artifact, + generic_staging, + generic_destination, + &hashes.generic[..], + ), + ] { + if !matches_installed(transport, destination, expected)? { + transport.upload(artifact, source)?; + checked( + transport, + "sudo", + &["-n", "install", "-m", "0755", source, destination], + )?; + } + } + for (path, expected) in [ + (&host_destination[..], &hashes.host[..]), + (generic_destination, &hashes.generic[..]), + ] { + if !matches_installed(transport, path, expected)? { + return Err(Error::Invalid( + "guest artifact bytes or metadata differ from the selected binary".into(), + )); + } + } + checked(transport, "rm", &["-f", &host_staging, generic_staging])?; + Ok(hashes) +} + +fn matches_installed(transport: &impl GuestTransport, path: &str, expected: &str) -> Result { + if test_path(transport, "-L", path)? { + return Err(Error::Invalid("installed binary is a symlink".into())); + } + if !test_path(transport, "-e", path)? { + return Ok(false); + } + if !test_path(transport, "-f", path)? { + return Err(Error::Invalid( + "installed binary is not a regular file".into(), + )); + } + let hash = checked(transport, "sha256sum", &[path])?; + let metadata = checked(transport, "stat", &["--format=%a:%u:%g", path])?; + Ok(std::str::from_utf8(&hash) + .ok() + .and_then(|value| value.split_whitespace().next()) + == Some(expected) + && metadata == b"755:0:0\n") +} + +fn test_path(transport: &impl GuestTransport, condition: &str, path: &str) -> Result { + let output = transport.execute( + &GuestCommand { + program: "test", + arguments: &[condition, path], + }, + None, + )?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + code => Err(Error::Guest { + operation: "inspect installed binary".into(), + code, + }), + } +} + +pub fn sha256(path: &Path) -> Result { + reject_symlinks(path)?; + if !std::fs::metadata(path)?.is_file() { + return Err(Error::Invalid("artifact is not a regular file".into())); + } + let mut file = File::open(path)?; + if !file.metadata()?.is_file() { + return Err(Error::Invalid("artifact is not a regular file".into())); + } + let mut digest = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(hex::encode(digest.finalize())) +} + +pub(crate) fn checked( + transport: &impl GuestTransport, + program: &str, + arguments: &[&str], +) -> Result> { + let output = transport.execute(&GuestCommand { program, arguments }, None)?; + if !output.status.success() { + return Err(Error::Guest { + operation: program.into(), + code: output.status.code(), + }); + } + Ok(output.stdout) +} + +#[cfg(test)] +#[path = "delivery/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/delivery/tests.rs b/ublue/skillet/crates/vm/src/delivery/tests.rs new file mode 100644 index 00000000..0ce2e579 --- /dev/null +++ b/ublue/skillet/crates/vm/src/delivery/tests.rs @@ -0,0 +1,151 @@ +use super::*; +use crate::{manifest::tests::legacy_run, transport::GuestCommand}; +use std::{ + cell::{Cell, RefCell}, + fs, + os::unix::process::ExitStatusExt as _, + process::Output, +}; + +struct FakeTransport { + hashes: ArtifactHashes, + fail_install: Cell, + wrong_hash: Cell, + present: Cell, + calls: RefCell)>>, +} +impl GuestTransport for FakeTransport { + fn execute(&self, command: &GuestCommand<'_>, _: Option<&[u8]>) -> Result { + self.calls.borrow_mut().push(( + command.program.into(), + command + .arguments + .iter() + .map(|argument| (*argument).into()) + .collect(), + )); + let failed = (command.program == "sudo" && self.fail_install.get()) + || (command.program == "test" && (command.arguments[0] == "-L" || !self.present.get())); + if command.program == "sudo" + && !failed + && command.arguments.last().unwrap().ends_with("/skillet") + { + self.present.set(true); + } + let stdout = if command.program == "sha256sum" { + let hash = if self.wrong_hash.get() { + "bad" + } else if command.arguments[0].ends_with("skillet-fixture") { + &self.hashes.host + } else { + &self.hashes.generic + }; + format!("{hash} {}\n", command.arguments[0]).into_bytes() + } else if command.program == "stat" { + b"755:0:0\n".to_vec() + } else { + Vec::new() + }; + Ok(Output { + status: std::process::ExitStatus::from_raw(i32::from(failed) << 8), + stdout, + stderr: Vec::new(), + }) + } + fn upload(&self, _: &Path, destination: &str) -> Result<()> { + self.calls + .borrow_mut() + .push(("upload".into(), vec![destination.into()])); + Ok(()) + } +} + +#[test] +fn delivery_uses_selected_profile_and_verifies_both_binaries_without_changing_capture() { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let host = dir.join("host-artifact"); + let generic = dir.join("generic-artifact"); + fs::write(&host, "new host executable").unwrap(); + fs::write(&generic, "new generic executable").unwrap(); + let run = store.load(&identity).unwrap(); + let original = run.captured.clone(); + let hashes = ArtifactHashes { + host: sha256(&host).unwrap(), + generic: sha256(&generic).unwrap(), + }; + let transport = FakeTransport { + hashes: hashes.clone(), + fail_install: Cell::new(false), + wrong_hash: Cell::new(false), + present: Cell::new(false), + calls: RefCell::default(), + }; + assert_eq!(deliver(&run, &transport, &host, &generic).unwrap(), hashes); + assert_eq!(run.captured, original); + assert!(transport + .calls + .borrow() + .iter() + .any(|(program, args)| program == "sudo" + && args.last().unwrap() == "/var/usrlocal/bin/skillet-fixture")); + assert_eq!( + transport + .calls + .borrow() + .iter() + .filter(|(program, _)| program == "sha256sum") + .count(), + 2 + ); + transport.calls.borrow_mut().clear(); + assert_eq!(deliver(&run, &transport, &host, &generic).unwrap(), hashes); + assert!(!transport + .calls + .borrow() + .iter() + .any(|(program, _)| program == "upload" || program == "sudo")); +} + +#[test] +fn failed_install_or_wrong_hash_does_not_claim_deployment_success() { + let (_tmp, store, identity) = legacy_run(); + let host = store.run_dir(&identity).join("host-artifact"); + fs::write(&host, "fixture").unwrap(); + let run = store.import(&identity).unwrap(); + let hashes = ArtifactHashes { + host: sha256(&host).unwrap(), + generic: sha256(&host).unwrap(), + }; + let transport = FakeTransport { + hashes, + fail_install: Cell::new(true), + wrong_hash: Cell::new(false), + present: Cell::new(false), + calls: RefCell::default(), + }; + assert!(deliver(&run, &transport, &host, &host).is_err()); + assert!(store.load(&identity).unwrap().deployed.is_none()); + transport.fail_install.set(false); + transport.wrong_hash.set(true); + assert!(deliver(&run, &transport, &host, &host).is_err()); + assert!(store.load(&identity).unwrap().deployed.is_none()); + transport.wrong_hash.set(false); + assert!(deliver(&run, &transport, &host, &host).is_ok()); +} + +#[test] +fn disposal_in_progress_refuses_delivery_before_remote_mutation() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.load(&identity).unwrap(); + run.phase = Phase::ExternalCleanupComplete; + let transport = FakeTransport { + hashes: run.captured.clone().unwrap(), + fail_install: Cell::new(false), + wrong_hash: Cell::new(false), + present: Cell::new(false), + calls: RefCell::default(), + }; + assert!(deliver(&run, &transport, Path::new("/absent"), Path::new("/absent")).is_err()); + assert!(transport.calls.borrow().is_empty()); +} diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index ecb734b4..ad0199b9 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -3,6 +3,7 @@ pub mod artifacts; pub mod backend; pub mod catalog; +pub mod delivery; pub mod lifecycle; pub mod manifest; mod process; diff --git a/ublue/skillet/crates/vm/src/lifecycle.rs b/ublue/skillet/crates/vm/src/lifecycle.rs index e973cb23..1ebb0a82 100644 --- a/ublue/skillet/crates/vm/src/lifecycle.rs +++ b/ublue/skillet/crates/vm/src/lifecycle.rs @@ -1,9 +1,5 @@ use crate::{backend::VmBackend, Error, ManifestStore, Phase, Result, RunIdentity, VmRun}; -use nix::fcntl::{Flock, FlockArg}; -use std::{ - fs::{self, File, OpenOptions}, - os::unix::fs::OpenOptionsExt as _, -}; +use std::fs::{self, File}; /// Dispose external resources, the UUID-verified domain, then local artifacts. /// The guest need not exist; runtime errors and cleanup failures retain state. @@ -15,22 +11,7 @@ pub fn destroy( ) -> Result<()> { store.load(identity)?; let dir = store.run_dir(identity); - let lock_path = dir.join(".vm.lock"); - crate::manifest::reject_symlinks(&lock_path)?; - let file = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .mode(0o600) - .open(lock_path)?; - let _lock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, error)| { - if error == nix::errno::Errno::EWOULDBLOCK { - Error::Busy - } else { - Error::Io(std::io::Error::from_raw_os_error(error as i32)) - } - })?; + let _lock = store.lock(identity)?; let run = store.load(identity)?; if let Some(domain) = backend.inspect(&run)? { domain.validate_owned(&run)?; diff --git a/ublue/skillet/crates/vm/src/lifecycle/tests.rs b/ublue/skillet/crates/vm/src/lifecycle/tests.rs index accaaa2c..c8a45c66 100644 --- a/ublue/skillet/crates/vm/src/lifecycle/tests.rs +++ b/ublue/skillet/crates/vm/src/lifecycle/tests.rs @@ -1,6 +1,10 @@ use super::*; use crate::{backend::DomainSnapshot, manifest::tests::legacy_run}; -use std::cell::{Cell, RefCell}; +use nix::fcntl::{Flock, FlockArg}; +use std::{ + cell::{Cell, RefCell}, + fs::OpenOptions, +}; struct FakeBackend { domain: RefCell>, diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs index 6f3055b2..3e010ec4 100644 --- a/ublue/skillet/crates/vm/src/manifest.rs +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -1,10 +1,11 @@ use crate::{Error, Result}; +use nix::fcntl::{Flock, FlockArg}; use serde::{Deserialize, Serialize}; use std::{ collections::BTreeMap, - fs::{self, File}, + fs::{self, File, OpenOptions}, io::Write as _, - os::unix::fs::{MetadataExt as _, PermissionsExt as _}, + os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}, path::{Path, PathBuf}, }; use uuid::Uuid; @@ -127,6 +128,11 @@ pub struct ManifestStore { owner_uid: u32, } +/// Held across an entire lifecycle mutation, including its external calls. +pub struct RunLock { + _lock: Flock, +} + impl ManifestStore { pub fn new(root: &Path, owner_uid: u32) -> Result { if !root.is_absolute() @@ -151,6 +157,34 @@ impl ManifestStore { &self.root } + pub fn lock(&self, identity: &RunIdentity) -> Result { + let dir = self.run_dir(identity); + self.validate_directory(&dir)?; + let path = dir.join(".vm.lock"); + reject_symlinks(&path)?; + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(path)?; + if !file.metadata()?.is_file() || file.metadata()?.uid() != self.owner_uid { + return Err(Error::Invalid( + "invalid VM lock file ownership or type".into(), + )); + } + let lock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, error)| { + if error == nix::errno::Errno::EWOULDBLOCK { + Error::Busy + } else { + Error::Io(std::io::Error::from_raw_os_error(error as i32)) + } + })?; + Ok(RunLock { _lock: lock }) + } + /// Read without migrating or contacting a runtime; safe for retained runs. pub fn load(&self, identity: &RunIdentity) -> Result { let dir = self.run_dir(identity); @@ -158,7 +192,7 @@ impl ManifestStore { let json = dir.join("vm.json"); reject_symlinks(&json)?; let run = if json.exists() { - serde_json::from_reader(File::open(json)?)? + serde_json::from_str(&read_file(&json)?)? } else { self.read_legacy(identity, &dir)? }; @@ -402,6 +436,9 @@ fn read_hash(path: &Path) -> Result { } fn read_file(path: &Path) -> Result { reject_symlinks(path)?; + if !fs::metadata(path)?.is_file() { + return Err(Error::Invalid("VM metadata must be a regular file".into())); + } Ok(fs::read_to_string(path)?) } diff --git a/ublue/skillet/crates/vm/src/manifest/tests.rs b/ublue/skillet/crates/vm/src/manifest/tests.rs index b896bb3f..f2827429 100644 --- a/ublue/skillet/crates/vm/src/manifest/tests.rs +++ b/ublue/skillet/crates/vm/src/manifest/tests.rs @@ -186,3 +186,30 @@ fn flatpak_and_native_connections_have_distinct_validated_runtime_paths() { connection.uri = "qemu:///system".into(); assert!(connection.validate(uid).is_err()); } + +#[test] +fn metadata_fifo_is_rejected_without_blocking() { + let (_tmp, store, identity) = legacy_run(); + let path = store.run_dir(&identity).join("vm.json"); + nix::unistd::mkfifo( + &path, + nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR, + ) + .unwrap(); + assert!(store.load(&identity).is_err()); +} + +#[test] +fn shared_run_lock_is_nonblocking_and_refuses_linked_files() { + let (tmp, store, identity) = legacy_run(); + let first = store.lock(&identity).unwrap(); + assert!(matches!(store.lock(&identity), Err(Error::Busy))); + drop(first); + let lock_path = store.run_dir(&identity).join(".vm.lock"); + fs::remove_file(&lock_path).unwrap(); + let target = tmp.path().join("other-lock"); + fs::write(&target, "unrelated").unwrap(); + symlink(&target, &lock_path).unwrap(); + assert!(store.lock(&identity).is_err()); + assert_eq!(fs::read_to_string(target).unwrap(), "unrelated"); +} From a9c00d3e014a5eca47150783dd18d24cfaa75213 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 14:36:47 +0200 Subject: [PATCH 130/252] refactor(vm): migrate guest readiness checks to Rust --- ublue/butane/ACCEPTANCE.md | 25 + ublue/butane/README.md | 19 +- ublue/butane/bin/test-vm | 8 +- ublue/butane/bin/test-vm-ready | 137 +----- ublue/design/skillet-architecture.md | 9 +- ublue/design/smoke-vms.md | 8 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 15 +- ublue/plan/REFACTOR-02-HOST-PROFILES.md | 5 + ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/skillet/AGENTS.md | 6 + ublue/skillet/README.md | 5 + ublue/skillet/crates/cli-common/src/hosts.rs | 19 + .../crates/cli-common/src/hosts_tests.rs | 11 + ublue/skillet/crates/cli/src/main.rs | 27 +- ublue/skillet/crates/cli/src/vm.rs | 81 ++- ublue/skillet/crates/vm/src/delivery.rs | 6 +- ublue/skillet/crates/vm/src/lib.rs | 1 + ublue/skillet/crates/vm/src/manifest.rs | 35 ++ ublue/skillet/crates/vm/src/manifest/tests.rs | 22 + ublue/skillet/crates/vm/src/readiness.rs | 464 ++++++++++++++++++ .../skillet/crates/vm/src/readiness/tests.rs | 406 +++++++++++++++ 21 files changed, 1154 insertions(+), 157 deletions(-) create mode 100644 ublue/skillet/crates/vm/src/readiness.rs create mode 100644 ublue/skillet/crates/vm/src/readiness/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index fed418ea..09e09973 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,30 @@ # Clamps VM acceptance log +## Rust readiness migration, 2026-10-03 + +- The CLI's `test vm ready HOST INSTANCE`, create's readiness phase, + `test-vm HOST ready INSTANCE` and the retained RUN_DIR helper now share + Rust readiness. It uses the run lock, recorded SSH target, ownership checks + after waits, captured binaries, bounded probes and explicit profile boot + expectations. It does not use embedded shell or `systemctl --wait`. +- Preserved signed-origin, base-unit, artifact bytes/metadata, SELinux, + resolver/DNS, masked-unit, Homebrew and dotfiles acceptance checks. Both + binaries are verified again after apply. Ready/deployed state is saved only + after all checks and final domain ownership validation. +- Forty-eight VM tests pass, including success/retry, SSH/signed-boot timeout, + bootstrap/apply failure, ownership loss, capture tampering, signed-origin + refusal, guest security/user-environment failures and directory-based legacy + identity lookup. Failure retains private diagnostic logs; ownership loss + refuses further guest contact. The temporary boot policy lookup remains to + be consolidated in workstream 2. +- Passed static-musl `cargo fmt --all --check`, offline workspace all-target + Clippy with `-D warnings`, offline workspace tests/build, the routine Fedora + base integration (both applies; no repeat service start/restart), readiness + CLI help, ShellCheck for both changed helpers, and `git diff --check`. +- Live native/Flatpak readiness, rebase and retained-run import remain + unverified. The user's VM and provider resources were not contacted or + modified. No vault unlock or workstation installation was needed. + ## Retained VM update migration, 2026-10-03 - Rust now builds both binaries from Cargo-reported paths, checks recorded diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 49c3c421..90771c85 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -86,11 +86,12 @@ create and ready: cargo run --release -p skillet -- test vm create clamps smoke cargo run --release -p skillet -- test vm list clamps cargo run --release -p skillet -- test vm status clamps smoke +cargo run --release -p skillet -- test vm ready clamps smoke cargo run --release -p skillet -- test vm provision clamps smoke cargo run --release -p skillet -- test vm destroy clamps smoke ``` -`list`, `status`, `update` and `destroy` now use the Rust ownership implementation; +`list`, `status`, `ready`, `update` and `destroy` now use the Rust ownership implementation; the corresponding `test-vm` commands delegate to it. Reading status does not import or rewrite a legacy run. Disposal creates a private versioned manifest while retaining original files until cleanup finishes. If the owned guest is @@ -154,14 +155,18 @@ most two reboot attempts. The shared base unit runs with no app credentials. Clamps adds a separate full-apply unit gated on its Pi-hole and Tailscale credentials. -`./bin/test-vm HOST ready INSTANCE` waits up to 45 minutes, checks the expected -domain/disk, noninteractive SSH and sudo, signed booted deployment, successful -Skillet unit, guest artifact SHA, enforcing SELinux, DNS, masked resolved, +`./bin/test-vm HOST ready INSTANCE` waits up to 45 minutes per SSH/signed-boot +phase with bounded probes, and checks the recorded domain UUID/disks, +noninteractive SSH and sudo, signed booted deployment, successful +Skillet unit, guest artifact SHA and metadata, enforcing SELinux, DNS, +the profile's masked units, Homebrew, dotfiles links, and the complete `core` Brewfile bundle. -It writes `domain.txt`, `disks.txt`, `final-status.json`, `final-boot-id`, +It writes `final-status.json`, `final-boot-id`, `guest-skillet*.sha256`, `readiness.log`, and `user-environment.log` into the run -directory. On timeout, it writes `failure.log` or -`user-environment-failure.log` with relevant journals. +directory with mode 0600. On failure, it attempts to retain relevant journals +in those logs while preserving the original error. When ownership no longer +matches, it refuses further guest contact. `bin/test-vm-ready RUN_DIR` remains +a compatibility entry point to the same Rust implementation. For manual diagnostics, use `test-vm HOST status INSTANCE` and `test-vm HOST logs INSTANCE`. The equivalent guest SSH command is: diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 4f9f3769..8a980fe9 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -113,7 +113,7 @@ host="$1" operation="$2" shift 2 if [[ ! "$host" =~ ^[a-z][a-z0-9-]*$ ]] || - { [[ "$operation" != destroy && "$operation" != status && "$operation" != list && "$operation" != update ]] && ! host_exists "$host"; }; then + { [[ "$operation" != destroy && "$operation" != status && "$operation" != list && "$operation" != update && "$operation" != ready ]] && ! host_exists "$host"; }; then echo "Unknown host: $host" >&2 exit 2 fi @@ -131,7 +131,7 @@ run_dir="$root/$name" [[ ! -L "$root" && ! -L "$run_dir" ]] || { echo "Symlinked artifact path refused" >&2; exit 1; } # Migrate commands one slice at a time. All disposal now uses the Rust owner, # including provider cleanup when the owned domain has already disappeared. -if [[ "$operation" == destroy || "$operation" == status || "$operation" == update ]]; then +if [[ "$operation" == destroy || "$operation" == status || "$operation" == update || "$operation" == ready ]]; then (($# == 0)) || usage cd "$script_dir/../../skillet" exec cargo run --quiet --release -p skillet -- test vm "$operation" "$host" "$instance" @@ -202,10 +202,6 @@ case "$operation" in --ssh-port "$port" --libvirt-uri "$uri" "$script_dir/../$host.bu" vm_virsh domuuid "$name" > "$run_dir/domain.uuid" echo "Run: $run_dir" ;; - ready) - (($# == 0)) || usage - inspect_owned - "$script_dir/test-vm-ready" "$run_dir" ;; ssh) (($# == 0)) || usage inspect_owned diff --git a/ublue/butane/bin/test-vm-ready b/ublue/butane/bin/test-vm-ready index 4bf08ec1..34fd0670 100755 --- a/ublue/butane/bin/test-vm-ready +++ b/ublue/butane/bin/test-vm-ready @@ -1,137 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -if (($# != 1)) || [[ ! -f "$1/run.conf" ]]; then +# Compatibility entry point only. Rust validates ownership and retained state. +if (($# != 1)); then echo "Usage: $0 RUN_DIR" >&2 exit 2 fi -run_dir="$(realpath "$1")" -config_value() { sed -n "s/^$1=//p" "$run_dir/run.conf" | head -n 1; } -vm="$(config_value vm)" -host="$(config_value host)" -uri="$(config_value uri)" -ssh_port="$(config_value ssh_port)" -runtime_dir="$(config_value runtime_dir)" -backend="$(config_value backend)" -[[ -n "$backend" ]] || backend=native -[[ "$host" =~ ^[a-z][a-z0-9-]*$ && "$vm" == "$host"-test-* ]] || { echo "Invalid host VM manifest" >&2; exit 1; } -vm_virsh() { - if [[ "$backend" == flatpak ]]; then - TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" virsh -c "$uri" "$@" - elif [[ -n "$runtime_dir" ]]; then - env XDG_RUNTIME_DIR="$runtime_dir" virsh -c "$uri" "$@" - else - virsh -c "$uri" "$@" - fi -} -known_hosts="$run_dir/ssh/known_hosts" -ssh_args=(-i "$run_dir/ssh/id_ed25519" -p "$ssh_port" -o BatchMode=yes - -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts") -scp_args=(-i "$run_dir/ssh/id_ed25519" -P "$ssh_port" -o BatchMode=yes - -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts") -# shellcheck disable=SC2029 -ssh_guest() { ssh "${ssh_args[@]}" giacomo@127.0.0.1 "$@"; } - -if ! vm_virsh dominfo "$vm" > "$run_dir/domain.txt"; then - echo "Domain $vm unavailable on $uri" >&2 - exit 1 -fi -vm_virsh domblklist "$vm" > "$run_dir/disks.txt" -if ! rg -Fq "$run_dir/$vm.qcow2" "$run_dir/disks.txt"; then - echo "Domain $vm does not use expected disk $run_dir/$vm.qcow2" >&2 - exit 1 -fi - -artifact="$run_dir/source/files/skillet-$host" -[[ -x "$artifact" ]] || { echo "Missing host artifact: $artifact" >&2; exit 1; } -generic_artifact="$run_dir/source/files/skillet" -[[ -x "$generic_artifact" ]] || { echo "Missing generic artifact: $generic_artifact" >&2; exit 1; } -deadline=$((SECONDS + 2700)) -next_report=$((SECONDS + 60)) -echo "Waiting for SSH on giacomo@127.0.0.1:$ssh_port before delivering Skillet..." -while ((SECONDS < deadline)); do - if ssh_guest 'sudo -n true' &>/dev/null; then - break - fi - if ((SECONDS >= next_report)); then - echo "Still waiting for guest SSH; elapsed $((2700 - (deadline - SECONDS))) seconds" - next_report=$((SECONDS + 60)) - fi - sleep 5 -done -if ((SECONDS >= deadline)); then - echo "Timed out waiting for guest SSH; inspect the VM serial console and $run_dir/domain.txt" >&2 - exit 1 -fi -scp -O "${scp_args[@]}" "$artifact" "giacomo@127.0.0.1:/var/tmp/skillet-$host" -scp -O "${scp_args[@]}" "$generic_artifact" giacomo@127.0.0.1:/var/tmp/skillet -ssh_guest "sudo -n install -m 0755 /var/tmp/skillet-$host /var/usrlocal/bin/skillet-$host && sudo -n install -m 0755 /var/tmp/skillet /var/usrlocal/bin/skillet && rm -f /var/tmp/skillet-$host /var/tmp/skillet" -echo "Installed Skillet artifact; waiting for the signed $host deployment..." - -deadline=$((SECONDS + 2700)) -next_report=$((SECONDS + 60)) -while ((SECONDS < deadline)); do - if ssh_guest 'sudo -n test -e /run/ucore-bootstrap-ready' &>/dev/null; then - break - fi - if ssh_guest 'sudo -n systemctl is-failed --quiet ucore-bootstrap.service' &>/dev/null; then - ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true - echo "$host bootstrap failed; diagnostics: $run_dir/failure.log" >&2 - exit 1 - fi - if ((SECONDS >= next_report)); then - echo "Still waiting for signed deployment; elapsed $((2700 - (deadline - SECONDS))) seconds" - next_report=$((SECONDS + 60)) - fi - sleep 10 -done -if ((SECONDS >= deadline)); then - echo "Timed out waiting for signed $host boot" >&2 - ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true - echo "Diagnostics: $run_dir/failure.log" >&2 - exit 1 -fi -if ! ssh_guest 'sudo -n systemctl reset-failed skillet-apply.service && sudo -n systemctl restart skillet-apply.service'; then - ssh_guest 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' > "$run_dir/failure.log" 2>&1 || true - echo "Skillet base apply failed; diagnostics: $run_dir/failure.log" >&2 - exit 1 -fi -echo "Waiting for Homebrew and the core dotfiles profile..." -if ! ssh_guest 'sudo -n systemctl reset-failed brew-install.service dotfiles-install.service && sudo -n systemctl start brew-install.service && sudo -n systemctl start dotfiles-install.service'; then - ssh_guest 'sudo -n journalctl -b -u brew-install.service -u dotfiles-install.service --no-pager -n 200' > "$run_dir/user-environment-failure.log" 2>&1 || true - echo "User environment install failed; diagnostics: $run_dir/user-environment-failure.log" >&2 - exit 1 -fi - -ssh_guest 'sudo -n rpm-ostree status --json' > "$run_dir/final-status.json" -ssh_guest 'cat /proc/sys/kernel/random/boot_id' > "$run_dir/final-boot-id" -ssh_guest "sha256sum /var/usrlocal/bin/skillet-$host" > "$run_dir/guest-skillet.sha256" -ssh_guest 'sha256sum /var/usrlocal/bin/skillet' > "$run_dir/guest-skillet-generic.sha256" -ssh_guest 'getenforce; readlink -f /etc/resolv.conf; cat /etc/resolv.conf; getent hosts ghcr.io; sudo -n systemctl is-enabled systemd-resolved.service; sudo -n systemctl status --no-pager skillet-apply.service' > "$run_dir/readiness.log" 2>&1 || true -ssh_guest 'systemctl show brew-install.service dotfiles-install.service -p Id -p Result -p ExecMainStatus; readlink -f /home/giacomo/.config/Brewfile; readlink -f /home/giacomo/.ssh/authorized_keys; /home/linuxbrew/.linuxbrew/bin/brew --version' > "$run_dir/user-environment.log" 2>&1 || true -expected="$(cut -d ' ' -f 1 "$run_dir/skillet.sha256")" -actual="$(cut -d ' ' -f 1 "$run_dir/guest-skillet.sha256")" -[[ "$expected" == "$actual" ]] || { echo "Guest Skillet SHA mismatch" >&2; exit 1; } -expected_generic="$(cut -d ' ' -f 1 "$run_dir/skillet-generic.sha256")" -actual_generic="$(cut -d ' ' -f 1 "$run_dir/guest-skillet-generic.sha256")" -[[ "$expected_generic" == "$actual_generic" ]] || { echo "Guest generic Skillet SHA mismatch" >&2; exit 1; } -if ! jq -e --arg image "ostree-image-signed:docker://ghcr.io/gbagnoli/ucore-$host" \ - '.deployments[] | select(.booted) | .["container-image-reference"] | . == ($image + ":latest") or (startswith($image + "@sha256:") and (split("@sha256:")[1] | test("^[0-9a-f]{64}$")))' \ - "$run_dir/final-status.json" >/dev/null; then - echo "Booted deployment is not the expected signed $host origin" >&2 - exit 1 -fi -# The substitutions run on the guest. -# shellcheck disable=SC2016 -ssh_guest 'test "$(systemctl show -p Result --value skillet-apply.service)" = success && test "$(systemctl show -p ExecMainStatus --value skillet-apply.service)" = 0 && test "$(getenforce)" = Enforcing && test "$(readlink -f /etc/resolv.conf)" = /run/NetworkManager/resolv.conf && getent hosts ghcr.io >/dev/null' -# shellcheck disable=SC2016 -ssh_guest 'test "$(cat /etc/skillet/host)" = clamps && systemctl cat skillet-apply.service | grep -Fq "ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base"' -if [[ "$host" == clamps ]]; then - # The substitution runs on the guest. - # shellcheck disable=SC2016 - ssh_guest 'test "$(systemctl is-enabled systemd-resolved.service)" = masked' -fi -# The substitutions run on the guest. -# shellcheck disable=SC2016 -ssh_guest 'test -e /var/lib/ucore-bootstrap/brew-installed && test -e /var/lib/ucore-bootstrap/dotfiles-installed && test "$(readlink -f /home/giacomo/.config/Brewfile)" = "$(readlink -f /home/giacomo/.local/src/dotfiles/brew/Brewfile.core)" && test "$(readlink -f /home/giacomo/.ssh/authorized_keys)" = "$(readlink -f /home/giacomo/.local/src/dotfiles/ssh/authorized_keys)" && /home/linuxbrew/.linuxbrew/bin/brew bundle check --file /home/giacomo/.local/src/dotfiles/brew/Brewfile.core' -echo "Ready: $vm on $uri; SSH: ssh -i $run_dir/ssh/id_ed25519 -p $ssh_port -o UserKnownHostsFile=$known_hosts giacomo@127.0.0.1" +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +run_dir="$(realpath -e "$1")" +cd "$script_dir/../../skillet" +exec cargo run --quiet --release -p skillet -- test vm ready-directory "$run_dir" diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 8dfd526a..4133fea1 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -12,9 +12,12 @@ listing is also in Rust. Shared Cargo JSON artifact discovery selects the exact reported binary and now serves the container runner and retained VM updates. Updates share the ownership lock and SSH transport, preserve captured hashes, and record deployed hashes only after verifying both installed artifacts. -Remaining VM commands -stay on their existing paths until their slices land. The shared SSH transport -is implemented but not yet wired into readiness or credential delivery. Its +Readiness now uses the same locked owner and SSH transport, with explicit +boot expectations from the selected profile, bounded probes and private +diagnostics. It verifies original artifacts and signed boot evidence before +recording readiness. The interim profile boot lookup will join the canonical +capability declaration in workstream 2. Remaining VM commands and credential +delivery stay on their existing paths until their slices land. Transport's bounded I/O stays in memory; payload copies are zeroized after stdin transfer. Keep application crates as reusable recipes. Separate canonical host profiles diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 52e59e13..6807c2a8 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -30,14 +30,18 @@ Move VM lifecycle management from Bash to one Rust orchestrator, as recorded in [the architecture decision](skillet-architecture.md) and [the migration plan](../plan/REFACTOR-01-VM-LIFECYCLE.md). Preserve public commands, retained manifests, native/Flatpak support, and guest assertions. -Listing, status, updates and destruction now delegate to Rust. Updates build +Listing, status, readiness, updates and destruction now delegate to Rust. Updates build both binaries, use verified recorded SSH trust, and save deployed hashes after checking installed bytes and metadata. Original capture evidence is retained. Disposal validates identity, cleans external resources, then removes the UUID-addressed domain and local artifacts. It also works with an already-absent guest; connection failures and partial cleanup preserve journals for retry. A per-run lock prevents concurrent -updates and disposal. Remaining lifecycle commands still use Bash. Unit/adapter checks pass; +readiness, updates and disposal. Readiness uses the selected profile's explicit +boot expectations and captured binaries, and records Ready only after signed +boot, unit, SELinux, resolver and user-environment checks. Bounded probes and +private diagnostics allow failed checks to be retried. Creation and other +remaining lifecycle commands still use Bash. Unit/adapter checks pass; live migration acceptance is deferred while the user's VM creation runs. ## States and recovery diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 242e500c..d9f43d63 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -21,15 +21,22 @@ implemented and used by the routine container runner; a custom target-directory integration check selects the reported binary. `GuestTransport`/`SshTransport` now provide explicit target/key policy, literal remote executable arguments, noninteractive execution and upload. Subprocess stdin/output stay in memory, -with bounded concurrent I/O and zeroized copied stdin. Forty VM tests pass. +with bounded concurrent I/O and zeroized copied stdin. Forty-eight VM tests pass. No live VM was changed or imported. Retained binary updates now use Rust's shared lock, ownership checks, Cargo artifact discovery and SSH transport. Both installed artifacts are verified before deployed hashes are saved; original capture evidence is preserved. -Creation, readiness, reboot and interactive SSH still use Bash. Credential -delivery has not yet adopted the shared transport. -Next: migrate readiness to that transport, recoverable creation and the +Readiness now uses Rust's shared transport and lock, bounded SSH/signed-boot +probes, captured artifact verification, explicit profile boot expectations, +base/user-environment checks and mode-0600 diagnostics. The CLI, create path, +`test-vm ready` and the RUN_DIR compatibility helper share that implementation. +Identity for the compatibility helper comes from validated recorded fields. +Ready/deployed state is saved only after acceptance and a final ownership check. +Creation, reboot and interactive SSH still use Bash. Credential delivery has +not yet adopted the shared transport. Workstream 2 must consolidate the interim +`boot_policy_for_host` lookup with the canonical capability declaration. +Next: recoverable creation and the remaining public-helper delegation. Disposal live acceptance remains deferred. See [validation evidence](../butane/ACCEPTANCE.md#vm-refactoring-foundation-2026-10-03). diff --git a/ublue/plan/REFACTOR-02-HOST-PROFILES.md b/ublue/plan/REFACTOR-02-HOST-PROFILES.md index a74a580f..f86078cc 100644 --- a/ublue/plan/REFACTOR-02-HOST-PROFILES.md +++ b/ublue/plan/REFACTOR-02-HOST-PROFILES.md @@ -18,6 +18,11 @@ back to the baseline, and UI presence is used as a proxy for service eligibility ## Implementation sequence +Workstream 1 introduced explicit boot expectations in +`cli-common::hosts::boot_policy_for_host`. Fold this interim lookup into the +canonical profile declaration alongside composition and service capabilities; +keep the VM readiness orchestrator independent of deployment names. + 1. Add canonical library `skillet_hosts` and move host definitions/composition out of `cli-common`. Define a validated `HostId` and `HostProfile` carrying enabled application configurations, shared network/storage policy, optional diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 95eb51c1..7f6f4b8b 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -20,7 +20,7 @@ assignments to execute concurrently against the same interfaces. | Order | Workstream | Depends on | Status | | --- | --- | --- | --- | -| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifests, list/status/disposal/update, Cargo artifacts | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifests, list/status/disposal/update/readiness, Cargo artifacts | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Planned | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Planned | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 18ae0cd1..f17f654a 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -104,6 +104,12 @@ tracks existing violations and their migration, not completed implementation. - Verify deployed artifact bytes and required metadata before recording delivery success. Keep original capture evidence distinct from subsequent deployments; a partially successful transfer must remain safely retryable. +- Readiness verifies authoritative boot and application evidence, not only a + marker or successful connection. Supply profile expectations explicitly, keep + probes bounded and retain private failure diagnostics without replacing the + original error. Refuse further guest contact after ownership is lost. +- Compatibility entry points obtain identity from validated recorded fields; + do not reconstruct independent identity parsers from composite resource names. - Remove recovery metadata only after owned artifact deletion succeeds, too. A recursive directory deletion must not erase its own retry journal before discovering that a later child cannot be removed. diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index e655638f..8f20e3aa 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -84,6 +84,11 @@ After editing host code, use install the current binary. `ready` reinstalls the artifact captured when the VM was created. +Rerun boot and base/user-environment acceptance with +`cargo run --release -p skillet -- test vm ready clamps smoke`. +Private diagnostics are retained in the run directory; see the +[VM commands](../butane/README.md) and [lifecycle design](../design/smoke-vms.md). + When finished, remove the disposable VM, disk, SSH key, and run artifacts: ```bash diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 8247a5d8..4d9faf98 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -23,6 +23,25 @@ pub enum ApplyPhase { Caddy, } +/// Explicit guest boot expectations. Consolidated with the canonical capability +/// declaration in refactoring workstream 2; the VM orchestrator has no host cases. +pub struct HostBootPolicy { + pub signed_image: String, + pub masked_units: Vec<&'static str>, +} + +pub fn boot_policy_for_host(hostname: &str) -> Option { + let masked_units = match hostname { + "clamps" => vec!["systemd-resolved.service"], + "beezelbot" => Vec::new(), + _ => return None, + }; + Some(HostBootPolicy { + signed_image: format!("ghcr.io/gbagnoli/ucore-{hostname}"), + masked_units, + }) +} + #[derive(Error, Debug)] pub enum ApplyError { #[error("System error: {0}")] diff --git a/ublue/skillet/crates/cli-common/src/hosts_tests.rs b/ublue/skillet/crates/cli-common/src/hosts_tests.rs index 5f58b8df..299c9bee 100644 --- a/ublue/skillet/crates/cli-common/src/hosts_tests.rs +++ b/ublue/skillet/crates/cli-common/src/hosts_tests.rs @@ -1,6 +1,17 @@ use super::{clamps_tailscale_config, ui_config_for_host, TAILSCALE_AUTH_KEY_CREDENTIAL}; use skillet_podman::SecretTarget; +#[test] +fn boot_expectations_are_profile_inputs_and_unknown_profiles_are_refused() { + let clamps = super::boot_policy_for_host("clamps").unwrap(); + assert_eq!(clamps.signed_image, "ghcr.io/gbagnoli/ucore-clamps"); + assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); + let other = super::boot_policy_for_host("beezelbot").unwrap(); + assert!(other.masked_units.is_empty()); + assert!(super::boot_policy_for_host("clamps-test-smoke").is_none()); + assert!(super::boot_policy_for_host("unknown").is_none()); +} + #[test] fn clamps_tailscale_uses_host_network_and_persistent_state() { let config = clamps_tailscale_config("clamps-test-smoke", "test-auth-key".to_string()); diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 6f0db0a4..a4f0df8b 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -127,6 +127,11 @@ enum VmCommands { List(VmListArgs), /// Inspect the UUID and disks recorded for an owned disposable VM Status(VmTargetArgs), + /// Verify signed boot and apply the captured base/user environment + Ready(VmTargetArgs), + /// Compatibility adapter for the former `RUN_DIR` readiness helper + #[command(hide = true)] + ReadyDirectory(VmDirectoryArgs), /// Provision Pi-hole and enroll the disposable VM in Tailscale Provision(VmProvisionArgs), /// Install the current host binary on a retained disposable VM @@ -147,6 +152,11 @@ struct VmTargetArgs { instance: String, } +#[derive(clap::Args, Debug)] +struct VmDirectoryArgs { + run_dir: PathBuf, +} + #[derive(clap::Args, Debug)] struct VmDestroyArgs { hostname: String, @@ -275,6 +285,18 @@ fn main() -> Result<()> { command: VmCommands::Status(args), }, } => vm::status(&args)?, + Commands::Test { + command: + TestCommands::Vm { + command: VmCommands::Ready(args), + }, + } => vm::ready(&args)?, + Commands::Test { + command: + TestCommands::Vm { + command: VmCommands::ReadyDirectory(args), + }, + } => vm::ready_directory(&args)?, Commands::Test { command: TestCommands::Vm { @@ -348,7 +370,10 @@ fn run_vm_create(args: &VmCreateArgs) -> Result<()> { )?; let run_dir = butane.join("runs").join(&name); - if let Err(error) = run_helper(&helper, &[&args.hostname, "ready", &args.instance]) { + if let Err(error) = vm::ready(&VmTargetArgs { + hostname: args.hostname.clone(), + instance: args.instance.clone(), + }) { return Err(anyhow!( "VM created but readiness failed; inspect it with `test-vm {} logs {}` or destroy it with `skillet test vm destroy {} {}`: {error}", args.hostname, args.instance, args.hostname, args.instance diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 368ff8df..458b63e3 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -1,6 +1,7 @@ //! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. use super::{ - butane_root, secret_delivery, workspace_root, VmDestroyArgs, VmListArgs, VmTargetArgs, + butane_root, secret_delivery, workspace_root, VmDestroyArgs, VmDirectoryArgs, VmListArgs, + VmTargetArgs, }; use anyhow::{anyhow, Result}; use skillet_vm::{ @@ -49,6 +50,84 @@ fn current_uid() -> u32 { skillet_vm::current_uid() } +pub(super) fn ready_directory(args: &VmDirectoryArgs) -> Result<()> { + let butane = butane_root()?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load_directory(&args.run_dir)?; + ready(&VmTargetArgs { + hostname: run.identity.host().into(), + instance: run.identity.instance().into(), + }) +} + +pub(super) fn ready(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let _lock = store.lock(&identity)?; + let run = store.load(&identity)?; + let boot = skillet_cli_common::hosts::boot_policy_for_host(identity.host()) + .ok_or_else(|| anyhow!("unknown readiness profile: {}", identity.host()))?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("owned domain is absent"))? + .validate_owned(&run)?; + let mut run = store.import(&identity)?; + let mut probe = skillet_vm::transport::SshTransport::new( + run.ssh.clone(), + skillet_vm::transport::HostKeyPolicy::Enroll, + )?; + probe.timeout = std::time::Duration::from_secs(15); + let operations = skillet_vm::transport::SshTransport::new( + run.ssh.clone(), + skillet_vm::transport::HostKeyPolicy::Enroll, + )?; + let policy = skillet_vm::readiness::ReadinessPolicy { + signed_image: boot.signed_image, + resolver_target: "/run/NetworkManager/resolv.conf".into(), + masked_units: boot.masked_units.into_iter().map(String::from).collect(), + phase_timeout: std::time::Duration::from_mins(45), + }; + skillet_vm::readiness::ready( + &run, + &store.run_dir(&identity), + &policy, + &skillet_vm::readiness::ReadinessIo { + probe: &probe, + operations: &operations, + ownership: &|| { + backend + .inspect(&run)? + .ok_or_else(|| { + skillet_vm::Error::Invalid( + "owned domain disappeared during readiness".into(), + ) + })? + .validate_owned(&run) + }, + }, + &skillet_vm::readiness::MonotonicClock::default(), + &mut |message| tracing::info!("{message}"), + )?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("owned domain disappeared during readiness"))? + .validate_owned(&run)?; + run.phase = skillet_vm::Phase::Ready; + run.deployed.clone_from(&run.captured); + store.save(&run)?; + println!( + "Ready: {} on {}; SSH: {}@{}:{}", + identity.domain_name(), + run.connection.uri, + run.ssh.user, + run.ssh.address, + run.ssh.port + ); + Ok(()) +} + pub(super) fn update(args: &VmDestroyArgs) -> Result<()> { let butane = butane_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; diff --git a/ublue/skillet/crates/vm/src/delivery.rs b/ublue/skillet/crates/vm/src/delivery.rs index adf21c8f..966e6800 100644 --- a/ublue/skillet/crates/vm/src/delivery.rs +++ b/ublue/skillet/crates/vm/src/delivery.rs @@ -63,7 +63,11 @@ pub fn deliver( Ok(hashes) } -fn matches_installed(transport: &impl GuestTransport, path: &str, expected: &str) -> Result { +pub(crate) fn matches_installed( + transport: &impl GuestTransport, + path: &str, + expected: &str, +) -> Result { if test_path(transport, "-L", path)? { return Err(Error::Invalid("installed binary is a symlink".into())); } diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index ad0199b9..fa191fb7 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -7,6 +7,7 @@ pub mod delivery; pub mod lifecycle; pub mod manifest; mod process; +pub mod readiness; pub mod transport; pub use manifest::{ diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs index 3e010ec4..4c7dcc61 100644 --- a/ublue/skillet/crates/vm/src/manifest.rs +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -157,6 +157,41 @@ impl ManifestStore { &self.root } + /// Compatibility entry points obtain identity from recorded fields, never + /// by splitting a potentially ambiguous composite directory name. + pub fn load_directory(&self, dir: &Path) -> Result { + if dir.parent() != Some(self.root.as_path()) { + return Err(Error::Invalid( + "run is outside the recorded artifact root".into(), + )); + } + self.validate_directory(dir)?; + let json = dir.join("vm.json"); + reject_symlinks(&json)?; + let identity = if json.exists() { + let run: VmRun = serde_json::from_str(&read_file(&json)?)?; + run.identity + } else { + let config = parse_legacy(&read_file(&dir.join("run.conf"))?)?; + let host = config + .get("host") + .ok_or_else(|| Error::Invalid("legacy manifest lacks host".into()))?; + let vm = config + .get("vm") + .ok_or_else(|| Error::Invalid("legacy manifest lacks vm".into()))?; + let instance = vm + .strip_prefix(&format!("{host}-test-")) + .ok_or_else(|| Error::Invalid("legacy VM does not match its profile".into()))?; + RunIdentity::new(host, instance)? + }; + if self.run_dir(&identity) != dir { + return Err(Error::Invalid( + "recorded identity differs from run directory".into(), + )); + } + self.load(&identity) + } + pub fn lock(&self, identity: &RunIdentity) -> Result { let dir = self.run_dir(identity); self.validate_directory(&dir)?; diff --git a/ublue/skillet/crates/vm/src/manifest/tests.rs b/ublue/skillet/crates/vm/src/manifest/tests.rs index f2827429..320f537e 100644 --- a/ublue/skillet/crates/vm/src/manifest/tests.rs +++ b/ublue/skillet/crates/vm/src/manifest/tests.rs @@ -54,6 +54,28 @@ fn legacy_read_is_non_mutating_and_preserves_original_artifacts() { assert_eq!(fs::read(dir.join("run.conf")).unwrap(), before); } +#[test] +fn directory_compatibility_uses_recorded_identity_without_import_or_name_splitting() { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let run = store.load_directory(&dir).unwrap(); + assert_eq!(run.identity, identity); + assert!(!dir.join("vm.json").exists()); + store.import(&identity).unwrap(); + assert_eq!(store.load_directory(&dir).unwrap(), run); + assert!(store.load_directory(dir.parent().unwrap()).is_err()); + let other = RunIdentity::new("fixture-test-region", "retained-2").unwrap(); + let other_dir = store.run_dir(&other); + fs::rename(&dir, &other_dir).unwrap(); + fs::remove_file(other_dir.join("vm.json")).unwrap(); + let config = fs::read_to_string(other_dir.join("run.conf")) + .unwrap() + .replace("host=fixture\n", "host=fixture-test-region\n") + .replace(&identity.domain_name(), &other.domain_name()); + fs::write(other_dir.join("run.conf"), config).unwrap(); + assert_eq!(store.load_directory(&other_dir).unwrap().identity, other); +} + #[test] fn explicit_import_is_private_repeatable_and_preserves_external_journals() { let (_tmp, store, identity) = legacy_run(); diff --git a/ublue/skillet/crates/vm/src/readiness.rs b/ublue/skillet/crates/vm/src/readiness.rs new file mode 100644 index 00000000..a00d7e90 --- /dev/null +++ b/ublue/skillet/crates/vm/src/readiness.rs @@ -0,0 +1,464 @@ +//! Signed boot and base/user-environment acceptance for retained guest captures. +use crate::{ + delivery::{checked, deliver, sha256}, + manifest::reject_symlinks, + transport::{GuestCommand, GuestTransport}, + Error, Phase, Result, VmRun, +}; +use std::{ + fs, + io::Write as _, + os::unix::fs::PermissionsExt as _, + path::Path, + time::{Duration, Instant}, +}; + +/// Expectations belong to the selected profile, not the VM's mutable hostname. +pub struct ReadinessPolicy { + pub signed_image: String, + pub resolver_target: String, + pub masked_units: Vec, + pub phase_timeout: Duration, +} + +pub trait WaitClock { + fn elapsed(&self) -> Duration; + fn sleep(&self, duration: Duration); +} + +pub struct MonotonicClock(Instant); +impl Default for MonotonicClock { + fn default() -> Self { + Self(Instant::now()) + } +} +impl WaitClock for MonotonicClock { + fn elapsed(&self) -> Duration { + self.0.elapsed() + } + fn sleep(&self, duration: Duration) { + std::thread::sleep(duration); + } +} + +/// Probe transport uses a short deadline; installation may take several minutes. +pub struct ReadinessIo<'a, T> { + pub probe: &'a T, + pub operations: &'a T, + pub ownership: &'a dyn Fn() -> Result<()>, +} + +pub fn ready( + run: &VmRun, + run_dir: &Path, + policy: &ReadinessPolicy, + io: &ReadinessIo<'_, impl GuestTransport>, + clock: &impl WaitClock, + report: &mut impl FnMut(&str), +) -> Result<()> { + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(Error::Invalid("VM is not available for readiness".into())); + } + if policy.phase_timeout.is_zero() { + return Err(Error::Invalid("readiness timeout must be positive".into())); + } + let host = run_dir.join(format!("source/files/skillet-{}", run.identity.host())); + let generic = run_dir.join("source/files/skillet"); + let capture = run + .captured + .as_ref() + .ok_or_else(|| Error::Invalid("creation artifact hashes are absent".into()))?; + if sha256(&host)? != capture.host || sha256(&generic)? != capture.generic { + return Err(Error::Invalid( + "captured artifact bytes have changed".into(), + )); + } + (io.ownership)()?; + let result = (|| { + wait(run, policy, io.probe, clock, report, false)?; + (io.ownership)()?; + report("Installing captured Skillet binaries"); + deliver(run, io.operations, &host, &generic)?; + wait(run, policy, io.probe, clock, report, true)?; + (io.ownership)()?; + expect( + io.operations, + "cat", + &["/etc/skillet/host"], + run.identity.host(), + )?; + let status = checked( + io.operations, + "sudo", + &["-n", "rpm-ostree", "status", "--json"], + )?; + verify_signed_origin(&status, &policy.signed_image)?; + report("Applying the base profile and user environment"); + let transport = io.operations; + for arguments in [ + vec!["reset-failed", "skillet-apply.service"], + vec!["restart", "skillet-apply.service"], + vec![ + "reset-failed", + "brew-install.service", + "dotfiles-install.service", + ], + vec!["start", "brew-install.service"], + vec!["start", "dotfiles-install.service"], + ] { + let mut command = vec!["-n", "systemctl"]; + command.extend(arguments); + checked(transport, "sudo", &command)?; + } + verify(run, run_dir, policy, transport) + })(); + if result.is_err() && (io.ownership)().is_ok() { + // Diagnostics are best effort; never replace the original failure. + let _ = diagnostics(io.probe, run_dir); + } + result +} + +fn wait( + run: &VmRun, + policy: &ReadinessPolicy, + transport: &impl GuestTransport, + clock: &impl WaitClock, + report: &mut impl FnMut(&str), + signed: bool, +) -> Result<()> { + let phase = if signed { "signed boot" } else { "SSH" }; + report(&format!( + "Waiting for {phase} on {}", + run.identity.domain_name() + )); + let start = clock.elapsed(); + let mut next_report = Duration::from_mins(1); + loop { + let elapsed = clock.elapsed().saturating_sub(start); + if elapsed >= policy.phase_timeout { + return Err(Error::Invalid(format!("timed out waiting for {phase}"))); + } + let arguments: &[&str] = if signed { + &["-n", "test", "-e", "/run/ucore-bootstrap-ready"] + } else { + &["-n", "true"] + }; + if succeeds(transport, "sudo", arguments) { + return Ok(()); + } + if signed + && succeeds( + transport, + "sudo", + &[ + "-n", + "systemctl", + "is-failed", + "--quiet", + "ucore-bootstrap.service", + ], + ) + { + return Err(Error::Invalid("signed bootstrap service failed".into())); + } + if elapsed >= next_report { + report(&format!( + "Still waiting for {phase}; {} seconds elapsed", + elapsed.as_secs() + )); + next_report = elapsed + Duration::from_mins(1); + } + clock.sleep( + Duration::from_secs(5).min( + policy + .phase_timeout + .saturating_sub(clock.elapsed().saturating_sub(start)), + ), + ); + } +} + +fn succeeds(transport: &impl GuestTransport, program: &str, arguments: &[&str]) -> bool { + transport + .execute(&GuestCommand { program, arguments }, None) + .is_ok_and(|output| output.status.success()) +} + +fn expect( + transport: &impl GuestTransport, + program: &str, + args: &[&str], + expected: &str, +) -> Result<()> { + let output = checked(transport, program, args)?; + if std::str::from_utf8(&output).ok().map(str::trim) != Some(expected) { + return Err(Error::Invalid(format!( + "guest {program} readiness assertion failed" + ))); + } + Ok(()) +} + +fn verify( + run: &VmRun, + run_dir: &Path, + policy: &ReadinessPolicy, + transport: &impl GuestTransport, +) -> Result<()> { + let status = checked(transport, "sudo", &["-n", "rpm-ostree", "status", "--json"])?; + save(run_dir, "final-status.json", &status)?; + verify_signed_origin(&status, &policy.signed_image)?; + let captured = run + .captured + .as_ref() + .ok_or_else(|| Error::Invalid("creation artifact hashes are absent".into()))?; + for (path, hash) in [ + ( + format!("/var/usrlocal/bin/skillet-{}", run.identity.host()), + &captured.host, + ), + ("/var/usrlocal/bin/skillet".into(), &captured.generic), + ] { + if !crate::delivery::matches_installed(transport, &path, hash)? { + return Err(Error::Invalid( + "installed creation artifact differs after apply".into(), + )); + } + } + for (name, program, args) in [ + ( + "final-boot-id", + "cat", + vec!["/proc/sys/kernel/random/boot_id"], + ), + ("guest-skillet.sha256", "sha256sum", vec![]), + ( + "guest-skillet-generic.sha256", + "sha256sum", + vec!["/var/usrlocal/bin/skillet"], + ), + ] { + let host_path = format!("/var/usrlocal/bin/skillet-{}", run.identity.host()); + let args = if args.is_empty() { + vec![host_path.as_str()] + } else { + args + }; + save(run_dir, name, &checked(transport, program, &args)?)?; + } + expect( + transport, + "cat", + &["/etc/skillet/host"], + run.identity.host(), + )?; + expect(transport, "getenforce", &[], "Enforcing")?; + expect( + transport, + "readlink", + &["-f", "/etc/resolv.conf"], + &policy.resolver_target, + )?; + checked(transport, "getent", &["hosts", "ghcr.io"])?; + for unit in [ + "skillet-apply.service", + "brew-install.service", + "dotfiles-install.service", + ] { + expect( + transport, + "systemctl", + &["show", "-p", "Result", "--value", unit], + "success", + )?; + expect( + transport, + "systemctl", + &["show", "-p", "ExecMainStatus", "--value", unit], + "0", + )?; + } + let definition = checked(transport, "systemctl", &["cat", "skillet-apply.service"])?; + if !String::from_utf8_lossy(&definition).lines().any(|line| { + line == "ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base" + }) { + return Err(Error::Invalid("base unit does not use the stable host profile".into())); + } + for unit in &policy.masked_units { + // systemctl is-enabled returns nonzero for a correctly masked unit. + let output = transport.execute( + &GuestCommand { + program: "systemctl", + arguments: &["is-enabled", unit], + }, + None, + )?; + if String::from_utf8_lossy(&output.stdout).trim() != "masked" { + return Err(Error::Invalid("required unit is not masked".into())); + } + } + verify_user_environment(transport)?; + diagnostics(transport, run_dir)?; + Ok(()) +} + +fn verify_user_environment(transport: &impl GuestTransport) -> Result<()> { + for path in [ + "/var/lib/ucore-bootstrap/brew-installed", + "/var/lib/ucore-bootstrap/dotfiles-installed", + ] { + checked(transport, "test", &["-e", path])?; + } + for (link, source) in [ + ( + "/home/giacomo/.config/Brewfile", + "/home/giacomo/.local/src/dotfiles/brew/Brewfile.core", + ), + ( + "/home/giacomo/.ssh/authorized_keys", + "/home/giacomo/.local/src/dotfiles/ssh/authorized_keys", + ), + ] { + let expected = checked(transport, "readlink", &["-f", source])?; + let actual = checked(transport, "readlink", &["-f", link])?; + if expected != actual || expected.is_empty() { + return Err(Error::Invalid( + "user environment link target differs".into(), + )); + } + } + checked( + transport, + "/home/linuxbrew/.linuxbrew/bin/brew", + &[ + "bundle", + "check", + "--file", + "/home/giacomo/.local/src/dotfiles/brew/Brewfile.core", + ], + )?; + Ok(()) +} + +pub fn verify_signed_origin(status: &[u8], expected: &str) -> Result<()> { + let status: serde_json::Value = serde_json::from_slice(status)?; + let deployments = status["deployments"] + .as_array() + .ok_or_else(|| Error::Invalid("rpm-ostree deployments are absent".into()))?; + let booted: Vec<_> = deployments + .iter() + .filter(|entry| entry["booted"] == true) + .collect(); + if booted.len() != 1 { + return Err(Error::Invalid( + "expected exactly one booted deployment".into(), + )); + } + let origin = booted[0]["container-image-reference"] + .as_str() + .ok_or_else(|| Error::Invalid("booted image reference is absent".into()))?; + let signed = format!("ostree-image-signed:docker://{expected}"); + let digest = origin.strip_prefix(&format!("{signed}@sha256:")); + if origin != format!("{signed}:latest") + && !digest.is_some_and(|hash| { + hash.len() == 64 + && hash + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }) + { + return Err(Error::Invalid( + "booted deployment is not the expected signed origin".into(), + )); + } + Ok(()) +} + +fn diagnostics(transport: &impl GuestTransport, run_dir: &Path) -> Result<()> { + let mut log = Vec::new(); + for arguments in [ + vec!["-n", "rpm-ostree", "status"], + vec![ + "-n", + "journalctl", + "-b", + "-u", + "ucore-bootstrap.service", + "-u", + "skillet-apply.service", + "-u", + "brew-install.service", + "-u", + "dotfiles-install.service", + "--no-pager", + "-n", + "200", + ], + ] { + capture_diagnostic(transport, "sudo", &arguments, &mut log); + } + for (program, arguments) in [ + ("getenforce", vec![]), + ("readlink", vec!["-f", "/etc/resolv.conf"]), + ("cat", vec!["/etc/resolv.conf"]), + ("getent", vec!["hosts", "ghcr.io"]), + ( + "systemctl", + vec!["status", "--no-pager", "skillet-apply.service"], + ), + ] { + capture_diagnostic(transport, program, &arguments, &mut log); + } + save(run_dir, "readiness.log", &log)?; + let mut user_log = Vec::new(); + for unit in ["brew-install.service", "dotfiles-install.service"] { + for property in ["Result", "ExecMainStatus"] { + capture_diagnostic( + transport, + "systemctl", + &["show", "-p", property, "--value", unit], + &mut user_log, + ); + } + } + save(run_dir, "user-environment.log", &user_log) +} + +fn capture_diagnostic( + transport: &impl GuestTransport, + program: &str, + args: &[&str], + log: &mut Vec, +) { + log.extend(format!("{program} {args:?}\n").bytes()); + match transport.execute( + &GuestCommand { + program, + arguments: args, + }, + None, + ) { + Ok(output) => { + log.extend(output.stdout); + log.extend(output.stderr); + } + Err(_) => log.extend(b"Guest diagnostic command unavailable\n"), + } +} + +fn save(run_dir: &Path, name: &str, bytes: &[u8]) -> Result<()> { + let path = run_dir.join(name); + reject_symlinks(&path)?; + let mut tmp = tempfile::NamedTempFile::new_in(run_dir)?; + tmp.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + tmp.write_all(bytes)?; + tmp.as_file().sync_all()?; + tmp.persist(path).map_err(|error| Error::Io(error.error))?; + Ok(()) +} + +#[cfg(test)] +#[path = "readiness/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/readiness/tests.rs b/ublue/skillet/crates/vm/src/readiness/tests.rs new file mode 100644 index 00000000..fb72a333 --- /dev/null +++ b/ublue/skillet/crates/vm/src/readiness/tests.rs @@ -0,0 +1,406 @@ +use super::*; +use crate::manifest::{tests::legacy_run, ArtifactHashes}; +use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + os::unix::process::ExitStatusExt as _, + process::{ExitStatus, Output}, +}; + +#[derive(Default)] +struct FakeClock(Cell); +impl WaitClock for FakeClock { + fn elapsed(&self) -> Duration { + self.0.get() + } + fn sleep(&self, duration: Duration) { + self.0.set(self.0.get() + duration); + } +} + +#[allow(clippy::struct_excessive_bools)] // Independent failures exercised at each stage. +struct FakeGuest { + files: RefCell>, + calls: RefCell>, + no_ssh: bool, + no_signed: bool, + failed_bootstrap: bool, + fail_unit: bool, + profile: &'static str, + origin: String, + fault: &'static str, +} +impl Default for FakeGuest { + fn default() -> Self { + Self { + files: RefCell::default(), + calls: RefCell::default(), + no_ssh: false, + no_signed: false, + failed_bootstrap: false, + fail_unit: false, + profile: "fixture", + origin: "ostree-image-signed:docker://registry/fixture:latest".into(), + fault: "none", + } + } +} + +fn output(code: i32, stdout: impl Into>) -> Output { + Output { + status: ExitStatus::from_raw(code << 8), + stdout: stdout.into(), + stderr: Vec::new(), + } +} + +impl GuestTransport for FakeGuest { + fn execute(&self, command: &GuestCommand<'_>, _: Option<&[u8]>) -> Result { + self.calls + .borrow_mut() + .push(format!("{} {:?}", command.program, command.arguments)); + let (program, args) = if command.program == "sudo" { + if self.no_ssh { + return Ok(output(255, "")); + } + (&command.arguments[1], &command.arguments[2..]) + } else { + (&command.program, command.arguments) + }; + match (self.fault, *program, args) { + ("selinux", "getenforce", []) => return Ok(output(0, "Permissive\n")), + ("resolver", "readlink", ["-f", "/etc/resolv.conf"]) => { + return Ok(output(0, "/wrong/resolver\n")) + } + ("result", "systemctl", ["show", "-p", "Result", ..]) => { + return Ok(output(0, "exit-code\n")) + } + ("mask", "systemctl", ["is-enabled", ..]) => return Ok(output(0, "enabled\n")), + ("brew", "/home/linuxbrew/.linuxbrew/bin/brew", _) => return Ok(output(1, "")), + ("link", "readlink", ["-f", "/home/giacomo/.config/Brewfile"]) => { + return Ok(output(0, "/wrong/Brewfile\n")) + } + ("artifact", "systemctl", ["restart", "skillet-apply.service"]) => { + self.files + .borrow_mut() + .insert("/var/usrlocal/bin/skillet".into(), "bad".into()); + } + _ => {} + } + let stdout = match (*program, args) { + ("test", ["-e", "/run/ucore-bootstrap-ready"]) => return Ok(output(i32::from(self.no_signed), "")), + ("systemctl", ["is-failed", "--quiet", "ucore-bootstrap.service"]) => return Ok(output(i32::from(!self.failed_bootstrap), "")), + ("test", ["-L", _]) => return Ok(output(1, "")), + ("test", ["-e" | "-f", path]) if path.starts_with("/var/usrlocal/") => { + return Ok(output(i32::from(!self.files.borrow().contains_key(*path)), "")); + } + ("test", ["-e", path]) if path.starts_with("/var/lib/ucore-bootstrap/") => String::new(), + ("install", ["-m", "0755", source, destination]) => { + let hash = self.files.borrow().get(*source).cloned().ok_or_else(|| Error::Invalid("unstaged fake file".into()))?; + self.files.borrow_mut().insert((*destination).into(), hash); + String::new() + } + ("sha256sum", [path]) => { + let hash = self.files.borrow().get(*path).cloned().ok_or_else(|| Error::Invalid("missing fake file".into()))?; + format!("{hash} {path}\n") + } + ("stat", ["--format=%a:%u:%g", path]) if self.files.borrow().contains_key(*path) => "755:0:0\n".into(), + ("rm", ["-f", paths @ ..]) => { for path in paths { self.files.borrow_mut().remove(*path); } String::new() } + ("systemctl", ["restart" | "start" | "reset-failed", _rest @ ..]) => { + return Ok(output(i32::from(self.fail_unit), "")); + } + ("rpm-ostree", ["status", "--json"]) => serde_json::json!({"deployments":[{"booted":true,"container-image-reference":self.origin}]}).to_string(), + ("rpm-ostree", ["status"]) => "Signed deployment\n".into(), + ("journalctl", _) => "Sanitized fixture journal\n".into(), + ("cat", ["/proc/sys/kernel/random/boot_id"]) => "fixture-boot\n".into(), + ("cat", ["/etc/skillet/host"]) => format!("{}\n", self.profile), + ("getenforce", []) => "Enforcing\n".into(), + ("readlink", ["-f", "/etc/resolv.conf"]) => "/run/NetworkManager/resolv.conf\n".into(), + ("readlink", ["-f", path]) if path.contains("Brewfile") => "/home/giacomo/.local/src/dotfiles/brew/Brewfile.core\n".into(), + ("readlink", ["-f", path]) if path.contains("authorized_keys") => "/home/giacomo/.local/src/dotfiles/ssh/authorized_keys\n".into(), + ("getent", ["hosts", "ghcr.io"]) => "192.0.2.1 ghcr.io\n".into(), + ("systemctl", ["show", "-p", "Result", "--value", _]) => "success\n".into(), + ("systemctl", ["show", "-p", "ExecMainStatus", "--value", _]) => "0\n".into(), + ("systemctl", ["cat", "skillet-apply.service"]) => "ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base\n".into(), + ("systemctl", ["status", "--no-pager", "skillet-apply.service"]) => "Active: active (exited)\n".into(), + ("cat", ["/etc/resolv.conf"]) => "nameserver 192.0.2.53\n".into(), + ("systemctl", ["is-enabled", "fixture-masked.service"]) => return Ok(output(1, "masked\n")), + ("true", []) | ("/home/linuxbrew/.linuxbrew/bin/brew", ["bundle", "check", "--file", _]) => String::new(), + _ => return Err(Error::Invalid(format!("unsupported fake operation: {program} {args:?}"))), + }; + Ok(output(0, stdout)) + } + fn upload(&self, source: &Path, destination: &str) -> Result<()> { + self.files + .borrow_mut() + .insert(destination.into(), sha256(source)?); + self.calls.borrow_mut().push("upload".into()); + Ok(()) + } +} + +fn fixture() -> ( + tempfile::TempDir, + crate::ManifestStore, + VmRun, + ReadinessPolicy, +) { + let (tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity).join("source/files"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("skillet-fixture"), "captured host").unwrap(); + fs::write(dir.join("skillet"), "captured generic").unwrap(); + let mut run = store.import(&identity).unwrap(); + run.captured = Some(ArtifactHashes { + host: sha256(&dir.join("skillet-fixture")).unwrap(), + generic: sha256(&dir.join("skillet")).unwrap(), + }); + store.save(&run).unwrap(); + let policy = ReadinessPolicy { + signed_image: "registry/fixture".into(), + resolver_target: "/run/NetworkManager/resolv.conf".into(), + masked_units: vec!["fixture-masked.service".into()], + phase_timeout: Duration::from_secs(20), + }; + (tmp, store, run, policy) +} + +#[test] +fn selected_profile_passes_signed_boot_delivery_and_user_environment_checks() { + let (_tmp, store, run, policy) = fixture(); + let guest = FakeGuest::default(); + let dir = store.run_dir(&run.identity); + let clock = FakeClock::default(); + let mut messages = Vec::new(); + ready( + &run, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &clock, + &mut |message| messages.push(message.to_owned()), + ) + .unwrap(); + assert!(messages + .iter() + .any(|message| message.contains("fixture-test"))); + assert_eq!( + fs::metadata(dir.join("readiness.log")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert!(dir.join("final-status.json").is_file()); + let calls = guest.calls.borrow(); + assert!(calls + .iter() + .any(|call| call.contains("brew") && call.contains("bundle"))); + assert!(!calls.iter().any(|call| call.contains("--wait"))); + drop(calls); + guest.calls.borrow_mut().clear(); + ready( + &run, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &clock, + &mut |_| {}, + ) + .unwrap(); + assert!(!guest.calls.borrow().iter().any(|call| call == "upload")); +} + +#[test] +fn ssh_and_signed_boot_timeouts_are_bounded_and_leave_private_diagnostics() { + for signed in [false, true] { + let (_tmp, store, run, policy) = fixture(); + let guest = FakeGuest { + no_ssh: !signed, + no_signed: signed, + ..FakeGuest::default() + }; + let clock = FakeClock::default(); + let dir = store.run_dir(&run.identity); + assert!(ready( + &run, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &clock, + &mut |_| {} + ) + .is_err()); + assert_eq!(clock.elapsed(), policy.phase_timeout); + assert!(dir.join("readiness.log").is_file()); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Started); + } +} + +#[test] +fn bootstrap_apply_or_profile_failure_never_claims_ready() { + for guest in [ + FakeGuest { + no_signed: true, + failed_bootstrap: true, + ..FakeGuest::default() + }, + FakeGuest { + fail_unit: true, + ..FakeGuest::default() + }, + FakeGuest { + profile: "unrelated", + ..FakeGuest::default() + }, + ] { + let (_tmp, store, run, policy) = fixture(); + let dir = store.run_dir(&run.identity); + assert!(ready( + &run, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &FakeClock::default(), + &mut |_| {} + ) + .is_err()); + assert!(dir.join("readiness.log").is_file()); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Started); + } +} + +#[test] +fn changed_capture_or_cleanup_phase_refuses_all_guest_effects() { + for closing in [false, true] { + let (_tmp, store, mut run, policy) = fixture(); + if closing { + run.phase = Phase::DomainRemoved; + } else { + fs::write( + store.run_dir(&run.identity).join("source/files/skillet"), + "tampered", + ) + .unwrap(); + } + let guest = FakeGuest::default(); + assert!(ready( + &run, + &store.run_dir(&run.identity), + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &FakeClock::default(), + &mut |_| {} + ) + .is_err()); + assert!(guest.calls.borrow().is_empty()); + } +} + +#[test] +fn signed_origin_requires_one_booted_expected_signed_image_or_digest() { + let expected = "registry/fixture"; + for origin in [ + "ostree-image-signed:docker://registry/fixture:latest".into(), + format!( + "ostree-image-signed:docker://registry/fixture@sha256:{}", + "a".repeat(64) + ), + ] { + let status = + serde_json::json!({"deployments":[{"booted":true,"container-image-reference":origin}]}) + .to_string(); + verify_signed_origin(status.as_bytes(), expected).unwrap(); + } + for status in [ + serde_json::json!({"deployments":[]}), + serde_json::json!({"deployments":[{"booted":true,"container-image-reference":"ostree-unverified-registry:registry/fixture:latest"}]}), + serde_json::json!({"deployments":[{"booted":true,"container-image-reference":"ostree-image-signed:docker://registry/other:latest"}]}), + serde_json::json!({"deployments":[{"booted":true},{"booted":true}]}), + ] { + assert!(verify_signed_origin(status.to_string().as_bytes(), expected).is_err()); + } +} + +#[test] +fn guest_security_service_links_brew_and_artifact_assertions_fail_closed() { + for fault in [ + "selinux", "resolver", "result", "mask", "brew", "link", "artifact", + ] { + let (_tmp, store, run, policy) = fixture(); + let guest = FakeGuest { + fault, + ..FakeGuest::default() + }; + let dir = store.run_dir(&run.identity); + assert!( + ready( + &run, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &FakeClock::default(), + &mut |_| {} + ) + .is_err(), + "{fault} must fail" + ); + assert!(dir.join("readiness.log").is_file()); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Started); + } +} + +#[test] +fn ownership_loss_after_wait_refuses_delivery_and_contact_for_diagnostics() { + let (_tmp, store, run, policy) = fixture(); + let guest = FakeGuest::default(); + let checks = Cell::new(0); + let ownership = || { + checks.set(checks.get() + 1); + if checks.get() == 1 { + Ok(()) + } else { + Err(Error::Invalid("domain changed".into())) + } + }; + assert!(ready( + &run, + &store.run_dir(&run.identity), + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &ownership, + }, + &FakeClock::default(), + &mut |_| {} + ) + .is_err()); + assert_eq!(guest.calls.borrow().len(), 1); // Only the initial SSH probe. + assert!(!store.run_dir(&run.identity).join("readiness.log").exists()); +} From 0a0de810be234b9a1e719693b848905b03b851ba Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 14:55:26 +0200 Subject: [PATCH 131/252] fix(vm): recover failed readiness retries safely --- ublue/butane/ACCEPTANCE.md | 18 +++- ublue/design/skillet-architecture.md | 4 +- ublue/design/smoke-vms.md | 4 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 11 ++- ublue/skillet/AGENTS.md | 6 +- ublue/skillet/crates/cli/src/vm.rs | 15 ++-- ublue/skillet/crates/vm/src/readiness.rs | 67 +++++++++++--- .../skillet/crates/vm/src/readiness/tests.rs | 87 +++++++++++++++---- 8 files changed, 167 insertions(+), 45 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 09e09973..61f90ab3 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -11,7 +11,7 @@ resolver/DNS, masked-unit, Homebrew and dotfiles acceptance checks. Both binaries are verified again after apply. Ready/deployed state is saved only after all checks and final domain ownership validation. -- Forty-eight VM tests pass, including success/retry, SSH/signed-boot timeout, +- Forty-nine VM tests pass, including success/retry, SSH/signed-boot timeout, bootstrap/apply failure, ownership loss, capture tampering, signed-origin refusal, guest security/user-environment failures and directory-based legacy identity lookup. Failure retains private diagnostic logs; ownership loss @@ -25,6 +25,22 @@ unverified. The user's VM and provider resources were not contacted or modified. No vault unlock or workstation installation was needed. +## Readiness retry recovery, 2026-10-03 + +- A readiness retry now persists `Started` before guest work, so failure after + updating a previously `Ready` run cannot leave the prior success checkpoint + in place. Only full acceptance plus a final owner check persists `Ready` and + the captured deployed hashes. +- A guarded transport checks domain identity before and after every guest + command and upload, including systemd service starts. A changed owner halts + the sequence before subsequent service operations or diagnostics. +- Regression tests begin timeout/bootstrap/apply retry cases in `Ready` and + verify the saved phase becomes `Started`; an ownership-loss case during the + Brew start confirms dotfiles is not started afterward. Full validation is + complete: formatting, all-target offline Clippy, workspace tests/build, + Fedora repeat-apply integration, readiness CLI help, ShellCheck for both VM + wrappers and `git diff --check` pass. Live VM acceptance remains pending. + ## Retained VM update migration, 2026-10-03 - Rust now builds both binaries from Cargo-reported paths, checks recorded diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 4133fea1..4c99fbcf 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -15,7 +15,9 @@ and record deployed hashes only after verifying both installed artifacts. Readiness now uses the same locked owner and SSH transport, with explicit boot expectations from the selected profile, bounded probes and private diagnostics. It verifies original artifacts and signed boot evidence before -recording readiness. The interim profile boot lookup will join the canonical +recording readiness. A retry first clears the persisted Ready checkpoint and +restores it only after acceptance. Guest operations are guarded by ownership +checks before and after each call. The interim profile boot lookup will join the canonical capability declaration in workstream 2. Remaining VM commands and credential delivery stay on their existing paths until their slices land. Transport's bounded I/O stays in memory; payload copies are zeroized after stdin transfer. diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 6807c2a8..4d4bf0f5 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -40,7 +40,9 @@ partial cleanup preserve journals for retry. A per-run lock prevents concurrent readiness, updates and disposal. Readiness uses the selected profile's explicit boot expectations and captured binaries, and records Ready only after signed boot, unit, SELinux, resolver and user-environment checks. Bounded probes and -private diagnostics allow failed checks to be retried. Creation and other +private diagnostics allow failed checks to be retried. It first persists +Started to invalidate stale readiness, then checks ownership before and after +each guest command/upload. Creation and other remaining lifecycle commands still use Bash. Unit/adapter checks pass; live migration acceptance is deferred while the user's VM creation runs. diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index d9f43d63..dc3747bb 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -21,7 +21,7 @@ implemented and used by the routine container runner; a custom target-directory integration check selects the reported binary. `GuestTransport`/`SshTransport` now provide explicit target/key policy, literal remote executable arguments, noninteractive execution and upload. Subprocess stdin/output stay in memory, -with bounded concurrent I/O and zeroized copied stdin. Forty-eight VM tests pass. +with bounded concurrent I/O and zeroized copied stdin. Forty-nine VM tests pass. No live VM was changed or imported. Retained binary updates now use Rust's shared lock, ownership checks, Cargo @@ -32,12 +32,15 @@ probes, captured artifact verification, explicit profile boot expectations, base/user-environment checks and mode-0600 diagnostics. The CLI, create path, `test-vm ready` and the RUN_DIR compatibility helper share that implementation. Identity for the compatibility helper comes from validated recorded fields. -Ready/deployed state is saved only after acceptance and a final ownership check. +Readiness first persists `Started`, invalidating any earlier success; it saves +`Ready` and deployed hashes only after acceptance and a final ownership check. +Every guest command and upload checks ownership before and after the operation, +so a change during a long service start stops subsequent guest work. Creation, reboot and interactive SSH still use Bash. Credential delivery has not yet adopted the shared transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability declaration. -Next: recoverable creation and the -remaining public-helper delegation. Disposal live acceptance remains deferred. See +Next: recoverable creation and the remaining public-helper delegation. Disposal +and readiness live acceptance remain deferred. See [validation evidence](../butane/ACCEPTANCE.md#vm-refactoring-foundation-2026-10-03). ## Read and locate diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index f17f654a..b4f1b217 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -99,8 +99,10 @@ tracks existing violations and their migration, not completed implementation. overwrite a concurrently created authoritative manifest. - Serialize lifecycle mutations per run. A successful runtime query must establish absence; transport failure is not absence. Revalidate ownership - after long external calls and address destructive runtime actions by the - recorded immutable identity, not only a reusable name. + around each bounded remote operation and after long external calls; do not + continue with later operations when an ownership check fails. Address + destructive runtime actions by the recorded immutable identity, not only a + reusable name. - Verify deployed artifact bytes and required metadata before recording delivery success. Keep original capture evidence distinct from subsequent deployments; a partially successful transfer must remain safely retryable. diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 458b63e3..eef2eaf3 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -74,6 +74,7 @@ pub(super) fn ready(args: &VmTargetArgs) -> Result<()> { .ok_or_else(|| anyhow!("owned domain is absent"))? .validate_owned(&run)?; let mut run = store.import(&identity)?; + let ownership_run = run.clone(); let mut probe = skillet_vm::transport::SshTransport::new( run.ssh.clone(), skillet_vm::transport::HostKeyPolicy::Enroll, @@ -90,7 +91,8 @@ pub(super) fn ready(args: &VmTargetArgs) -> Result<()> { phase_timeout: std::time::Duration::from_mins(45), }; skillet_vm::readiness::ready( - &run, + &mut run, + &store, &store.run_dir(&identity), &policy, &skillet_vm::readiness::ReadinessIo { @@ -98,25 +100,18 @@ pub(super) fn ready(args: &VmTargetArgs) -> Result<()> { operations: &operations, ownership: &|| { backend - .inspect(&run)? + .inspect(&ownership_run)? .ok_or_else(|| { skillet_vm::Error::Invalid( "owned domain disappeared during readiness".into(), ) })? - .validate_owned(&run) + .validate_owned(&ownership_run) }, }, &skillet_vm::readiness::MonotonicClock::default(), &mut |message| tracing::info!("{message}"), )?; - backend - .inspect(&run)? - .ok_or_else(|| anyhow!("owned domain disappeared during readiness"))? - .validate_owned(&run)?; - run.phase = skillet_vm::Phase::Ready; - run.deployed.clone_from(&run.captured); - store.save(&run)?; println!( "Ready: {} on {}; SSH: {}@{}:{}", identity.domain_name(), diff --git a/ublue/skillet/crates/vm/src/readiness.rs b/ublue/skillet/crates/vm/src/readiness.rs index a00d7e90..eb851631 100644 --- a/ublue/skillet/crates/vm/src/readiness.rs +++ b/ublue/skillet/crates/vm/src/readiness.rs @@ -3,7 +3,7 @@ use crate::{ delivery::{checked, deliver, sha256}, manifest::reject_symlinks, transport::{GuestCommand, GuestTransport}, - Error, Phase, Result, VmRun, + Error, ManifestStore, Phase, Result, VmRun, }; use std::{ fs, @@ -48,8 +48,34 @@ pub struct ReadinessIo<'a, T> { pub ownership: &'a dyn Fn() -> Result<()>, } +struct OwnedTransport<'a, T> { + transport: &'a T, + ownership: &'a dyn Fn() -> Result<()>, +} + +impl GuestTransport for OwnedTransport<'_, T> { + fn execute( + &self, + command: &GuestCommand<'_>, + input: Option<&[u8]>, + ) -> Result { + (self.ownership)()?; + let result = self.transport.execute(command, input); + (self.ownership)()?; + result + } + + fn upload(&self, source: &Path, destination: &str) -> Result<()> { + (self.ownership)()?; + let result = self.transport.upload(source, destination); + (self.ownership)()?; + result + } +} + pub fn ready( - run: &VmRun, + run: &mut VmRun, + store: &ManifestStore, run_dir: &Path, policy: &ReadinessPolicy, io: &ReadinessIo<'_, impl GuestTransport>, @@ -74,27 +100,40 @@ pub fn ready( )); } (io.ownership)()?; + // Readiness can replace delivered executables and restart services. Mark + // the old success stale before the first guest operation so a failed retry + // cannot leave a Ready manifest behind. + run.phase = Phase::Started; + store.save(run)?; + let probe = OwnedTransport { + transport: io.probe, + ownership: io.ownership, + }; + let operations = OwnedTransport { + transport: io.operations, + ownership: io.ownership, + }; let result = (|| { - wait(run, policy, io.probe, clock, report, false)?; + wait(run, policy, &probe, io.ownership, clock, report, false)?; (io.ownership)()?; report("Installing captured Skillet binaries"); - deliver(run, io.operations, &host, &generic)?; - wait(run, policy, io.probe, clock, report, true)?; + deliver(run, &operations, &host, &generic)?; + wait(run, policy, &probe, io.ownership, clock, report, true)?; (io.ownership)()?; expect( - io.operations, + &operations, "cat", &["/etc/skillet/host"], run.identity.host(), )?; let status = checked( - io.operations, + &operations, "sudo", &["-n", "rpm-ostree", "status", "--json"], )?; verify_signed_origin(&status, &policy.signed_image)?; report("Applying the base profile and user environment"); - let transport = io.operations; + let transport = &operations; for arguments in [ vec!["reset-failed", "skillet-apply.service"], vec!["restart", "skillet-apply.service"], @@ -114,15 +153,20 @@ pub fn ready( })(); if result.is_err() && (io.ownership)().is_ok() { // Diagnostics are best effort; never replace the original failure. - let _ = diagnostics(io.probe, run_dir); + let _ = diagnostics(&probe, run_dir); } - result + result?; + (io.ownership)()?; + run.phase = Phase::Ready; + run.deployed.clone_from(&run.captured); + store.save(run) } fn wait( run: &VmRun, policy: &ReadinessPolicy, transport: &impl GuestTransport, + ownership: &dyn Fn() -> Result<()>, clock: &impl WaitClock, report: &mut impl FnMut(&str), signed: bool, @@ -135,6 +179,9 @@ fn wait( let start = clock.elapsed(); let mut next_report = Duration::from_mins(1); loop { + // The owner may have disappeared even when a failed SSH probe was an + // expected condition. Recheck before another probe or a backoff sleep. + ownership()?; let elapsed = clock.elapsed().saturating_sub(start); if elapsed >= policy.phase_timeout { return Err(Error::Invalid(format!("timed out waiting for {phase}"))); diff --git a/ublue/skillet/crates/vm/src/readiness/tests.rs b/ublue/skillet/crates/vm/src/readiness/tests.rs index fb72a333..39f91ee5 100644 --- a/ublue/skillet/crates/vm/src/readiness/tests.rs +++ b/ublue/skillet/crates/vm/src/readiness/tests.rs @@ -167,13 +167,14 @@ fn fixture() -> ( #[test] fn selected_profile_passes_signed_boot_delivery_and_user_environment_checks() { - let (_tmp, store, run, policy) = fixture(); + let (_tmp, store, mut run, policy) = fixture(); let guest = FakeGuest::default(); let dir = store.run_dir(&run.identity); let clock = FakeClock::default(); let mut messages = Vec::new(); ready( - &run, + &mut run, + &store, &dir, &policy, &ReadinessIo { @@ -185,6 +186,8 @@ fn selected_profile_passes_signed_boot_delivery_and_user_environment_checks() { &mut |message| messages.push(message.to_owned()), ) .unwrap(); + assert_eq!(run.phase, Phase::Ready); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Ready); assert!(messages .iter() .any(|message| message.contains("fixture-test"))); @@ -205,7 +208,8 @@ fn selected_profile_passes_signed_boot_delivery_and_user_environment_checks() { drop(calls); guest.calls.borrow_mut().clear(); ready( - &run, + &mut run, + &store, &dir, &policy, &ReadinessIo { @@ -223,7 +227,9 @@ fn selected_profile_passes_signed_boot_delivery_and_user_environment_checks() { #[test] fn ssh_and_signed_boot_timeouts_are_bounded_and_leave_private_diagnostics() { for signed in [false, true] { - let (_tmp, store, run, policy) = fixture(); + let (_tmp, store, mut run, policy) = fixture(); + run.phase = Phase::Ready; + store.save(&run).unwrap(); let guest = FakeGuest { no_ssh: !signed, no_signed: signed, @@ -232,7 +238,8 @@ fn ssh_and_signed_boot_timeouts_are_bounded_and_leave_private_diagnostics() { let clock = FakeClock::default(); let dir = store.run_dir(&run.identity); assert!(ready( - &run, + &mut run, + &store, &dir, &policy, &ReadinessIo { @@ -267,10 +274,13 @@ fn bootstrap_apply_or_profile_failure_never_claims_ready() { ..FakeGuest::default() }, ] { - let (_tmp, store, run, policy) = fixture(); + let (_tmp, store, mut run, policy) = fixture(); + run.phase = Phase::Ready; + store.save(&run).unwrap(); let dir = store.run_dir(&run.identity); assert!(ready( - &run, + &mut run, + &store, &dir, &policy, &ReadinessIo { @@ -301,9 +311,11 @@ fn changed_capture_or_cleanup_phase_refuses_all_guest_effects() { .unwrap(); } let guest = FakeGuest::default(); + let dir = store.run_dir(&run.identity); assert!(ready( - &run, - &store.run_dir(&run.identity), + &mut run, + &store, + &dir, &policy, &ReadinessIo { probe: &guest, @@ -348,7 +360,7 @@ fn guest_security_service_links_brew_and_artifact_assertions_fail_closed() { for fault in [ "selinux", "resolver", "result", "mask", "brew", "link", "artifact", ] { - let (_tmp, store, run, policy) = fixture(); + let (_tmp, store, mut run, policy) = fixture(); let guest = FakeGuest { fault, ..FakeGuest::default() @@ -356,7 +368,8 @@ fn guest_security_service_links_brew_and_artifact_assertions_fail_closed() { let dir = store.run_dir(&run.identity); assert!( ready( - &run, + &mut run, + &store, &dir, &policy, &ReadinessIo { @@ -377,20 +390,22 @@ fn guest_security_service_links_brew_and_artifact_assertions_fail_closed() { #[test] fn ownership_loss_after_wait_refuses_delivery_and_contact_for_diagnostics() { - let (_tmp, store, run, policy) = fixture(); + let (_tmp, store, mut run, policy) = fixture(); let guest = FakeGuest::default(); let checks = Cell::new(0); let ownership = || { checks.set(checks.get() + 1); - if checks.get() == 1 { + if checks.get() <= 3 { Ok(()) } else { Err(Error::Invalid("domain changed".into())) } }; + let dir = store.run_dir(&run.identity); assert!(ready( - &run, - &store.run_dir(&run.identity), + &mut run, + &store, + &dir, &policy, &ReadinessIo { probe: &guest, @@ -402,5 +417,45 @@ fn ownership_loss_after_wait_refuses_delivery_and_contact_for_diagnostics() { ) .is_err()); assert_eq!(guest.calls.borrow().len(), 1); // Only the initial SSH probe. - assert!(!store.run_dir(&run.identity).join("readiness.log").exists()); + assert!(!dir.join("readiness.log").exists()); +} + +#[test] +fn ownership_loss_after_long_brew_operation_stops_before_dotfiles_start() { + let (_tmp, store, mut run, policy) = fixture(); + let guest = FakeGuest::default(); + let ownership = || { + let brew_started = guest.calls.borrow().iter().any(|call| { + call == "sudo [\"-n\", \"systemctl\", \"start\", \"brew-install.service\"]" + }); + if brew_started { + Err(Error::Invalid( + "owned domain changed during Brew install".into(), + )) + } else { + Ok(()) + } + }; + let dir = store.run_dir(&run.identity); + assert!(ready( + &mut run, + &store, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &ownership, + }, + &FakeClock::default(), + &mut |_| {}, + ) + .is_err()); + assert!(guest.calls.borrow().iter().any(|call| { + call == "sudo [\"-n\", \"systemctl\", \"start\", \"brew-install.service\"]" + })); + assert!(!guest.calls.borrow().iter().any(|call| { + call == "sudo [\"-n\", \"systemctl\", \"start\", \"dotfiles-install.service\"]" + })); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Started); } From 226c6610fd3ca102f721232b5c21cde3c9140e7f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 14:56:06 +0200 Subject: [PATCH 132/252] docs(vm): link readiness recovery evidence --- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index dc3747bb..77b82e5b 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -41,7 +41,7 @@ not yet adopted the shared transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability declaration. Next: recoverable creation and the remaining public-helper delegation. Disposal and readiness live acceptance remain deferred. See -[validation evidence](../butane/ACCEPTANCE.md#vm-refactoring-foundation-2026-10-03). +[validation evidence](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). ## Read and locate From fd9ec85c5b41f4fbbe44971e86bd185d213b7608 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 15:50:10 +0200 Subject: [PATCH 133/252] feat(vm): persist recoverable creation intents --- ublue/butane/ACCEPTANCE.md | 18 +++ ublue/butane/bin/coreos-install | 57 ++++++-- ublue/butane/bin/test-vm | 31 +++- ublue/design/smoke-vms.md | 10 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 25 ++-- ublue/skillet/crates/cli/src/main.rs | 136 +++++++++++------- ublue/skillet/crates/cli/src/vm.rs | 30 ++++ ublue/skillet/crates/vm/src/manifest.rs | 107 ++++++++++++++ ublue/skillet/crates/vm/src/manifest/tests.rs | 57 +++++++- 9 files changed, 395 insertions(+), 76 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 61f90ab3..469c58fe 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,23 @@ # Clamps VM acceptance log +## Recoverable VM creation intent, 2026-10-03 + +- Creation now writes a mode-0600 Rust manifest with a generated domain UUID + before installer staging. Repeated preparation resumes only the same + `Preparing` intent with matching backend, runtime, SSH port, and source + revision; ambiguous and progressed directories are refused. +- The UUID is embedded in native libvirt XML and passed to Flatpak `virt-install`. + Native definition is recorded as `Defined` before start. Failed start no + longer undefines the guest. Re-running create checks UUID and disk ownership, + starts a verified stopped domain, or resumes staging when no domain exists. + Captured hashes and `Started` are written after the helper succeeds. +- Local regression tests cover intent reuse/conflict, private run-directory + permissions, and phase transitions. Passed `cargo fmt --all --check`, + offline workspace all-target Clippy with `-D warnings`, offline workspace + tests/build, the CI Fedora repeat-apply container integration, ShellCheck for + both changed helpers, CLI help, and `git diff --check`. No VM, libvirt domain, + vault, or provider resource was contacted for this change. + ## Rust readiness migration, 2026-10-03 - The CLI's `test vm ready HOST INSTANCE`, create's readiness phase, diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 85ad25fb..bb9db526 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -7,7 +7,7 @@ repo_dir="$(realpath "$butane_dir/..")" usage() { cat <= 2)) || { usage >&2; exit 2; } case "$1" in --name) vm_name="$2" ;; @@ -40,8 +42,10 @@ while (($#)); do --artifact) artifact="$2"; custom_artifact=true ;; --image) image="$2" ;; --libvirt-uri) libvirt_uri="$2" ;; + --uuid) vm_uuid="$2" ;; esac shift 2 ;; + --resume-prepared) resume_prepared=true; shift ;; -h|--help) usage; exit 0 ;; --) shift; break ;; -*) usage >&2; exit 2 ;; @@ -59,13 +63,23 @@ if [[ ! "$ssh_port" =~ ^[0-9]+$ ]] || ((ssh_port < 1024 || ssh_port > 65535)); t exit 2 fi output_dir="$(realpath -m "${output_dir:-$butane_dir/runs/$vm_name}")" +if [[ "$resume_prepared" == true ]]; then + [[ "$output_dir" == "$butane_dir/runs/$vm_name" && -f "$output_dir/vm.json" ]] || { + echo "Prepared VM output must be its recorded run directory" >&2; exit 1; + } + [[ "$vm_uuid" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] || { + echo "A valid recorded UUID is required for prepared VM creation" >&2; exit 2; + } +else + [[ -z "$vm_uuid" ]] || { echo "--uuid requires --resume-prepared" >&2; exit 2; } +fi vm_command virsh -c "$libvirt_uri" uri >/dev/null || { echo "Cannot access libvirt URI $libvirt_uri" >&2; exit 1; } command -v passt >/dev/null || { echo "passt is required for loopback SSH forwarding" >&2; exit 1; } if vm_command virsh -c "$libvirt_uri" dominfo "$vm_name" &>/dev/null; then echo "Refusing existing libvirt domain $vm_name on $libvirt_uri" >&2 exit 1 fi -if [[ -e "$output_dir" || -L "$output_dir" ]]; then +if [[ -e "$output_dir" && "$resume_prepared" != true || -L "$output_dir" ]]; then echo "Refusing existing output path $output_dir" >&2 exit 1 fi @@ -106,7 +120,6 @@ fi [[ -f "$image" ]] || { echo "Missing FCOS image: $image" >&2; exit 1; } image="$(realpath "$image")" mkdir -p "$(dirname "$output_dir")" -mkdir "$output_dir" mkdir -p "$output_dir/source/includes" "$output_dir/source/files" "$output_dir/ssh" "$output_dir/ignition" cp "$input" "$output_dir/source/$host.bu" cp "$butane_dir"/includes/*.bu "$output_dir/source/includes/" @@ -130,7 +143,17 @@ skillet_host_include="$output_dir/source/includes/skillet-$host.bu" [[ -f "$skillet_host_include" ]] || { echo "Missing host Skillet Butane include: $skillet_host_include" >&2; exit 1; } HOST="$host" yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet-" + strenv(HOST)))' \ "$skillet_host_include" -ssh-keygen -q -t ed25519 -N '' -f "$output_dir/ssh/id_ed25519" +if [[ ! -f "$output_dir/ssh/id_ed25519" && ! -e "$output_dir/ssh/id_ed25519.pub" ]]; then + ssh-keygen -q -t ed25519 -N '' -f "$output_dir/ssh/id_ed25519" +elif [[ -f "$output_dir/ssh/id_ed25519" && -f "$output_dir/ssh/id_ed25519.pub" ]]; then + derived_public_key="$(ssh-keygen -y -f "$output_dir/ssh/id_ed25519")" + [[ "$derived_public_key" == "$(cat "$output_dir/ssh/id_ed25519.pub")" ]] || { + echo "Prepared SSH key pair is inconsistent" >&2; exit 1; + } +else + echo "Prepared SSH key pair is incomplete; inspect the run directory before retrying" >&2 + exit 1 +fi chmod 600 "$output_dir/ssh/id_ed25519" public_key="$(cat "$output_dir/ssh/id_ed25519.pub")" # The existing giacomo account survives the FCOS-to-uCore rebase. Inject the @@ -172,9 +195,16 @@ else network="passt,model=virtio,portForward0.proto=tcp,portForward0.address=127.0.0.1,portForward0.range0.start=$ssh_port,portForward0.range0.to=22" qemu_commandline="-fw_cfg name=opt/com.coreos/config,file=$ignition" fi +if [[ -n "$vm_uuid" ]]; then + printf '%s\n' "$vm_uuid" > "$output_dir/domain.uuid.tmp" + chmod 600 "$output_dir/domain.uuid.tmp" + mv -f "$output_dir/domain.uuid.tmp" "$output_dir/domain.uuid" +fi echo "Creating $vm_name on $libvirt_uri, disk $output_dir/$vm_name.qcow2, SSH 127.0.0.1:$ssh_port" >&2 if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then - vm_command virt-install --connect "$libvirt_uri" --name "$vm_name" \ + uuid_args=() + [[ -z "$vm_uuid" ]] || uuid_args=(--uuid "$vm_uuid") + vm_command virt-install --connect "$libvirt_uri" --name "$vm_name" "${uuid_args[@]}" \ --osinfo linux2024 --memory 8192 --vcpus 2 --import \ --disk "path=$output_dir/$vm_name.qcow2,format=qcow2,bus=virtio" \ --disk "path=$ignition,format=raw,bus=virtio,readonly=on" \ @@ -186,13 +216,15 @@ else emulator="$(vm_command virsh -c "$libvirt_uri" capabilities | xmllint --xpath "string((//guest[os_type='hvm']/arch[@name='x86_64']/emulator)[1])" -)" [[ -n "$emulator" && -x "$emulator" ]] || { echo 'No usable x86_64 libvirt emulator' >&2; exit 1; } - python3 - "$vm_name" "$emulator" "$output_dir/$vm_name.qcow2" "$ignition" "$ssh_port" > "$output_dir/domain.xml" <<'PY' + python3 - "$vm_name" "$vm_uuid" "$emulator" "$output_dir/$vm_name.qcow2" "$ignition" "$ssh_port" > "$output_dir/domain.xml" <<'PY' import sys import xml.etree.ElementTree as ET -name, emulator, disk, ignition, port = sys.argv[1:] +name, uuid, emulator, disk, ignition, port = sys.argv[1:] domain = ET.Element('domain', type='kvm') ET.SubElement(domain, 'name').text = name +if uuid: + ET.SubElement(domain, 'uuid').text = uuid ET.SubElement(domain, 'memory', unit='MiB').text = '8192' ET.SubElement(domain, 'vcpu').text = '2' os = ET.SubElement(domain, 'os') @@ -227,8 +259,11 @@ ET.ElementTree(domain).write(sys.stdout, encoding='unicode', xml_declaration=Tru PY virt-xml-validate "$output_dir/domain.xml" vm_command virsh -c "$libvirt_uri" define "$output_dir/domain.xml" - if ! vm_command virsh -c "$libvirt_uri" start "$vm_name"; then - vm_command virsh -c "$libvirt_uri" undefine "$vm_name" - exit 1 + if [[ -n "${SKILLET_BINARY:-}" ]]; then + "$SKILLET_BINARY" test vm defined "$host" "${vm_name#"$host-test-"}" + else + (cd "$repo_dir/skillet" && cargo run --quiet --release -p skillet -- \ + test vm defined "$host" "${vm_name#"$host-test-"}") fi + vm_command virsh -c "$libvirt_uri" start "$vm_name" fi diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 8a980fe9..40f098a4 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -98,6 +98,13 @@ usage() { exit 2 } config_value() { sed -n "s/^$1=//p" "$run_dir/run.conf" | head -n 1; } +skillet_cli() { + if [[ -n "${SKILLET_BINARY:-}" ]]; then + "$SKILLET_BINARY" "$@" + else + (cd "$script_dir/../../skillet" && cargo run --quiet --release -p skillet -- "$@") + fi +} host_exists() { [[ -f "$script_dir/../$1.bu" || -f "$script_dir/../../skillet/crates/hosts/$1/Cargo.toml" ]] } @@ -179,7 +186,6 @@ case "$operation" in usage fi [[ "$port" =~ ^22[0-9][0-9]$ ]] || { echo "Use a port from 2200 to 2299" >&2; exit 2; } - [[ ! -e "$run_dir" && ! -L "$run_dir" ]] || { echo "Run path already exists" >&2; exit 1; } host_available "$host" || { echo "Host $host needs both $host.bu and a skillet-$host crate" >&2; exit 1; } select_backend if [[ "$backend" == native ]]; then @@ -194,13 +200,30 @@ case "$operation" in exit 1 fi ensure_runtime + mkdir -p "$root" + if vm_virsh dominfo "$name" &>/dev/null && [[ ! -f "$run_dir/vm.json" ]]; then + echo "Domain already exists without a recorded creation intent; refusing adoption" >&2 + exit 1 + fi + prepare_args=(test vm prepare "$host" "$instance" --backend "$backend" --runtime-dir "$runtime_dir" --uri "$uri" --port "$port") + uuid="$(skillet_cli "${prepare_args[@]}")" if vm_virsh dominfo "$name" &>/dev/null; then - echo "Domain already exists" >&2; exit 1 + skillet_cli test vm status "$host" "$instance" + state="$(vm_virsh domstate "$name" | tr -d '\r')" + if [[ "$state" == "shut off" ]]; then + vm_virsh start "$name" + elif [[ "$state" != "running" ]]; then + echo "Cannot resume VM in libvirt state: $state" >&2 + exit 1 + fi + skillet_cli test vm started "$host" "$instance" + echo "Resumed recorded VM $name" + exit 0 fi - mkdir -p "$root" TEST_VM_BACKEND="$backend" TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" "$script_dir/coreos-install" --name "$name" --output-dir "$run_dir" \ - --ssh-port "$port" --libvirt-uri "$uri" "$script_dir/../$host.bu" + --ssh-port "$port" --libvirt-uri "$uri" --uuid "$uuid" --resume-prepared "$script_dir/../$host.bu" vm_virsh domuuid "$name" > "$run_dir/domain.uuid" + skillet_cli test vm started "$host" "$instance" echo "Run: $run_dir" ;; ssh) (($# == 0)) || usage diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 4d4bf0f5..219a0598 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -42,9 +42,13 @@ boot expectations and captured binaries, and records Ready only after signed boot, unit, SELinux, resolver and user-environment checks. Bounded probes and private diagnostics allow failed checks to be retried. It first persists Started to invalidate stale readiness, then checks ownership before and after -each guest command/upload. Creation and other -remaining lifecycle commands still use Bash. Unit/adapter checks pass; -live migration acceptance is deferred while the user's VM creation runs. +each guest command/upload. Creation still uses the Bash installer, with Rust +persisting a private `Preparing` intent and UUID first. The generated UUID is +now part of native XML and Flatpak creation. Native definition and start are +separate manifest phases; failed starts retain the defined domain for +inspection and retry. Re-running create validates and starts that owned domain, +or resumes staging when no domain exists. Unit/adapter checks pass; live +migration acceptance is deferred while the user's VM creation runs. ## States and recovery diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 77b82e5b..00b59fa3 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -9,7 +9,8 @@ Implemented foundation: `skillet_vm` owns validated host/instance identity, backend/connection and SSH types, version-1 `vm.json`, atomic mode-0600 writes, and explicit legacy import. Reads do not rewrite retained runs. Legacy files, captured/deployed hashes and provider journals remain intact. The generic CLI -uses the shared identity validator. Eight manifest regression tests pass. +uses the shared identity validator. Manifest regressions cover strict import, +preserved journals/hashes, permissions, ownership, and path refusal. Rust now owns status inspection and disposal through `VmBackend`, a bounded native/Flatpak libvirt adapter, and one locked cleanup orchestrator. Both @@ -21,7 +22,7 @@ implemented and used by the routine container runner; a custom target-directory integration check selects the reported binary. `GuestTransport`/`SshTransport` now provide explicit target/key policy, literal remote executable arguments, noninteractive execution and upload. Subprocess stdin/output stay in memory, -with bounded concurrent I/O and zeroized copied stdin. Forty-nine VM tests pass. +with bounded concurrent I/O and zeroized copied stdin. Fifty VM tests pass. No live VM was changed or imported. Retained binary updates now use Rust's shared lock, ownership checks, Cargo @@ -36,12 +37,20 @@ Readiness first persists `Started`, invalidating any earlier success; it saves `Ready` and deployed hashes only after acceptance and a final ownership check. Every guest command and upload checks ownership before and after the operation, so a change during a long service start stops subsequent guest work. -Creation, reboot and interactive SSH still use Bash. Credential delivery has +Creation now records a Rust-owned Preparing manifest and UUID before invoking +the still-Bash installer; native XML and Flatpak use that UUID. Definition and +start are recorded as separate phases where the backend exposes them. A failed +start keeps its domain and can be validated/restarted through the same create +entry point. Installer staging can resume from a matching Preparing manifest. +Disk/key preparation, XML generation, reboot and interactive SSH still use +Bash. Credential delivery has not yet adopted the shared transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability declaration. -Next: recoverable creation and the remaining public-helper delegation. Disposal -and readiness live acceptance remain deferred. See -[validation evidence](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). +Next: move staging, disk/key preparation and backend creation into Rust, then +migrate reboot and interactive SSH. Disposal and readiness live acceptance +remain deferred. See +[creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) +and [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). ## Read and locate @@ -52,8 +61,8 @@ and readiness live acceptance remain deferred. See `secret_delivery.rs` provisioning, external ownership, cleanup, and VM SSH. Review findings: disposal used to bypass external cleanup or require a live -guest; those paths are now fixed. Remaining: failed creation can leave artifacts -without a usable lifecycle record; readiness and target selection are duplicated. +guest; those paths are now fixed. Persisted intent now makes interrupted +creation inspectable; readiness and target selection remain duplicated. ## Implementation sequence diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index a4f0df8b..8aa30fd3 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -121,6 +121,15 @@ enum TestCommands { enum VmCommands { /// Provision a disposable host VM and wait for it to become ready Create(VmCreateArgs), + /// Persist a recoverable VM creation intent for the source-tree helper + #[command(hide = true)] + Prepare(VmPrepareArgs), + /// Record successful domain definition for the source-tree helper + #[command(hide = true)] + Defined(VmTargetArgs), + /// Record successful VM start for the source-tree helper + #[command(hide = true)] + Started(VmTargetArgs), /// Destroy a disposable host VM and remove its temporary key and artifacts Destroy(VmDestroyArgs), /// List available host templates and their recorded disposable VMs @@ -146,6 +155,26 @@ struct VmCreateArgs { port: u16, } +#[derive(clap::Args, Debug)] +struct VmPrepareArgs { + hostname: String, + instance: String, + #[arg(long, value_enum)] + backend: VmBackendName, + #[arg(long)] + runtime_dir: PathBuf, + #[arg(long, default_value = "qemu:///session")] + uri: String, + #[arg(long, default_value_t = 2201)] + port: u16, +} + +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum VmBackendName { + Native, + Flatpak, +} + #[derive(clap::Args, Debug)] struct VmTargetArgs { hostname: String, @@ -262,53 +291,25 @@ fn main() -> Result<()> { command: TestCommands::Smoke(args), } => run_smoke(&args)?, Commands::Test { - command: - TestCommands::Vm { - command: VmCommands::Create(args), - }, - } => run_vm_create(&args)?, - Commands::Test { - command: - TestCommands::Vm { - command: VmCommands::Destroy(args), - }, - } => run_vm_destroy(&args)?, - Commands::Test { - command: - TestCommands::Vm { - command: VmCommands::List(args), - }, - } => run_vm_list(&args)?, - Commands::Test { - command: - TestCommands::Vm { - command: VmCommands::Status(args), - }, - } => vm::status(&args)?, - Commands::Test { - command: - TestCommands::Vm { - command: VmCommands::Ready(args), - }, - } => vm::ready(&args)?, - Commands::Test { - command: - TestCommands::Vm { - command: VmCommands::ReadyDirectory(args), - }, - } => vm::ready_directory(&args)?, - Commands::Test { - command: - TestCommands::Vm { - command: VmCommands::Provision(args), - }, - } => secret_delivery::provision_vm(&args)?, - Commands::Test { - command: - TestCommands::Vm { - command: VmCommands::Update(args), - }, - } => run_vm_update(&args)?, + command: TestCommands::Vm { command }, + } => run_vm_command(command)?, + } + Ok(()) +} + +fn run_vm_command(command: VmCommands) -> Result<()> { + match command { + VmCommands::Create(args) => run_vm_create(&args)?, + VmCommands::Prepare(args) => run_vm_prepare(&args)?, + VmCommands::Defined(args) => vm::record_defined(&args)?, + VmCommands::Started(args) => vm::record_started(&args)?, + VmCommands::Destroy(args) => run_vm_destroy(&args)?, + VmCommands::List(args) => run_vm_list(&args)?, + VmCommands::Status(args) => vm::status(&args)?, + VmCommands::Ready(args) => vm::ready(&args)?, + VmCommands::ReadyDirectory(args) => vm::ready_directory(&args)?, + VmCommands::Provision(args) => secret_delivery::provision_vm(&args)?, + VmCommands::Update(args) => run_vm_update(&args)?, } Ok(()) } @@ -364,9 +365,11 @@ fn run_vm_create(args: &VmCreateArgs) -> Result<()> { let butane = butane_root()?; let helper = butane.join("bin/test-vm"); let port = args.port.to_string(); - run_helper( + let binary = std::env::current_exe().context("locating the running Skillet binary failed")?; + run_helper_with_binary( &helper, &[&args.hostname, "create", &args.instance, "--port", &port], + &binary, )?; let run_dir = butane.join("runs").join(&name); @@ -388,6 +391,40 @@ fn run_vm_create(args: &VmCreateArgs) -> Result<()> { Ok(()) } +fn run_vm_prepare(args: &VmPrepareArgs) -> Result<()> { + let identity = skillet_vm::RunIdentity::new(&args.hostname, &args.instance)?; + let root = butane_root()?.join("runs"); + let source_commit = Command::new("git") + .args(["-C"]) + .arg(workspace_root()?) + .args(["rev-parse", "--verify", "HEAD"]) + .output() + .context("reading the source revision for VM creation failed")?; + if !source_commit.status.success() { + return Err(anyhow!("git could not identify the source revision")); + } + let source_commit = String::from_utf8(source_commit.stdout) + .context("git returned a non-UTF-8 source revision")? + .trim() + .to_owned(); + let backend = match args.backend { + VmBackendName::Native => skillet_vm::Backend::Native, + VmBackendName::Flatpak => skillet_vm::Backend::Flatpak, + }; + let run = skillet_vm::ManifestStore::new(&root, skillet_vm::current_uid())?.prepare_intent( + &identity, + skillet_vm::Connection { + backend, + uri: args.uri.clone(), + runtime_dir: args.runtime_dir.clone(), + }, + args.port, + &source_commit, + )?; + println!("{}", run.uuid); + Ok(()) +} + fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { vm::destroy(args) } @@ -404,8 +441,9 @@ fn vm_name(hostname: &str, instance: &str) -> Result { Ok(skillet_vm::RunIdentity::new(hostname, instance)?.domain_name()) } -fn run_helper(path: &Path, args: &[&str]) -> Result<()> { +fn run_helper_with_binary(path: &Path, args: &[&str], binary: &Path) -> Result<()> { let status = Command::new(path) + .env("SKILLET_BINARY", binary) .args(args) .status() .with_context(|| format!("running {} failed", path.display()))?; diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index eef2eaf3..f6154a3d 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -32,6 +32,36 @@ pub(super) fn status(args: &VmTargetArgs) -> Result<()> { Ok(()) } +pub(super) fn record_started(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let _lock = store.lock(&identity)?; + let run = store.load(&identity)?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("VM creation returned without an owned domain"))? + .validate_owned(&run)?; + store.mark_started(&identity)?; + Ok(()) +} + +pub(super) fn record_defined(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let _lock = store.lock(&identity)?; + let run = store.load(&identity)?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("VM definition returned without an owned domain"))? + .validate_owned(&run)?; + store.mark_defined(&identity)?; + Ok(()) +} + pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { let butane = butane_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs index 4c7dcc61..d9bd77eb 100644 --- a/ublue/skillet/crates/vm/src/manifest.rs +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -157,6 +157,113 @@ impl ManifestStore { &self.root } + /// Create or resume a creation intent before any guest resources are + /// defined. Matching Preparing and Defined states can be recovered; + /// ambiguous or progressed runs must be inspected explicitly. + pub fn prepare_intent( + &self, + identity: &RunIdentity, + connection: Connection, + ssh_port: u16, + source_commit: &str, + ) -> Result { + if !(2200..=2299).contains(&ssh_port) || source_commit.trim().is_empty() { + return Err(Error::Invalid( + "invalid SSH port or empty source revision for VM creation".into(), + )); + } + connection.validate(self.owner_uid)?; + let dir = self.run_dir(identity); + reject_symlinks(&self.root)?; + if !self.root.exists() { + fs::create_dir_all(&self.root)?; + } + reject_symlinks(&self.root)?; + let root_metadata = fs::metadata(&self.root)?; + if !root_metadata.is_dir() || root_metadata.uid() != self.owner_uid { + return Err(Error::Invalid( + "VM artifact root is not a directory owned by the current user".into(), + )); + } + if dir.exists() { + let existing = self.load(identity)?; + if matches!(existing.phase, Phase::Preparing | Phase::Defined) + && existing.connection == connection + && existing.ssh.port == ssh_port + && existing.source_commit == source_commit + { + return Ok(existing); + } + return Err(Error::Invalid( + "VM run already exists and is not a matching recoverable creation; inspect or dispose it before creating".into(), + )); + } + fs::create_dir(&dir)?; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; + let run = VmRun { + version: MANIFEST_VERSION, + owner_uid: self.owner_uid, + identity: identity.clone(), + environment: Environment::Test, + guest_hostname: identity.domain_name(), + uuid: Uuid::new_v4(), + connection, + ssh: SshTarget { + user: "giacomo".into(), + address: "127.0.0.1".into(), + port: ssh_port, + identity: dir.join("ssh/id_ed25519"), + known_hosts: dir.join("ssh/known_hosts"), + }, + disk: dir.join(format!("{}.qcow2", identity.domain_name())), + ignition: dir + .join("ignition") + .join(format!("{}.ign", identity.host())), + source_commit: source_commit.into(), + captured: None, + deployed: None, + phase: Phase::Preparing, + }; + self.write(&run, false)?; + Ok(run) + } + + /// Record successful VM definition/start only after callers validate the + /// runtime UUID and disk ownership. Hashes are read from the captured files. + pub fn mark_started(&self, identity: &RunIdentity) -> Result { + let mut run = self.load(identity)?; + if !matches!(run.phase, Phase::Preparing | Phase::Defined) { + return Err(Error::Invalid( + "only a preparing or defined VM can be recorded as started".into(), + )); + } + let dir = self.run_dir(identity); + run.captured = Some(ArtifactHashes { + host: read_hash(&dir.join("skillet.sha256"))?, + generic: read_hash(&dir.join("skillet-generic.sha256"))?, + }); + run.phase = Phase::Started; + self.save(&run)?; + Ok(run) + } + + pub fn mark_defined(&self, identity: &RunIdentity) -> Result { + let mut run = self.load(identity)?; + if run.phase != Phase::Preparing { + return Err(Error::Invalid( + "only a preparing VM can be recorded as defined".into(), + )); + } + let dir = self.run_dir(identity); + run.captured = Some(ArtifactHashes { + host: read_hash(&dir.join("skillet.sha256"))?, + generic: read_hash(&dir.join("skillet-generic.sha256"))?, + }); + run.phase = Phase::Defined; + self.save(&run)?; + Ok(run) + } + /// Compatibility entry points obtain identity from recorded fields, never /// by splitting a potentially ambiguous composite directory name. pub fn load_directory(&self, dir: &Path) -> Result { diff --git a/ublue/skillet/crates/vm/src/manifest/tests.rs b/ublue/skillet/crates/vm/src/manifest/tests.rs index 320f537e..bf0ca115 100644 --- a/ublue/skillet/crates/vm/src/manifest/tests.rs +++ b/ublue/skillet/crates/vm/src/manifest/tests.rs @@ -1,5 +1,5 @@ use super::*; -use std::os::unix::fs::symlink; +use std::{os::unix::fs::symlink, path::PathBuf}; pub(crate) fn legacy_run() -> (tempfile::TempDir, ManifestStore, RunIdentity) { let tmp = tempfile::tempdir().unwrap(); @@ -41,6 +41,61 @@ fn identity_separates_profile_and_instance_and_rejects_path_input() { } } +#[test] +fn preparation_intent_is_durable_repeatable_and_can_be_advanced_after_capture() { + let tmp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(tmp.path(), users::get_current_uid()).unwrap(); + let identity = RunIdentity::new("fixture", "intent").unwrap(); + let connection = Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir: PathBuf::from(format!("/run/user/{}", users::get_current_uid())), + }; + let first = store + .prepare_intent(&identity, connection.clone(), 2205, "fixture-revision") + .unwrap(); + assert_eq!(first.phase, Phase::Preparing); + assert!(first.captured.is_none()); + assert_eq!( + fs::metadata(store.run_dir(&identity)).unwrap().mode() & 0o777, + 0o700 + ); + let repeated = store + .prepare_intent(&identity, connection.clone(), 2205, "fixture-revision") + .unwrap(); + assert_eq!(repeated.uuid, first.uuid); + assert!(store + .prepare_intent(&identity, connection.clone(), 2206, "fixture-revision") + .is_err()); + + let dir = store.run_dir(&identity); + fs::write( + dir.join("skillet.sha256"), + format!("{} host\n", "a".repeat(64)), + ) + .unwrap(); + fs::write( + dir.join("skillet-generic.sha256"), + format!("{} generic\n", "b".repeat(64)), + ) + .unwrap(); + let defined = store.mark_defined(&identity).unwrap(); + assert_eq!(defined.phase, Phase::Defined); + assert_eq!( + store + .prepare_intent(&identity, connection.clone(), 2205, "fixture-revision") + .unwrap() + .uuid, + first.uuid + ); + let started = store.mark_started(&identity).unwrap(); + assert_eq!(started.phase, Phase::Started); + assert_eq!(started.captured.unwrap().host, "a".repeat(64)); + assert!(store + .prepare_intent(&identity, connection.clone(), 2205, "fixture-revision") + .is_err()); +} + #[test] fn legacy_read_is_non_mutating_and_preserves_original_artifacts() { let (_tmp, store, identity) = legacy_run(); From e4da71292e77df142ea0782027b6b7fcea8cb6b5 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 16:24:30 +0200 Subject: [PATCH 134/252] refactor(vm): render native domain XML in Rust --- ublue/butane/ACCEPTANCE.md | 17 ++ ublue/butane/README.md | 2 +- ublue/butane/bin/coreos-install | 52 +---- ublue/butane/bin/test-vm | 5 +- ublue/design/smoke-vms.md | 9 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 26 ++- ublue/skillet/crates/cli/src/main.rs | 12 + ublue/skillet/crates/cli/src/vm.rs | 17 +- ublue/skillet/crates/vm/src/backend.rs | 2 +- ublue/skillet/crates/vm/src/domain_xml.rs | 208 ++++++++++++++++++ .../skillet/crates/vm/src/domain_xml_tests.rs | 40 ++++ ublue/skillet/crates/vm/src/lib.rs | 3 + 12 files changed, 330 insertions(+), 63 deletions(-) create mode 100644 ublue/skillet/crates/vm/src/domain_xml.rs create mode 100644 ublue/skillet/crates/vm/src/domain_xml_tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 469c58fe..284dd640 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -18,6 +18,23 @@ both changed helpers, CLI help, and `git diff --check`. No VM, libvirt domain, vault, or provider resource was contacted for this change. +## Native XML rendering, 2026-10-03 + +- Native domain XML is rendered in `skillet_vm` with `quick-xml`, written + atomically as a private file under the manifest's run directory, and consumed + by the existing `virt-xml-validate` and `virsh define` steps. The renderer + carries the manifest UUID, exact disk and Ignition paths, QEMU fw_cfg input, + passt loopback SSH forwarding, serial console, and original machine profile. + Attribute escaping is checked using a path containing `&`. +- Removed the Python XML generator and the Python 3 VM preflight requirement. + Backend/emulator selection and subsequent define/start remain in Bash for now; + Flatpak still delegates VM construction to `virt-install`. +- Passed format, offline workspace all-target Clippy (`-D warnings`), workspace + tests (51 VM tests), build, ShellCheck for both changed helpers, and the CI + Fedora repeat-apply container integration. The renderer test validates the + generated XML against the same name, UUID and disk ownership parser used by + status inspection. No VM/libvirt domain was contacted. + ## Rust readiness migration, 2026-10-03 - The CLI's `test vm ready HOST INSTANCE`, create's readiness phase, diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 90771c85..0f6a99cf 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -11,7 +11,7 @@ Design decisions: [disposable VM lifecycle](../design/smoke-vms.md) and `/dev/vda`, the virtio disk attached by this launcher. Do not use it as a physical-disk install file. The host needs Podman, `yq`, `rg`, `ssh-keygen`, and KVM. Native libvirt additionally needs `virsh`, `virtqemud`, -`virtstoraged`, `passt`, Python 3, `xmllint`, and `virt-xml-validate`; the helper +`virtstoraged`, `passt`, `xmllint`, and `virt-xml-validate`; the helper defines native VMs with `virsh`. The Flatpak backend uses QEMU user networking and an existing virt-manager with its QEMU extension. The shared [data storage design](../design/storage.md) is implemented by diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index bb9db526..cc9f621a 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -216,49 +216,15 @@ else emulator="$(vm_command virsh -c "$libvirt_uri" capabilities | xmllint --xpath "string((//guest[os_type='hvm']/arch[@name='x86_64']/emulator)[1])" -)" [[ -n "$emulator" && -x "$emulator" ]] || { echo 'No usable x86_64 libvirt emulator' >&2; exit 1; } - python3 - "$vm_name" "$vm_uuid" "$emulator" "$output_dir/$vm_name.qcow2" "$ignition" "$ssh_port" > "$output_dir/domain.xml" <<'PY' -import sys -import xml.etree.ElementTree as ET - -name, uuid, emulator, disk, ignition, port = sys.argv[1:] -domain = ET.Element('domain', type='kvm') -ET.SubElement(domain, 'name').text = name -if uuid: - ET.SubElement(domain, 'uuid').text = uuid -ET.SubElement(domain, 'memory', unit='MiB').text = '8192' -ET.SubElement(domain, 'vcpu').text = '2' -os = ET.SubElement(domain, 'os') -ET.SubElement(os, 'type', arch='x86_64', machine='q35').text = 'hvm' -ET.SubElement(os, 'boot', dev='hd') -features = ET.SubElement(domain, 'features') -ET.SubElement(features, 'acpi') -ET.SubElement(features, 'apic') -ET.SubElement(domain, 'cpu', mode='host-passthrough') -sysinfo = ET.SubElement(domain, 'sysinfo', type='fwcfg') -ET.SubElement(sysinfo, 'entry', name='opt/com.coreos/config', file=ignition) -devices = ET.SubElement(domain, 'devices') -ET.SubElement(devices, 'emulator').text = emulator -for source, fmt, target, readonly in ((disk, 'qcow2', 'vda', False), (ignition, 'raw', 'vdb', True)): - device = ET.SubElement(devices, 'disk', type='file', device='disk') - ET.SubElement(device, 'driver', name='qemu', type=fmt) - ET.SubElement(device, 'source', file=source) - ET.SubElement(device, 'target', dev=target, bus='virtio') - if readonly: - ET.SubElement(device, 'readonly') -interface = ET.SubElement(devices, 'interface', type='user') -ET.SubElement(interface, 'backend', type='passt') -forward = ET.SubElement(interface, 'portForward', proto='tcp', address='127.0.0.1') -ET.SubElement(forward, 'range', start=port, to='22') -ET.SubElement(interface, 'model', type='virtio') -serial = ET.SubElement(devices, 'serial', type='pty') -ET.SubElement(serial, 'target', port='0') -console = ET.SubElement(devices, 'console', type='pty') -ET.SubElement(console, 'target', type='serial', port='0') -ET.indent(domain) -ET.ElementTree(domain).write(sys.stdout, encoding='unicode', xml_declaration=True) -PY - virt-xml-validate "$output_dir/domain.xml" - vm_command virsh -c "$libvirt_uri" define "$output_dir/domain.xml" + if [[ -n "${SKILLET_BINARY:-}" ]]; then + domain_xml="$("$SKILLET_BINARY" test vm render-domain "$host" "${vm_name#"$host-test-"}" --emulator "$emulator")" + else + domain_xml="$(cd "$repo_dir/skillet" && cargo run --quiet --release -p skillet -- \ + test vm render-domain "$host" "${vm_name#"$host-test-"}" --emulator "$emulator")" + fi + [[ "$domain_xml" == "$output_dir/domain.xml" ]] || { echo 'Rust returned an unexpected domain XML path' >&2; exit 1; } + virt-xml-validate "$domain_xml" + vm_command virsh -c "$libvirt_uri" define "$domain_xml" if [[ -n "${SKILLET_BINARY:-}" ]]; then "$SKILLET_BINARY" test vm defined "$host" "${vm_name#"$host-test-"}" else diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 40f098a4..e7f7c321 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -190,9 +190,8 @@ case "$operation" in select_backend if [[ "$backend" == native ]]; then command -v passt >/dev/null || { echo 'Missing native VM tool: passt' >&2; exit 1; } - if ! command -v python3 >/dev/null || ! command -v xmllint >/dev/null || - ! command -v virt-xml-validate >/dev/null; then - echo 'Native VM creation needs python3, xmllint and virt-xml-validate' >&2; exit 1 + if ! command -v xmllint >/dev/null || ! command -v virt-xml-validate >/dev/null; then + echo 'Native VM creation needs xmllint and virt-xml-validate' >&2; exit 1 fi fi if [[ ! -e /dev/kvm ]]; then diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 219a0598..2b339e39 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -46,9 +46,12 @@ each guest command/upload. Creation still uses the Bash installer, with Rust persisting a private `Preparing` intent and UUID first. The generated UUID is now part of native XML and Flatpak creation. Native definition and start are separate manifest phases; failed starts retain the defined domain for -inspection and retry. Re-running create validates and starts that owned domain, -or resumes staging when no domain exists. Unit/adapter checks pass; live -migration acceptance is deferred while the user's VM creation runs. +inspection and retry. Native domain XML now renders through the Rust XML +library, preserving the recorded UUID, disk ownership, fw_cfg and passt network +contract. Re-running create validates and starts that owned domain, or resumes +staging when no domain exists. Backend selection, emulator discovery, libvirt +define/start, Flatpak `virt-install`, and staging remain in Bash. Unit/adapter +checks pass; live migration acceptance is deferred. ## States and recovery diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 00b59fa3..336a0583 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -42,15 +42,17 @@ the still-Bash installer; native XML and Flatpak use that UUID. Definition and start are recorded as separate phases where the backend exposes them. A failed start keeps its domain and can be validated/restarted through the same create entry point. Installer staging can resume from a matching Preparing manifest. -Disk/key preparation, XML generation, reboot and interactive SSH still use +Native XML generation now uses `quick-xml`; disk/key preparation, reboot and +interactive SSH still use Bash. Credential delivery has not yet adopted the shared transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability declaration. -Next: move staging, disk/key preparation and backend creation into Rust, then -migrate reboot and interactive SSH. Disposal and readiness live acceptance -remain deferred. See +Next: move staging, disk/key preparation and backend define/start into Rust, +then migrate reboot and interactive SSH. Flatpak `virt-install` creation remains +pending. Disposal and readiness live acceptance remain deferred. See [creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) -and [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). +and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), +plus [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). ## Read and locate @@ -80,12 +82,14 @@ creation inspectable; readiness and target selection remain duplicated. and Flatpak subprocess adapters invoking focused tools directly. Preserve current connection/runtime selection and networking behavior. Do not link native libvirt, embed shell, or install tools during this migration. -4. Move list/status/create/reboot/update into Rust. Generate domain XML with - an XML library, stage copied templates with structured data operations, and - build artifacts through a shared Cargo helper. Keep the standalone Butane - compiler if it still only compiles configuration. Replace VM disk/key - preparation, Python XML generation, and lifecycle logic in `coreos-install`. - Validate required tools and SSH-port conflicts before costly provisioning. +4. Move list/status/create/reboot/update into Rust. Native domain XML now uses + `quick-xml`; Flatpak still uses `virt-install`. Stage copied templates with + structured data operations and build artifacts through a shared Cargo + helper. Keep the standalone Butane compiler if it still only compiles + configuration. Replace VM disk/key preparation and remaining lifecycle + logic in `coreos-install`. Validate required tools and SSH-port conflicts + before costly provisioning. See + [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03). 5. Persist creation intent and a generated UUID before domain definition; advance through explicit recoverable phases as side effects complete. Failed starts retain owned state for inspect/retry/disposal. Distinguish diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 8aa30fd3..c235db8d 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -130,6 +130,9 @@ enum VmCommands { /// Record successful VM start for the source-tree helper #[command(hide = true)] Started(VmTargetArgs), + /// Render a native domain definition from the recorded run + #[command(hide = true)] + RenderDomain(VmRenderDomainArgs), /// Destroy a disposable host VM and remove its temporary key and artifacts Destroy(VmDestroyArgs), /// List available host templates and their recorded disposable VMs @@ -169,6 +172,14 @@ struct VmPrepareArgs { port: u16, } +#[derive(clap::Args, Debug)] +struct VmRenderDomainArgs { + hostname: String, + instance: String, + #[arg(long)] + emulator: PathBuf, +} + #[derive(Clone, Copy, Debug, clap::ValueEnum)] enum VmBackendName { Native, @@ -303,6 +314,7 @@ fn run_vm_command(command: VmCommands) -> Result<()> { VmCommands::Prepare(args) => run_vm_prepare(&args)?, VmCommands::Defined(args) => vm::record_defined(&args)?, VmCommands::Started(args) => vm::record_started(&args)?, + VmCommands::RenderDomain(args) => vm::render_domain(&args)?, VmCommands::Destroy(args) => run_vm_destroy(&args)?, VmCommands::List(args) => run_vm_list(&args)?, VmCommands::Status(args) => vm::status(&args)?, diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index f6154a3d..c40a8d2e 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -1,7 +1,7 @@ //! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. use super::{ butane_root, secret_delivery, workspace_root, VmDestroyArgs, VmDirectoryArgs, VmListArgs, - VmTargetArgs, + VmRenderDomainArgs, VmTargetArgs, }; use anyhow::{anyhow, Result}; use skillet_vm::{ @@ -62,6 +62,21 @@ pub(super) fn record_defined(args: &VmTargetArgs) -> Result<()> { Ok(()) } +pub(super) fn render_domain(args: &VmRenderDomainArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load(&identity)?; + if run.phase != skillet_vm::Phase::Preparing { + return Err(anyhow!( + "domain XML can only be rendered for a preparing VM" + )); + } + let path = skillet_vm::domain_xml::write_native_domain_xml(&store, &run, &args.emulator)?; + println!("{}", path.display()); + Ok(()) +} + pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { let butane = butane_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; diff --git a/ublue/skillet/crates/vm/src/backend.rs b/ublue/skillet/crates/vm/src/backend.rs index 1b96b55b..1f8bd413 100644 --- a/ublue/skillet/crates/vm/src/backend.rs +++ b/ublue/skillet/crates/vm/src/backend.rs @@ -237,7 +237,7 @@ struct DiskSourceXml { file: Option, } -fn parse_domain(xml: &str, state: String) -> Result { +pub(crate) fn parse_domain(xml: &str, state: String) -> Result { let domain: DomainXml = quick_xml::de::from_str(xml)?; let mut disks = Vec::new(); for disk in domain.devices.disks { diff --git a/ublue/skillet/crates/vm/src/domain_xml.rs b/ublue/skillet/crates/vm/src/domain_xml.rs new file mode 100644 index 00000000..340e8412 --- /dev/null +++ b/ublue/skillet/crates/vm/src/domain_xml.rs @@ -0,0 +1,208 @@ +//! Native libvirt domain XML rendering for a recorded test run. +use crate::{Backend, Error, ManifestStore, Phase, Result, VmRun}; +use quick_xml::{ + events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}, + Writer, +}; +use std::{ + fs::{self, File}, + io::Write as _, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, +}; + +/// Write the native KVM definition beside its owned disk and Ignition files. +/// The output is atomically replaced and never follows a symlink. +pub fn write_native_domain_xml( + store: &ManifestStore, + run: &VmRun, + emulator: &Path, +) -> Result { + store.validate(run, &run.identity)?; + if run.connection.backend != Backend::Native + || !matches!(run.phase, Phase::Preparing | Phase::Defined) + || !emulator.is_absolute() + || !emulator.is_file() + { + return Err(Error::Invalid( + "native domain XML requires a native preparing run and absolute emulator".into(), + )); + } + let dir = run + .disk + .parent() + .ok_or_else(|| Error::Invalid("VM disk has no parent directory".into()))?; + for path in [&run.disk, &run.ignition] { + crate::manifest::reject_symlinks(path)?; + if !fs::metadata(path)?.is_file() { + return Err(Error::Invalid( + "VM disk and Ignition must be regular files".into(), + )); + } + } + crate::manifest::reject_symlinks(dir)?; + let path = dir.join("domain.xml"); + crate::manifest::reject_symlinks(&path)?; + let xml = render(run, emulator)?; + let mut output = tempfile::NamedTempFile::new_in(dir)?; + output + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + output.write_all(xml.as_bytes())?; + output.write_all(b"\n")?; + output.as_file().sync_all()?; + output + .persist(&path) + .map_err(|error| Error::Io(error.error))?; + File::open(dir)?.sync_all()?; + Ok(path) +} + +fn render(run: &VmRun, emulator: &Path) -> Result { + let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2); + writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; + start(&mut writer, "domain", &[("type", "kvm".into())])?; + text_element(&mut writer, "name", &run.identity.domain_name())?; + text_element(&mut writer, "uuid", &run.uuid.to_string())?; + text_element_with_attrs(&mut writer, "memory", "8192", &[("unit", "MiB".into())])?; + text_element(&mut writer, "vcpu", "2")?; + + start(&mut writer, "os", &[])?; + text_element_with_attrs( + &mut writer, + "type", + "hvm", + &[("arch", "x86_64".into()), ("machine", "q35".into())], + )?; + empty(&mut writer, "boot", &[("dev", "hd".into())])?; + end(&mut writer, "os")?; + + start(&mut writer, "features", &[])?; + empty(&mut writer, "acpi", &[])?; + empty(&mut writer, "apic", &[])?; + end(&mut writer, "features")?; + empty(&mut writer, "cpu", &[("mode", "host-passthrough".into())])?; + + start(&mut writer, "sysinfo", &[("type", "fwcfg".into())])?; + empty( + &mut writer, + "entry", + &[ + ("name", "opt/com.coreos/config".into()), + ("file", run.ignition.display().to_string()), + ], + )?; + end(&mut writer, "sysinfo")?; + + start(&mut writer, "devices", &[])?; + text_element(&mut writer, "emulator", &emulator.display().to_string())?; + disk(&mut writer, &run.disk, "qcow2", "vda", false)?; + disk(&mut writer, &run.ignition, "raw", "vdb", true)?; + start(&mut writer, "interface", &[("type", "user".into())])?; + empty(&mut writer, "backend", &[("type", "passt".into())])?; + start( + &mut writer, + "portForward", + &[("proto", "tcp".into()), ("address", "127.0.0.1".into())], + )?; + empty( + &mut writer, + "range", + &[("start", run.ssh.port.to_string()), ("to", "22".into())], + )?; + end(&mut writer, "portForward")?; + empty(&mut writer, "model", &[("type", "virtio".into())])?; + end(&mut writer, "interface")?; + start(&mut writer, "serial", &[("type", "pty".into())])?; + empty(&mut writer, "target", &[("port", "0".into())])?; + end(&mut writer, "serial")?; + start(&mut writer, "console", &[("type", "pty".into())])?; + empty( + &mut writer, + "target", + &[("type", "serial".into()), ("port", "0".into())], + )?; + end(&mut writer, "console")?; + end(&mut writer, "devices")?; + end(&mut writer, "domain")?; + String::from_utf8(writer.into_inner()) + .map_err(|_| Error::Invalid("generated libvirt XML was not UTF-8".into())) +} + +fn disk( + writer: &mut Writer>, + path: &Path, + format: &str, + target: &str, + readonly: bool, +) -> Result<()> { + start( + writer, + "disk", + &[("type", "file".into()), ("device", "disk".into())], + )?; + empty( + writer, + "driver", + &[("name", "qemu".into()), ("type", format.into())], + )?; + empty(writer, "source", &[("file", path.display().to_string())])?; + empty( + writer, + "target", + &[("dev", target.into()), ("bus", "virtio".into())], + )?; + if readonly { + empty(writer, "readonly", &[])?; + } + end(writer, "disk") +} + +fn start(writer: &mut Writer>, name: &str, attrs: &[(&str, String)]) -> Result<()> { + let mut element = BytesStart::new(name); + for (key, value) in attrs { + element.push_attribute((*key, value.as_str())); + } + writer.write_event(Event::Start(element))?; + Ok(()) +} + +fn empty(writer: &mut Writer>, name: &str, attrs: &[(&str, String)]) -> Result<()> { + let mut element = BytesStart::new(name); + for (key, value) in attrs { + element.push_attribute((*key, value.as_str())); + } + writer.write_event(Event::Empty(element))?; + Ok(()) +} + +fn text(writer: &mut Writer>, value: &str) -> Result<()> { + writer.write_event(Event::Text(BytesText::new(value)))?; + Ok(()) +} + +fn text_element(writer: &mut Writer>, name: &str, value: &str) -> Result<()> { + start(writer, name, &[])?; + text(writer, value)?; + end(writer, name) +} + +fn text_element_with_attrs( + writer: &mut Writer>, + name: &str, + value: &str, + attrs: &[(&str, String)], +) -> Result<()> { + start(writer, name, attrs)?; + text(writer, value)?; + end(writer, name) +} + +fn end(writer: &mut Writer>, name: &str) -> Result<()> { + writer.write_event(Event::End(BytesEnd::new(name)))?; + Ok(()) +} + +#[cfg(test)] +#[path = "domain_xml_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/domain_xml_tests.rs b/ublue/skillet/crates/vm/src/domain_xml_tests.rs new file mode 100644 index 00000000..5759c1ea --- /dev/null +++ b/ublue/skillet/crates/vm/src/domain_xml_tests.rs @@ -0,0 +1,40 @@ +use super::*; +use crate::{backend::parse_domain, Backend, Connection, RunIdentity}; +use std::path::PathBuf; + +#[test] +fn native_xml_keeps_the_existing_boot_network_and_owned_disk_contract() { + let tmp = tempfile::Builder::new() + .prefix("vm&xml-") + .tempdir() + .unwrap(); + let store = ManifestStore::new(tmp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("fixture", "xml").unwrap(); + let run = store + .prepare_intent( + &identity, + Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir: PathBuf::from(format!("/run/user/{}", crate::current_uid())), + }, + 2207, + "fixture-revision", + ) + .unwrap(); + fs::write(&run.disk, "disk").unwrap(); + fs::create_dir_all(run.ignition.parent().unwrap()).unwrap(); + fs::write(&run.ignition, "ignition").unwrap(); + + let path = write_native_domain_xml(&store, &run, Path::new("/usr/bin/true")).unwrap(); + let xml = fs::read_to_string(path).unwrap(); + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("vm&xml"), "{xml}"); + parse_domain(&xml, "defined".into()) + .unwrap() + .validate_owned(&run) + .unwrap(); +} diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index fa191fb7..829d33ac 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -4,6 +4,7 @@ pub mod artifacts; pub mod backend; pub mod catalog; pub mod delivery; +pub mod domain_xml; pub mod lifecycle; pub mod manifest; mod process; @@ -24,6 +25,8 @@ pub enum Error { Json(#[from] serde_json::Error), #[error("invalid libvirt XML: {0}")] Xml(#[from] quick_xml::DeError), + #[error("libvirt XML rendering failed: {0}")] + XmlWrite(#[from] quick_xml::Error), #[error("libvirt command {operation} failed (exit {code:?}); inspect the selected runtime")] Command { operation: String, From 45eec4bb50d83a9a3494d4353388e6445ca5afb5 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 16:53:13 +0200 Subject: [PATCH 135/252] refactor(vm): move local artifact preparation to Rust --- ublue/butane/ACCEPTANCE.md | 20 ++ ublue/butane/bin/coreos-install | 25 +- ublue/design/smoke-vms.md | 10 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 31 +-- ublue/skillet/crates/cli/src/main.rs | 27 +++ ublue/skillet/crates/cli/src/vm.rs | 12 +- ublue/skillet/crates/vm/src/lib.rs | 3 + ublue/skillet/crates/vm/src/provisioning.rs | 220 ++++++++++++++++++ .../crates/vm/src/provisioning_tests.rs | 103 ++++++++ 9 files changed, 417 insertions(+), 34 deletions(-) create mode 100644 ublue/skillet/crates/vm/src/provisioning.rs create mode 100644 ublue/skillet/crates/vm/src/provisioning_tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 284dd640..4180d985 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -734,3 +734,23 @@ host tool; it still uses normal command approvals. and the container ID stayed unchanged. Backup restore, reboot persistence, and expected host listener checks remain pending; see [the UniFi plan](../plan/CLAMPS-UNIFI.md). + +### Rust VM local artifact preparation, 2026-10-03 + +- `skillet_vm::provisioning` now owns preparation of the disposable VM's copied + disk and Ed25519 SSH key. Disk copies are staged beside the destination and + atomically renamed; a SHA-256 sidecar records the selected image. Repeated + preparation reuses a byte-identical disk and matching key pair, repairs a + changed image while the run is still `Preparing`, and rejects partial keys, + symlinked inputs, or progressed runs. The hidden `skillet test vm + prepare-local` command bridges `coreos-install` to this library and returns + the public key for Ignition rendering. +- `cargo fmt --all --check`, workspace pedantic Clippy with warnings denied, + all 131 workspace tests, and `cargo build --offline --workspace` passed. + `bash -n` and ShellCheck passed for `coreos-install`; `git diff --check` + passed. The CI-style `cargo run --release -p skillet -- test run beezelbot + --phase base --image fedora:latest` passed, including repeat apply without + service restarts. +- No VM was created or changed for this slice. Template staging, Butane + compilation and native define/start remain in the Bash installer; Flatpak + creation and end-to-end VM acceptance remain pending. diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index cc9f621a..3c105624 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -31,6 +31,13 @@ vm_command() { "$@" fi } +skillet_cli() { + if [[ -n "${SKILLET_BINARY:-}" ]]; then + "$SKILLET_BINARY" "$@" + else + (cd "$repo_dir/skillet" && cargo run --quiet --release -p skillet -- "$@") + fi +} while (($#)); do case "$1" in --name|--output-dir|--ssh-port|--artifact|--image|--libvirt-uri|--uuid) @@ -120,7 +127,7 @@ fi [[ -f "$image" ]] || { echo "Missing FCOS image: $image" >&2; exit 1; } image="$(realpath "$image")" mkdir -p "$(dirname "$output_dir")" -mkdir -p "$output_dir/source/includes" "$output_dir/source/files" "$output_dir/ssh" "$output_dir/ignition" +mkdir -p "$output_dir/source/includes" "$output_dir/source/files" "$output_dir/ignition" cp "$input" "$output_dir/source/$host.bu" cp "$butane_dir"/includes/*.bu "$output_dir/source/includes/" cp "$artifact" "$output_dir/source/files/skillet-$host" @@ -143,19 +150,7 @@ skillet_host_include="$output_dir/source/includes/skillet-$host.bu" [[ -f "$skillet_host_include" ]] || { echo "Missing host Skillet Butane include: $skillet_host_include" >&2; exit 1; } HOST="$host" yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet-" + strenv(HOST)))' \ "$skillet_host_include" -if [[ ! -f "$output_dir/ssh/id_ed25519" && ! -e "$output_dir/ssh/id_ed25519.pub" ]]; then - ssh-keygen -q -t ed25519 -N '' -f "$output_dir/ssh/id_ed25519" -elif [[ -f "$output_dir/ssh/id_ed25519" && -f "$output_dir/ssh/id_ed25519.pub" ]]; then - derived_public_key="$(ssh-keygen -y -f "$output_dir/ssh/id_ed25519")" - [[ "$derived_public_key" == "$(cat "$output_dir/ssh/id_ed25519.pub")" ]] || { - echo "Prepared SSH key pair is inconsistent" >&2; exit 1; - } -else - echo "Prepared SSH key pair is incomplete; inspect the run directory before retrying" >&2 - exit 1 -fi -chmod 600 "$output_dir/ssh/id_ed25519" -public_key="$(cat "$output_dir/ssh/id_ed25519.pub")" +public_key="$(skillet_cli test vm prepare-local "$host" "${vm_name#"$host-test-"}" --image "$image")" # The existing giacomo account survives the FCOS-to-uCore rebase. Inject the # disposable key into that same account and skip its interactive password # expiry service only in this staged VM copy. @@ -170,10 +165,8 @@ ignition_bytes="$(stat -c '%s' "$ignition")" if ((ignition_bytes > 1048576)); then echo "Warning: Ignition config is ${ignition_bytes} bytes; large QEMU fw_cfg configs can take a long time to read" >&2 fi -cp --reflink=auto "$image" "$output_dir/$vm_name.qcow2" sha256sum "$output_dir/source/files/skillet-$host" > "$output_dir/skillet.sha256" sha256sum "$output_dir/source/files/skillet" > "$output_dir/skillet-generic.sha256" -sha256sum "$image" > "$output_dir/fcos-image.sha256" printf 'vm=%s\nhost=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir=%s\nbackend=%s\nsource_commit=%s\n' \ "$vm_name" "$host" "$libvirt_uri" "$ssh_port" "$image" "$artifact" "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" "${TEST_VM_BACKEND:-native}" \ "$(git -C "$repo_dir" rev-parse HEAD)" > "$output_dir/run.conf" diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 2b339e39..3aeeb29e 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -61,9 +61,13 @@ reboots preserve them and disposal removes only owned state. Software TPM checks establish provisioning behavior; physical hardware acceptance remains required. See the [TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). -1. **Create:** build the generic CLI and host binary, allocate the run directory and SSH key, - and provision a fresh VM disk. Ignition contains the public SSH key and - base configuration. Large binaries travel over SSH because embedding them +1. **Create:** build the generic CLI and host binary, allocate the run directory, + and persist a `Preparing` manifest before local artifact changes. The Rust VM + library atomically copies the selected image, records its digest, and creates + or validates the run's Ed25519 SSH key pair. The Bash installer currently + stages templates and compiles Ignition; it receives the public key from the + hidden `test vm prepare-local` command. Ignition contains the public SSH key + and base configuration. Large binaries travel over SSH because embedding them in QEMU `fw_cfg` previously caused long boot delays. 2. **Ready:** wait for SSH, deliver both binaries, complete unsigned then signed rebase, and check the booted deployment, base apply, resolver, SELinux, and diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 336a0583..158e40fd 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -42,14 +42,16 @@ the still-Bash installer; native XML and Flatpak use that UUID. Definition and start are recorded as separate phases where the backend exposes them. A failed start keeps its domain and can be validated/restarted through the same create entry point. Installer staging can resume from a matching Preparing manifest. -Native XML generation now uses `quick-xml`; disk/key preparation, reboot and -interactive SSH still use -Bash. Credential delivery has -not yet adopted the shared transport. Workstream 2 must consolidate the interim -`boot_policy_for_host` lookup with the canonical capability declaration. -Next: move staging, disk/key preparation and backend define/start into Rust, -then migrate reboot and interactive SSH. Flatpak `virt-install` creation remains -pending. Disposal and readiness live acceptance remain deferred. See +Native XML generation now uses `quick-xml`. The VM library also prepares the +local disk atomically, records its source digest, and creates or validates the +per-run SSH key pair; `coreos-install` delegates that operation through a +hidden CLI command. Template staging and Butane compilation, backend +define/start, reboot and interactive SSH still use Bash. Credential delivery +has not yet adopted the shared transport. Workstream 2 must consolidate the +interim `boot_policy_for_host` lookup with the canonical capability +declaration. Next: move template staging and backend define/start into Rust, +then migrate reboot and interactive SSH. Flatpak `virt-install` creation +remains pending. Disposal and readiness live acceptance remain deferred. See [creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), plus [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). @@ -83,12 +85,13 @@ creation inspectable; readiness and target selection remain duplicated. current connection/runtime selection and networking behavior. Do not link native libvirt, embed shell, or install tools during this migration. 4. Move list/status/create/reboot/update into Rust. Native domain XML now uses - `quick-xml`; Flatpak still uses `virt-install`. Stage copied templates with - structured data operations and build artifacts through a shared Cargo - helper. Keep the standalone Butane compiler if it still only compiles - configuration. Replace VM disk/key preparation and remaining lifecycle - logic in `coreos-install`. Validate required tools and SSH-port conflicts - before costly provisioning. See + `quick-xml`; Flatpak still uses `virt-install`. Rust owns atomic disk + preparation and per-run key creation/validation; template staging, + Butane compilation and native define/start remain in `coreos-install`. + Stage copied templates with structured data operations and build artifacts + through a shared Cargo helper. Keep the standalone Butane compiler if it + still only compiles configuration. Validate required tools and SSH-port + conflicts before costly provisioning. See [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03). 5. Persist creation intent and a generated UUID before domain definition; advance through explicit recoverable phases as side effects complete. diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index c235db8d..6104720b 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -124,6 +124,9 @@ enum VmCommands { /// Persist a recoverable VM creation intent for the source-tree helper #[command(hide = true)] Prepare(VmPrepareArgs), + /// Prepare local image and SSH key artifacts for the source-tree helper + #[command(hide = true)] + PrepareLocal(VmPrepareLocalArgs), /// Record successful domain definition for the source-tree helper #[command(hide = true)] Defined(VmTargetArgs), @@ -172,6 +175,14 @@ struct VmPrepareArgs { port: u16, } +#[derive(clap::Args, Debug)] +struct VmPrepareLocalArgs { + hostname: String, + instance: String, + #[arg(long)] + image: PathBuf, +} + #[derive(clap::Args, Debug)] struct VmRenderDomainArgs { hostname: String, @@ -312,6 +323,7 @@ fn run_vm_command(command: VmCommands) -> Result<()> { match command { VmCommands::Create(args) => run_vm_create(&args)?, VmCommands::Prepare(args) => run_vm_prepare(&args)?, + VmCommands::PrepareLocal(args) => vm::prepare_local(&args)?, VmCommands::Defined(args) => vm::record_defined(&args)?, VmCommands::Started(args) => vm::record_started(&args)?, VmCommands::RenderDomain(args) => vm::render_domain(&args)?, @@ -673,4 +685,19 @@ mod tests { ]); assert!(parsed.is_ok()); } + + #[test] + fn vm_local_preparation_parses_profile_instance_and_image() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "vm", + "prepare-local", + "clamps", + "smoke", + "--image", + "/tmp/fcos.qcow2", + ]); + assert!(parsed.is_ok()); + } } diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index c40a8d2e..5b0d9d03 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -1,7 +1,7 @@ //! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. use super::{ butane_root, secret_delivery, workspace_root, VmDestroyArgs, VmDirectoryArgs, VmListArgs, - VmRenderDomainArgs, VmTargetArgs, + VmPrepareLocalArgs, VmRenderDomainArgs, VmTargetArgs, }; use anyhow::{anyhow, Result}; use skillet_vm::{ @@ -77,6 +77,16 @@ pub(super) fn render_domain(args: &VmRenderDomainArgs) -> Result<()> { Ok(()) } +pub(super) fn prepare_local(args: &VmPrepareLocalArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let public_key = + skillet_vm::provisioning::prepare_local_artifacts(&store, &identity, &args.image)?; + println!("{public_key}"); + Ok(()) +} + pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { let butane = butane_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index 829d33ac..2322ef36 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -8,6 +8,7 @@ pub mod domain_xml; pub mod lifecycle; pub mod manifest; mod process; +pub mod provisioning; pub mod readiness; pub mod transport; @@ -34,6 +35,8 @@ pub enum Error { }, #[error("VM tooling command exceeded its timeout")] Timeout, + #[error("VM artifact preparation failed: {0}")] + Preparation(String), #[error("external VM cleanup failed: {0}")] ExternalCleanup(String), #[error("VM run is busy; retry after its current operation finishes")] diff --git a/ublue/skillet/crates/vm/src/provisioning.rs b/ublue/skillet/crates/vm/src/provisioning.rs new file mode 100644 index 00000000..3a1a8a8d --- /dev/null +++ b/ublue/skillet/crates/vm/src/provisioning.rs @@ -0,0 +1,220 @@ +//! Local artifacts owned by a disposable VM creation run. +use crate::{manifest::reject_symlinks, Error, ManifestStore, Phase, Result, RunIdentity, VmRun}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write as _}, + os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}, + path::Path, + process::Command, + time::Duration, +}; + +/// Prepare the private SSH key and VM disk for a recorded, not-yet-defined run. +/// Repeated calls reuse a matching key and image, and repair a changed disk. +pub fn prepare_local_artifacts( + store: &ManifestStore, + identity: &RunIdentity, + image: &Path, +) -> Result { + let _lock = store.lock(identity)?; + let run = store.load(identity)?; + store.validate(&run, identity)?; + if run.phase != Phase::Preparing { + return Err(Error::Invalid( + "local VM artifacts can only be prepared for a preparing run".into(), + )); + } + prepare_disk(store, &run, image)?; + prepare_key(store, &run) +} + +fn prepare_disk(store: &ManifestStore, run: &VmRun, image: &Path) -> Result<()> { + reject_symlinks(image)?; + let mut source = OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(image)?; + if !source.metadata()?.is_file() { + return Err(Error::Invalid("VM image is not a regular file".into())); + } + let dir = store.run_dir(&run.identity); + reject_symlinks(&dir)?; + reject_symlinks(&run.disk)?; + let (source_hash, source_size) = hash_file(&mut source)?; + let sidecar = dir.join("fcos-image.sha256"); + let disk_matches = if run.disk.is_file() { + let mut disk = OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(&run.disk)?; + disk.metadata()?.is_file() + && disk.metadata()?.len() == source_size + && hash_file(&mut disk)?.0 == source_hash + } else { + false + }; + if !disk_matches { + let mut source = OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(image)?; + let mut staged = tempfile::NamedTempFile::new_in(&dir)?; + staged + .as_file() + .set_permissions(fs::Permissions::from_mode(0o644))?; + let mut hasher = Sha256::new(); + let mut copied = 0_u64; + let mut buffer = [0_u8; 16 * 1024]; + loop { + let count = source.read(&mut buffer)?; + if count == 0 { + break; + } + staged.write_all(&buffer[..count])?; + hasher.update(&buffer[..count]); + copied = + copied + .checked_add(u64::try_from(count).map_err(|_| { + Error::Invalid("VM image size exceeds supported range".into()) + })?) + .ok_or_else(|| Error::Invalid("VM image size overflow".into()))?; + } + if copied != source_size || hex::encode(hasher.finalize()) != source_hash { + return Err(Error::Invalid( + "VM image changed while it was being copied".into(), + )); + } + staged.as_file().sync_all()?; + staged + .persist(&run.disk) + .map_err(|error| Error::Io(error.error))?; + } + let source_name = image + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Error::Invalid("VM image filename is invalid".into()))?; + write_atomic( + &dir, + &sidecar, + format!("{source_hash} {source_name}\n").as_bytes(), + 0o600, + )?; + File::open(dir)?.sync_all()?; + Ok(()) +} + +fn prepare_key(store: &ManifestStore, run: &VmRun) -> Result { + let key = &run.ssh.identity; + let public = key.with_extension("pub"); + let dir = key + .parent() + .ok_or_else(|| Error::Invalid("SSH key path has no parent".into()))?; + reject_symlinks(dir)?; + if !dir.exists() { + fs::create_dir(dir)?; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + } + store.validate(run, &run.identity)?; + reject_symlinks(dir)?; + reject_symlinks(key)?; + reject_symlinks(&public)?; + match (key.exists(), public.exists()) { + (false, false) => { + let mut command = Command::new("ssh-keygen"); + command.args(["-q", "-t", "ed25519", "-N", ""]); + command.arg("-f").arg(key); + let output = match crate::process::capture(command, Duration::from_secs(30)) { + Ok(output) => output, + Err(error) => { + remove_incomplete_key(key, &public)?; + return Err(error); + } + }; + if !output.status.success() { + remove_incomplete_key(key, &public)?; + return Err(Error::Preparation("ssh-keygen failed".into())); + } + } + (true, true) => (), + _ => { + return Err(Error::Invalid( + "prepared SSH key pair is incomplete; inspect the run before retrying".into(), + )); + } + } + reject_symlinks(key)?; + reject_symlinks(&public)?; + let key_metadata = fs::metadata(key)?; + let public_metadata = fs::metadata(&public)?; + if !key_metadata.is_file() || !public_metadata.is_file() { + return Err(Error::Invalid("SSH key pair must be regular files".into())); + } + fs::set_permissions(key, fs::Permissions::from_mode(0o600))?; + let mut command = Command::new("ssh-keygen"); + command.arg("-y").arg("-f").arg(key); + let output = crate::process::capture(command, Duration::from_secs(30))?; + if !output.status.success() { + return Err(Error::Preparation( + "SSH private key could not be validated".into(), + )); + } + let derived = String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid("ssh-keygen returned an invalid public key".into()))?; + let saved = fs::read_to_string(public)?; + let derived = derived.trim(); + let saved = saved.trim(); + if derived.is_empty() || derived != saved { + return Err(Error::Invalid( + "prepared SSH key pair is inconsistent".into(), + )); + } + Ok(derived.to_owned()) +} + +fn remove_incomplete_key(key: &Path, public: &Path) -> Result<()> { + reject_symlinks(key)?; + reject_symlinks(public)?; + if key.exists() { + fs::remove_file(key)?; + } + if public.exists() { + fs::remove_file(public)?; + } + Ok(()) +} + +fn hash_file(file: &mut File) -> Result<(String, u64)> { + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 16 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + size = size + .checked_add( + u64::try_from(count) + .map_err(|_| Error::Invalid("VM image size exceeds supported range".into()))?, + ) + .ok_or_else(|| Error::Invalid("VM image size overflow".into()))?; + } + Ok((hex::encode(hasher.finalize()), size)) +} + +fn write_atomic(dir: &Path, path: &Path, bytes: &[u8], mode: u32) -> Result<()> { + reject_symlinks(path)?; + let mut file = tempfile::NamedTempFile::new_in(dir)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(mode))?; + file.write_all(bytes)?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| Error::Io(error.error))?; + Ok(()) +} + +#[cfg(test)] +#[path = "provisioning_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/provisioning_tests.rs b/ublue/skillet/crates/vm/src/provisioning_tests.rs new file mode 100644 index 00000000..9ac70bf5 --- /dev/null +++ b/ublue/skillet/crates/vm/src/provisioning_tests.rs @@ -0,0 +1,103 @@ +use super::*; +use crate::{Backend, Connection}; +use std::{fs, os::unix::fs::symlink, path::PathBuf}; + +fn preparing_run() -> (tempfile::TempDir, ManifestStore, RunIdentity, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(temp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("fixture", "provisioning").unwrap(); + let runtime_dir = PathBuf::from(format!("/run/user/{}", crate::current_uid())); + store + .prepare_intent( + &identity, + Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir, + }, + 2204, + "test-revision", + ) + .unwrap(); + let image = temp.path().join("source.qcow2"); + fs::write(&image, b"disposable image bytes").unwrap(); + (temp, store, identity, image) +} + +#[test] +fn local_artifacts_are_created_and_matching_retries_are_idempotent() { + let (_temp, store, identity, image) = preparing_run(); + let run = store.load(&identity).unwrap(); + let key = prepare_local_artifacts(&store, &identity, &image).unwrap(); + assert!(key.starts_with("ssh-ed25519 ")); + assert_eq!(fs::read(&run.disk).unwrap(), b"disposable image bytes"); + assert_eq!( + fs::metadata(&run.ssh.identity) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::read_to_string(run.ssh.identity.with_extension("pub")) + .unwrap() + .trim(), + key + ); + assert_eq!( + fs::read_to_string(store.run_dir(&identity).join("fcos-image.sha256")) + .unwrap() + .split_whitespace() + .next() + .unwrap() + .len(), + 64 + ); + let repeated_key = prepare_local_artifacts(&store, &identity, &image).unwrap(); + assert_eq!(repeated_key, key); + assert_eq!(fs::read(&run.disk).unwrap(), b"disposable image bytes"); +} + +#[test] +fn changed_image_replaces_only_the_preparing_run_disk() { + let (_temp, store, identity, image) = preparing_run(); + let run = store.load(&identity).unwrap(); + prepare_local_artifacts(&store, &identity, &image).unwrap(); + fs::write(&image, b"replacement image").unwrap(); + prepare_local_artifacts(&store, &identity, &image).unwrap(); + assert_eq!(fs::read(run.disk).unwrap(), b"replacement image"); +} + +#[test] +fn incomplete_key_pair_and_symlinked_images_are_refused() { + let (_temp, store, identity, image) = preparing_run(); + let run = store.load(&identity).unwrap(); + fs::create_dir(run.ssh.identity.parent().unwrap()).unwrap(); + fs::write(&run.ssh.identity, b"partial").unwrap(); + assert!(prepare_local_artifacts(&store, &identity, &image).is_err()); + + let link = image.with_file_name("linked.qcow2"); + symlink(&image, &link).unwrap(); + assert!(prepare_disk(&store, &run, &link).is_err()); +} + +#[test] +fn artifact_preparation_refuses_progressed_runs() { + let (_temp, store, identity, image) = preparing_run(); + let run = store.load(&identity).unwrap(); + let dir = store.run_dir(&identity); + fs::create_dir_all(run.ssh.identity.parent().unwrap()).unwrap(); + fs::write( + dir.join("skillet.sha256"), + format!("{} host\n", "a".repeat(64)), + ) + .unwrap(); + fs::write( + dir.join("skillet-generic.sha256"), + format!("{} generic\n", "b".repeat(64)), + ) + .unwrap(); + store.mark_started(&identity).unwrap(); + assert!(prepare_local_artifacts(&store, &identity, &image).is_err()); +} From cfe38729de0e54210ba5d25db5d93561186be902 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 18:27:09 +0200 Subject: [PATCH 136/252] refactor(vm): move Butane staging to Rust --- ublue/butane/ACCEPTANCE.md | 20 + ublue/butane/README.md | 5 +- ublue/butane/bin/coreos-install | 51 +-- ublue/design/smoke-vms.md | 29 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 26 +- ublue/skillet/Cargo.lock | 1 + ublue/skillet/crates/cli/src/main.rs | 35 ++ ublue/skillet/crates/cli/src/vm.rs | 19 +- ublue/skillet/crates/vm/Cargo.toml | 1 + ublue/skillet/crates/vm/src/lib.rs | 1 + ublue/skillet/crates/vm/src/provisioning.rs | 6 + ublue/skillet/crates/vm/src/staging.rs | 384 +++++++++++++++++++ ublue/skillet/crates/vm/src/staging_tests.rs | 199 ++++++++++ 13 files changed, 709 insertions(+), 68 deletions(-) create mode 100644 ublue/skillet/crates/vm/src/staging.rs create mode 100644 ublue/skillet/crates/vm/src/staging_tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 4180d985..57e7b8ae 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -754,3 +754,23 @@ host tool; it still uses normal command approvals. - No VM was created or changed for this slice. Template staging, Butane compilation and native define/start remain in the Bash installer; Flatpak creation and end-to-end VM acceptance remain pending. + +### Rust VM Butane source staging, 2026-10-03 + +- `skillet_vm::staging` now copies the selected host config, `.bu` includes, + and both captured binaries into the owned run directory. It specializes the + guest hostname, injects the validated disposable SSH key, removes image + binary entries and VM-only password-expiry resources, and adds the test-only + sudoers file. Artifact hashes and `run.conf` compatibility metadata are + written atomically. Repeated staging is idempotent and requires the current + persisted run to remain `Preparing` while holding its run lock. +- `coreos-install` delegates disk/key preparation and staging to hidden Rust + VM commands. Bash now invokes the standalone Butane compiler on the Rust- + staged tree. Native define/start and tool-version capture remain in Bash. +- Formatting, workspace Clippy with warnings denied, all 134 workspace tests, + workspace build, `bash -n`, ShellCheck, and `git diff --check` passed. The + CI-style beezelbot base container scenario passed, including repeat apply + without service starts. +- No VM was created or changed. The staged tree was exercised through fixture + tests, but a fresh VM create through the combined flow was not verified. + Native define/start remain in Bash; Flatpak creation is still unverified. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 0f6a99cf..a1c363de 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -105,7 +105,10 @@ being migrated; see [the roadmap](../plan/SKILLET-REFACTOR.md). The helper calls `coreos-install`, which builds the generic `skillet` CLI and the `skillet-clamps` host binary. The lower-level launcher accepts `--artifact PATH` to use a specific host binary; the generic CLI is built from the workspace. -Both artifact hashes are recorded in `runs/NAME/`. `test-vm ready` transfers +Rust prepares the run's disk and SSH key, stages and specializes the Butane +source tree, and records captured binary hashes in `runs/NAME/`. The standalone +Butane compiler builds Ignition; native domain definition and start are still +handled by `coreos-install` during the migration. `test-vm ready` transfers both binaries over SSH after first boot and installs them at `/var/usrlocal/bin/skillet` and `/var/usrlocal/bin/skillet-clamps`. `ready` uses those captured binaries on every run. The shared base unit reads the diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 3c105624..3a3553ed 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -7,10 +7,10 @@ repo_dir="$(realpath "$butane_dir/..")" usage() { cat <&2; exit 2; } vm_name="${vm_name:-$host}" [[ "$vm_name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] || { echo "Invalid VM name" >&2; exit 2; } +[[ "$vm_name" == "$host-test-"* ]] || { + echo "VM name must match the recorded host-test-instance identity" >&2; exit 2; +} if [[ ! "$ssh_port" =~ ^[0-9]+$ ]] || ((ssh_port < 1024 || ssh_port > 65535)); then echo "Invalid SSH port" >&2 exit 2 @@ -126,50 +129,18 @@ if [[ -z "$image" ]]; then fi [[ -f "$image" ]] || { echo "Missing FCOS image: $image" >&2; exit 1; } image="$(realpath "$image")" -mkdir -p "$(dirname "$output_dir")" -mkdir -p "$output_dir/source/includes" "$output_dir/source/files" "$output_dir/ignition" -cp "$input" "$output_dir/source/$host.bu" -cp "$butane_dir"/includes/*.bu "$output_dir/source/includes/" -cp "$artifact" "$output_dir/source/files/skillet-$host" -chmod 755 "$output_dir/source/files/skillet-$host" -cp "$generic_artifact" "$output_dir/source/files/skillet" -chmod 755 "$output_dir/source/files/skillet" -# Give each test VM its own hostname so Tailscale device cleanup can verify -# the exact node belonging to this run, even when several VMs share a host OS. -TEST_VM_HOSTNAME="$vm_name" yq -i \ - '(.storage.files[] | select(.path == "/etc/hostname").contents.inline) = strenv(TEST_VM_HOSTNAME)' \ - "$output_dir/source/$host.bu" +instance="${vm_name#"$host-test-"}" +skillet_cli test vm prepare-local "$host" "$instance" --image "$image" >/dev/null +skillet_cli test vm stage-local "$host" "$instance" \ + --image "$image" --host-binary "$artifact" --generic-binary "$generic_artifact" # Keep the multi-megabyte static binary out of QEMU fw_cfg. Ignition reads -# fw_cfg very slowly for large configs; test-vm-ready installs this artifact +# fw_cfg very slowly for large configs; test-vm-ready installs the artifacts # over SSH after the guest has completed its first Ignition boot. -skillet_include="$output_dir/source/includes/skillet.bu" -[[ -f "$skillet_include" ]] || { echo "Missing shared Skillet Butane include" >&2; exit 1; } -yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet"))' \ - "$skillet_include" -skillet_host_include="$output_dir/source/includes/skillet-$host.bu" -[[ -f "$skillet_host_include" ]] || { echo "Missing host Skillet Butane include: $skillet_host_include" >&2; exit 1; } -HOST="$host" yq -i 'del(.storage.files[] | select(.path == "/var/usrlocal/bin/skillet-" + strenv(HOST)))' \ - "$skillet_host_include" -public_key="$(skillet_cli test vm prepare-local "$host" "${vm_name#"$host-test-"}" --image "$image")" -# The existing giacomo account survives the FCOS-to-uCore rebase. Inject the -# disposable key into that same account and skip its interactive password -# expiry service only in this staged VM copy. -PUBLIC_KEY="$public_key" yq -i \ - '(.passwd.users[] | select(.name == "giacomo") | .ssh_authorized_keys) += [strenv(PUBLIC_KEY)] | - del(.systemd.units[] | select(.name == "force-pw-change.service")) | - del(.storage.files[] | select(.path == "/usr/local/bin/force_pw_change.sh")) | - .storage.files += [{"path":"/etc/sudoers.d/90-skillet-vm","mode":288,"contents":{"inline":"giacomo ALL=(ALL) NOPASSWD: ALL\n"}}]' \ - "$output_dir/source/includes/passwd.bu" ignition="$("$script_dir/butane" -s -o "$output_dir/ignition" "$output_dir/source/$host.bu" "$host.ign")" ignition_bytes="$(stat -c '%s' "$ignition")" if ((ignition_bytes > 1048576)); then echo "Warning: Ignition config is ${ignition_bytes} bytes; large QEMU fw_cfg configs can take a long time to read" >&2 fi -sha256sum "$output_dir/source/files/skillet-$host" > "$output_dir/skillet.sha256" -sha256sum "$output_dir/source/files/skillet" > "$output_dir/skillet-generic.sha256" -printf 'vm=%s\nhost=%s\nuri=%s\nssh_port=%s\nimage=%s\nartifact=%s\nruntime_dir=%s\nbackend=%s\nsource_commit=%s\n' \ - "$vm_name" "$host" "$libvirt_uri" "$ssh_port" "$image" "$artifact" "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" "${TEST_VM_BACKEND:-native}" \ - "$(git -C "$repo_dir" rev-parse HEAD)" > "$output_dir/run.conf" { if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then virt-install --version diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 3aeeb29e..19b4882c 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -42,16 +42,17 @@ boot expectations and captured binaries, and records Ready only after signed boot, unit, SELinux, resolver and user-environment checks. Bounded probes and private diagnostics allow failed checks to be retried. It first persists Started to invalidate stale readiness, then checks ownership before and after -each guest command/upload. Creation still uses the Bash installer, with Rust -persisting a private `Preparing` intent and UUID first. The generated UUID is -now part of native XML and Flatpak creation. Native definition and start are -separate manifest phases; failed starts retain the defined domain for -inspection and retry. Native domain XML now renders through the Rust XML -library, preserving the recorded UUID, disk ownership, fw_cfg and passt network -contract. Re-running create validates and starts that owned domain, or resumes -staging when no domain exists. Backend selection, emulator discovery, libvirt -define/start, Flatpak `virt-install`, and staging remain in Bash. Unit/adapter -checks pass; live migration acceptance is deferred. +each guest command/upload. Creation persists a private `Preparing` intent and +UUID first. Rust atomically prepares the disk and SSH key, stages host templates, +includes and binaries, applies VM-only Ignition changes, and records captured +hashes and compatibility metadata. The standalone Butane compiler still builds +Ignition. The generated UUID is part of native XML and Flatpak creation. Native +definition and start are separate manifest phases; failed starts retain the +defined domain for inspection and retry. Native domain XML now renders through +the Rust XML library, preserving disk ownership, fw_cfg and passt networking. +Backend selection, emulator discovery, libvirt define/start, and Flatpak +`virt-install` remain in Bash. Unit/adapter checks pass; live migration +acceptance is deferred. ## States and recovery @@ -64,10 +65,10 @@ required. See the [TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). 1. **Create:** build the generic CLI and host binary, allocate the run directory, and persist a `Preparing` manifest before local artifact changes. The Rust VM library atomically copies the selected image, records its digest, and creates - or validates the run's Ed25519 SSH key pair. The Bash installer currently - stages templates and compiles Ignition; it receives the public key from the - hidden `test vm prepare-local` command. Ignition contains the public SSH key - and base configuration. Large binaries travel over SSH because embedding them + or validates the run's Ed25519 SSH key pair. It stages and specializes the + host Butane source, shared includes, and binaries, and records their hashes. + The standalone Butane compiler builds Ignition from that staged tree. + Ignition contains the public SSH key and base configuration. Large binaries travel over SSH because embedding them in QEMU `fw_cfg` previously caused long boot delays. 2. **Ready:** wait for SSH, deliver both binaries, complete unsigned then signed rebase, and check the booted deployment, base apply, resolver, SELinux, and diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 158e40fd..7fe8b01b 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -45,13 +45,15 @@ entry point. Installer staging can resume from a matching Preparing manifest. Native XML generation now uses `quick-xml`. The VM library also prepares the local disk atomically, records its source digest, and creates or validates the per-run SSH key pair; `coreos-install` delegates that operation through a -hidden CLI command. Template staging and Butane compilation, backend -define/start, reboot and interactive SSH still use Bash. Credential delivery -has not yet adopted the shared transport. Workstream 2 must consolidate the +hidden CLI command. The same library stages and specializes the Butane source +tree and records compatibility metadata and binary hashes. The standalone +Butane compilation, backend define/start, reboot and interactive SSH still use +Bash. Credential delivery has not yet adopted the shared transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability -declaration. Next: move template staging and backend define/start into Rust, -then migrate reboot and interactive SSH. Flatpak `virt-install` creation -remains pending. Disposal and readiness live acceptance remain deferred. See +declaration. Next: move native define/start and tool-version capture into Rust, +then migrate reboot and interactive SSH. Flatpak `virt-install` +creation remains pending. Disposal and readiness live acceptance remain +deferred. See [creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), plus [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). @@ -86,12 +88,12 @@ creation inspectable; readiness and target selection remain duplicated. native libvirt, embed shell, or install tools during this migration. 4. Move list/status/create/reboot/update into Rust. Native domain XML now uses `quick-xml`; Flatpak still uses `virt-install`. Rust owns atomic disk - preparation and per-run key creation/validation; template staging, - Butane compilation and native define/start remain in `coreos-install`. - Stage copied templates with structured data operations and build artifacts - through a shared Cargo helper. Keep the standalone Butane compiler if it - still only compiles configuration. Validate required tools and SSH-port - conflicts before costly provisioning. See + preparation, per-run key creation/validation, and structured staging of + host templates, includes, binaries, compatibility metadata, and captured + hashes. `coreos-install` now invokes the standalone Butane compiler; native + define/start, tool-version capture, and domain UUID bookkeeping remain + there. Build artifacts through a shared Cargo helper. Validate required + tools and SSH-port conflicts before costly provisioning. See [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03). 5. Persist creation intent and a generated UUID before domain definition; advance through explicit recoverable phases as side effects complete. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index c6a38435..5c5ec16b 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2441,6 +2441,7 @@ dependencies = [ "quick-xml", "serde", "serde_json", + "serde_yml", "sha2 0.10.9", "tempfile", "thiserror 1.0.69", diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 6104720b..d79eae26 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -127,6 +127,9 @@ enum VmCommands { /// Prepare local image and SSH key artifacts for the source-tree helper #[command(hide = true)] PrepareLocal(VmPrepareLocalArgs), + /// Stage VM Butane inputs and captured binaries for the source-tree helper + #[command(hide = true)] + StageLocal(VmStageLocalArgs), /// Record successful domain definition for the source-tree helper #[command(hide = true)] Defined(VmTargetArgs), @@ -183,6 +186,18 @@ struct VmPrepareLocalArgs { image: PathBuf, } +#[derive(clap::Args, Debug)] +struct VmStageLocalArgs { + hostname: String, + instance: String, + #[arg(long)] + image: PathBuf, + #[arg(long)] + host_binary: PathBuf, + #[arg(long)] + generic_binary: PathBuf, +} + #[derive(clap::Args, Debug)] struct VmRenderDomainArgs { hostname: String, @@ -324,6 +339,7 @@ fn run_vm_command(command: VmCommands) -> Result<()> { VmCommands::Create(args) => run_vm_create(&args)?, VmCommands::Prepare(args) => run_vm_prepare(&args)?, VmCommands::PrepareLocal(args) => vm::prepare_local(&args)?, + VmCommands::StageLocal(args) => vm::stage_local(&args)?, VmCommands::Defined(args) => vm::record_defined(&args)?, VmCommands::Started(args) => vm::record_started(&args)?, VmCommands::RenderDomain(args) => vm::render_domain(&args)?, @@ -700,4 +716,23 @@ mod tests { ]); assert!(parsed.is_ok()); } + + #[test] + fn vm_local_staging_parses_explicit_artifacts() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "vm", + "stage-local", + "clamps", + "smoke", + "--image", + "/tmp/fcos.qcow2", + "--host-binary", + "/tmp/skillet-clamps", + "--generic-binary", + "/tmp/skillet", + ]); + assert!(parsed.is_ok()); + } } diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 5b0d9d03..4b33cdaf 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -1,7 +1,7 @@ //! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. use super::{ butane_root, secret_delivery, workspace_root, VmDestroyArgs, VmDirectoryArgs, VmListArgs, - VmPrepareLocalArgs, VmRenderDomainArgs, VmTargetArgs, + VmPrepareLocalArgs, VmRenderDomainArgs, VmStageLocalArgs, VmTargetArgs, }; use anyhow::{anyhow, Result}; use skillet_vm::{ @@ -87,6 +87,23 @@ pub(super) fn prepare_local(args: &VmPrepareLocalArgs) -> Result<()> { Ok(()) } +pub(super) fn stage_local(args: &VmStageLocalArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load(&identity)?; + skillet_vm::staging::stage_butane_source( + &store, + &run, + &butane.join(format!("{}.bu", identity.host())), + &butane.join("includes"), + &args.host_binary, + &args.generic_binary, + &args.image, + )?; + Ok(()) +} + pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { let butane = butane_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; diff --git a/ublue/skillet/crates/vm/Cargo.toml b/ublue/skillet/crates/vm/Cargo.toml index f58bd531..ecf966dd 100644 --- a/ublue/skillet/crates/vm/Cargo.toml +++ b/ublue/skillet/crates/vm/Cargo.toml @@ -10,6 +10,7 @@ workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true +serde_yml.workspace = true tempfile.workspace = true uuid.workspace = true users.workspace = true diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index 2322ef36..29a46d5d 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -10,6 +10,7 @@ pub mod manifest; mod process; pub mod provisioning; pub mod readiness; +pub mod staging; pub mod transport; pub use manifest::{ diff --git a/ublue/skillet/crates/vm/src/provisioning.rs b/ublue/skillet/crates/vm/src/provisioning.rs index 3a1a8a8d..18ed4dc2 100644 --- a/ublue/skillet/crates/vm/src/provisioning.rs +++ b/ublue/skillet/crates/vm/src/provisioning.rs @@ -143,6 +143,12 @@ fn prepare_key(store: &ManifestStore, run: &VmRun) -> Result { )); } } + validated_public_key(run) +} + +pub(crate) fn validated_public_key(run: &VmRun) -> Result { + let key = &run.ssh.identity; + let public = key.with_extension("pub"); reject_symlinks(key)?; reject_symlinks(&public)?; let key_metadata = fs::metadata(key)?; diff --git a/ublue/skillet/crates/vm/src/staging.rs b/ublue/skillet/crates/vm/src/staging.rs new file mode 100644 index 00000000..df46e712 --- /dev/null +++ b/ublue/skillet/crates/vm/src/staging.rs @@ -0,0 +1,384 @@ +//! Stage a host's Butane inputs and captured binaries for one disposable run. +use crate::{manifest::reject_symlinks, Error, ManifestStore, Phase, Result, VmRun}; +use serde_yml::{Mapping, Value}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File}, + io::{Read as _, Write as _}, + os::unix::fs::PermissionsExt as _, + path::Path, +}; + +/// Copy and specialize the source Butane tree under the recorded run directory. +/// Repeated staging writes only changed bytes and never mutates source files. +pub fn stage_butane_source( + store: &ManifestStore, + run: &VmRun, + config: &Path, + includes: &Path, + host_binary: &Path, + generic_binary: &Path, + image: &Path, +) -> Result<()> { + let _lock = store.lock(&run.identity)?; + let current = store.load(&run.identity)?; + store.validate(¤t, &run.identity)?; + if current.phase != Phase::Preparing { + return Err(Error::Invalid( + "Butane staging is allowed only for a preparing VM".into(), + )); + } + let run = ¤t; + for path in [config, includes, host_binary, generic_binary, image] { + reject_symlinks(path)?; + } + if !config.is_file() + || !includes.is_dir() + || !host_binary.is_file() + || !generic_binary.is_file() + || !image.is_file() + { + return Err(Error::Invalid( + "Butane staging inputs must be regular files and an include directory".into(), + )); + } + let run_dir = store.run_dir(&run.identity); + reject_symlinks(&run_dir)?; + let source_dir = run_dir.join("source"); + let include_dir = source_dir.join("includes"); + let files_dir = source_dir.join("files"); + create_private_dir(&source_dir)?; + create_private_dir(&include_dir)?; + create_private_dir(&files_dir)?; + stage_config(run, config, includes, &source_dir, &include_dir)?; + stage_artifacts( + run, + host_binary, + generic_binary, + image, + &files_dir, + &run_dir, + )?; + File::open(&run_dir)?.sync_all()?; + Ok(()) +} + +fn stage_config( + run: &VmRun, + config: &Path, + includes: &Path, + source_dir: &Path, + include_dir: &Path, +) -> Result<()> { + let config_target = source_dir.join(format!("{}.bu", run.identity.host())); + let mut host_config = parse_yaml(config)?; + set_inline( + find_file(&mut host_config, "/etc/hostname")?, + &run.guest_hostname, + )?; + write_yaml_if_changed(&config_target, &host_config)?; + + for entry in fs::read_dir(includes)? { + let entry = entry?; + let source = entry.path(); + if source + .extension() + .is_some_and(|extension| extension == "bu") + { + reject_symlinks(&source)?; + if !entry.file_type()?.is_file() { + return Err(Error::Invalid( + "Butane include must be a regular file".into(), + )); + } + let target = include_dir.join(entry.file_name()); + copy_if_changed(&source, &target, 0o644)?; + } + } + + let shared_include = include_dir.join("skillet.bu"); + let mut shared = parse_yaml(&shared_include)?; + remove_file(&mut shared, "/var/usrlocal/bin/skillet")?; + write_yaml_if_changed(&shared_include, &shared)?; + + let host_include = include_dir.join(format!("skillet-{}.bu", run.identity.host())); + let mut host_specific = parse_yaml(&host_include)?; + remove_file( + &mut host_specific, + &format!("/var/usrlocal/bin/skillet-{}", run.identity.host()), + )?; + write_yaml_if_changed(&host_include, &host_specific)?; + + let passwd_include = include_dir.join("passwd.bu"); + let mut passwd = parse_yaml(&passwd_include)?; + add_authorized_key( + &mut passwd, + &crate::provisioning::validated_public_key(run)?, + )?; + remove_file(&mut passwd, "/usr/local/bin/force_pw_change.sh")?; + remove_unit(&mut passwd, "force-pw-change.service")?; + add_vm_sudoers(&mut passwd)?; + write_yaml_if_changed(&passwd_include, &passwd)?; + Ok(()) +} + +fn stage_artifacts( + run: &VmRun, + host_binary: &Path, + generic_binary: &Path, + image: &Path, + files_dir: &Path, + run_dir: &Path, +) -> Result<()> { + let host_staged = files_dir.join(format!("skillet-{}", run.identity.host())); + let generic_staged = files_dir.join("skillet"); + copy_if_changed(host_binary, &host_staged, 0o755)?; + copy_if_changed(generic_binary, &generic_staged, 0o755)?; + let host_hash = hash_path(&host_staged)?; + let generic_hash = hash_path(&generic_staged)?; + let image_name = image + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Error::Invalid("VM image filename is invalid".into()))?; + let image_hash = hash_path(image)?; + let image_sidecar = run_dir.join("fcos-image.sha256"); + write_if_changed( + &image_sidecar, + format!("{image_hash} {image_name}\n").as_bytes(), + 0o600, + )?; + write_if_changed( + &run_dir.join("skillet.sha256"), + format!("{host_hash} {}\n", host_staged.display()).as_bytes(), + 0o600, + )?; + write_if_changed( + &run_dir.join("skillet-generic.sha256"), + format!("{generic_hash} {}\n", generic_staged.display()).as_bytes(), + 0o600, + )?; + write_if_changed( + &run_dir.join("run.conf"), + legacy_config(run, image, host_binary)?.as_bytes(), + 0o600, + )?; + Ok(()) +} + +fn legacy_config(run: &VmRun, image: &Path, host_binary: &Path) -> Result { + let fields = [ + ("vm", run.identity.domain_name()), + ("host", run.identity.host().to_owned()), + ("uri", run.connection.uri.clone()), + ("ssh_port", run.ssh.port.to_string()), + ("image", path_value(image)?), + ("artifact", path_value(host_binary)?), + ("runtime_dir", path_value(&run.connection.runtime_dir)?), + ( + "backend", + match run.connection.backend { + crate::Backend::Native => "native", + crate::Backend::Flatpak => "flatpak", + } + .to_owned(), + ), + ("source_commit", run.source_commit.clone()), + ]; + if fields.iter().any(|(_, value)| value.contains(['\n', '\r'])) { + return Err(Error::Invalid( + "newline in legacy VM configuration value".into(), + )); + } + let mut output = String::new(); + for (key, value) in fields { + output.push_str(key); + output.push('='); + output.push_str(&value); + output.push('\n'); + } + Ok(output) +} + +fn path_value(path: &Path) -> Result { + path.to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| Error::Invalid("VM artifact path is not UTF-8".into())) +} + +fn parse_yaml(path: &Path) -> Result { + reject_symlinks(path)?; + let contents = fs::read_to_string(path)?; + serde_yml::from_str(&contents).map_err(|error| Error::Preparation(error.to_string())) +} + +fn write_yaml_if_changed(path: &Path, value: &Value) -> Result<()> { + let contents = + serde_yml::to_string(value).map_err(|error| Error::Preparation(error.to_string()))?; + write_if_changed(path, contents.as_bytes(), 0o644) +} + +fn create_private_dir(path: &Path) -> Result<()> { + reject_symlinks(path)?; + if !path.exists() { + fs::create_dir(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + if !fs::metadata(path)?.is_dir() { + return Err(Error::Invalid("VM staging path is not a directory".into())); + } + Ok(()) +} + +fn copy_if_changed(source: &Path, target: &Path, mode: u32) -> Result<()> { + reject_symlinks(source)?; + reject_symlinks(target)?; + let bytes = fs::read(source)?; + write_if_changed(target, &bytes, mode) +} + +fn write_if_changed(path: &Path, contents: &[u8], mode: u32) -> Result<()> { + reject_symlinks(path)?; + if path.is_file() + && fs::read(path)? == contents + && fs::metadata(path)?.permissions().mode() & 0o777 == mode + { + return Ok(()); + } + let parent = path + .parent() + .ok_or_else(|| Error::Invalid("staged file has no parent directory".into()))?; + let mut staged = tempfile::NamedTempFile::new_in(parent)?; + staged + .as_file() + .set_permissions(fs::Permissions::from_mode(mode))?; + staged.write_all(contents)?; + staged.as_file().sync_all()?; + staged + .persist(path) + .map_err(|error| Error::Io(error.error))?; + Ok(()) +} + +fn hash_path(path: &Path) -> Result { + reject_symlinks(path)?; + let mut file = fs::File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 16 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + Ok(hex::encode(digest.finalize())) +} + +fn find_file<'a>(root: &'a mut Value, path: &str) -> Result<&'a mut Mapping> { + let entries = sequence_at_mut(root, &["storage", "files"])?; + entries + .iter_mut() + .filter_map(Value::as_mapping_mut) + .find(|entry| string_at(entry, "path").as_deref() == Some(path)) + .ok_or_else(|| Error::Invalid(format!("required Butane file entry is absent: {path}"))) +} + +fn set_inline(file: &mut Mapping, inline: &str) -> Result<()> { + let contents = mapping_at_mut(file, "contents")?; + contents.insert(Value::String("inline".into()), Value::String(inline.into())); + Ok(()) +} + +fn remove_file(root: &mut Value, path: &str) -> Result<()> { + let files = sequence_at_mut(root, &["storage", "files"])?; + files.retain(|value| { + value + .as_mapping() + .and_then(|entry| string_at(entry, "path")) + .is_none_or(|candidate| candidate != path) + }); + Ok(()) +} + +fn remove_unit(root: &mut Value, name: &str) -> Result<()> { + let units = sequence_at_mut(root, &["systemd", "units"])?; + units.retain(|value| { + value + .as_mapping() + .and_then(|entry| string_at(entry, "name")) + .is_none_or(|candidate| candidate != name) + }); + Ok(()) +} + +fn add_authorized_key(root: &mut Value, key: &str) -> Result<()> { + let users = sequence_at_mut(root, &["passwd", "users"])?; + let user = users + .iter_mut() + .filter_map(Value::as_mapping_mut) + .find(|user| string_at(user, "name").as_deref() == Some("giacomo")) + .ok_or_else(|| Error::Invalid("Butane config lacks the giacomo account".into()))?; + let keys = user + .entry(Value::String("ssh_authorized_keys".into())) + .or_insert_with(|| Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or_else(|| Error::Invalid("SSH authorized key list is not a sequence".into()))?; + let key = Value::String(key.trim().to_owned()); + if key.as_str().is_none_or(str::is_empty) { + return Err(Error::Invalid("generated SSH public key is empty".into())); + } + if !keys.contains(&key) { + keys.push(key); + } + Ok(()) +} + +fn add_vm_sudoers(root: &mut Value) -> Result<()> { + let files = sequence_at_mut(root, &["storage", "files"])?; + if files.iter().any(|value| { + value + .as_mapping() + .and_then(|entry| string_at(entry, "path")) + .as_deref() + == Some("/etc/sudoers.d/90-skillet-vm") + }) { + return Ok(()); + } + files.push(serde_yml::from_str( + "path: /etc/sudoers.d/90-skillet-vm\nmode: 288\ncontents:\n inline: |\n giacomo ALL=(ALL) NOPASSWD: ALL\n", + ) + .map_err(|error| Error::Preparation(error.to_string()))?); + Ok(()) +} + +fn sequence_at_mut<'a>(root: &'a mut Value, path: &[&str]) -> Result<&'a mut Vec> { + let mut current = root; + for segment in path { + current = current + .as_mapping_mut() + .and_then(|mapping| mapping.get_mut(Value::String((*segment).into()))) + .ok_or_else(|| { + Error::Invalid(format!("required Butane mapping is absent: {segment}")) + })?; + } + current + .as_sequence_mut() + .ok_or_else(|| Error::Invalid("required Butane field is not a sequence".into())) +} + +fn mapping_at_mut<'a>(root: &'a mut Mapping, key: &str) -> Result<&'a mut Mapping> { + root.get_mut(Value::String(key.into())) + .and_then(Value::as_mapping_mut) + .ok_or_else(|| Error::Invalid(format!("required Butane mapping is absent: {key}"))) +} + +fn string_at(mapping: &Mapping, key: &str) -> Option { + mapping + .get(Value::String(key.into())) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +#[path = "staging_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/staging_tests.rs b/ublue/skillet/crates/vm/src/staging_tests.rs new file mode 100644 index 00000000..a8552b72 --- /dev/null +++ b/ublue/skillet/crates/vm/src/staging_tests.rs @@ -0,0 +1,199 @@ +use super::*; +use crate::{Backend, Connection, RunIdentity}; +use std::{fs, os::unix::fs::symlink, path::PathBuf, process::Command}; + +fn fixture() -> ( + tempfile::TempDir, + ManifestStore, + VmRun, + PathBuf, + PathBuf, + PathBuf, + PathBuf, + PathBuf, +) { + let temp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(temp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("clamps", "stage-test").unwrap(); + let run = store + .prepare_intent( + &identity, + Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir: PathBuf::from(format!("/run/user/{}", crate::current_uid())), + }, + 2204, + "source-revision", + ) + .unwrap(); + let config = temp.path().join("clamps.bu"); + let includes = temp.path().join("includes"); + fs::create_dir(&includes).unwrap(); + fs::write( + &config, + "variant: fcos\nversion: 1.6.0\nstorage:\n files:\n - path: /etc/hostname\n mode: 420\n contents:\n inline: clamps\n", + ) + .unwrap(); + fs::write( + includes.join("skillet.bu"), + "variant: fcos\nversion: 1.6.0\nstorage:\n files:\n - path: /var/usrlocal/bin/skillet\n contents:\n local: files/skillet\n - path: /etc/other\n contents:\n inline: preserved\n", + ) + .unwrap(); + fs::write( + includes.join("skillet-clamps.bu"), + "variant: fcos\nversion: 1.6.0\nstorage:\n files:\n - path: /var/usrlocal/bin/skillet-clamps\n contents:\n local: files/skillet-clamps\n", + ) + .unwrap(); + fs::write( + includes.join("passwd.bu"), + "variant: fcos\nversion: 1.6.0\npasswd:\n users:\n - name: giacomo\n ssh_authorized_keys:\n - ssh-ed25519 existing\nstorage:\n files:\n - path: /usr/local/bin/force_pw_change.sh\n contents:\n inline: script\nsystemd:\n units:\n - name: force-pw-change.service\n enabled: true\n", + ) + .unwrap(); + fs::write(includes.join("extra.bu"), "variant: fcos\nversion: 1.6.0\n").unwrap(); + let host_binary = temp.path().join("skillet-clamps"); + let generic_binary = temp.path().join("skillet"); + let image = temp.path().join("image.qcow2"); + fs::write(&host_binary, b"host executable").unwrap(); + fs::write(&generic_binary, b"generic executable").unwrap(); + fs::write(&image, b"image bytes").unwrap(); + fs::create_dir_all(run.ssh.identity.parent().unwrap()).unwrap(); + fs::set_permissions( + run.ssh.identity.parent().unwrap(), + fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let keygen = Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", ""]) + .arg("-f") + .arg(&run.ssh.identity) + .status() + .unwrap(); + assert!(keygen.success()); + ( + temp, + store, + run, + config, + includes, + host_binary, + generic_binary, + image, + ) +} + +#[test] +fn staging_specializes_only_the_owned_copy_and_hashes_artifacts() { + let (_temp, store, run, config, includes, host_binary, generic_binary, image) = fixture(); + stage_butane_source( + &store, + &run, + &config, + &includes, + &host_binary, + &generic_binary, + &image, + ) + .unwrap(); + let staged = store.run_dir(&run.identity).join("source"); + let host: Value = + serde_yml::from_str(&fs::read_to_string(staged.join("clamps.bu")).unwrap()).unwrap(); + assert_eq!( + host["storage"]["files"][0]["contents"]["inline"].as_str(), + Some(run.guest_hostname.as_str()) + ); + let shared: Value = + serde_yml::from_str(&fs::read_to_string(staged.join("includes/skillet.bu")).unwrap()) + .unwrap(); + assert_eq!(shared["storage"]["files"].as_sequence().unwrap().len(), 1); + let host_include: Value = serde_yml::from_str( + &fs::read_to_string(staged.join("includes/skillet-clamps.bu")).unwrap(), + ) + .unwrap(); + assert_eq!( + host_include["storage"]["files"] + .as_sequence() + .unwrap() + .len(), + 0 + ); + let passwd: Value = + serde_yml::from_str(&fs::read_to_string(staged.join("includes/passwd.bu")).unwrap()) + .unwrap(); + let user = &passwd["passwd"]["users"][0]; + assert_eq!(user["ssh_authorized_keys"].as_sequence().unwrap().len(), 2); + assert_eq!(passwd["storage"]["files"].as_sequence().unwrap().len(), 1); + assert_eq!(passwd["systemd"]["units"].as_sequence().unwrap().len(), 0); + assert_eq!( + passwd["storage"]["files"][0]["path"].as_str(), + Some("/etc/sudoers.d/90-skillet-vm") + ); + assert_eq!( + fs::read(staged.join("files/skillet-clamps")).unwrap(), + b"host executable" + ); + assert_eq!( + fs::metadata(staged.join("files/skillet-clamps")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + assert!(store.run_dir(&run.identity).join("run.conf").is_file()); + assert!(store + .run_dir(&run.identity) + .join("skillet-generic.sha256") + .is_file()); + assert!(fs::read_to_string(config) + .unwrap() + .contains("inline: clamps")); +} + +#[test] +fn staging_is_idempotent_and_refuses_progressed_runs_or_linked_sources() { + let (_temp, store, run, config, includes, host_binary, generic_binary, image) = fixture(); + let arguments = || { + ( + &store, + &run, + &config, + &includes, + &host_binary, + &generic_binary, + &image, + ) + }; + let args = arguments(); + stage_butane_source(args.0, args.1, args.2, args.3, args.4, args.5, args.6).unwrap(); + let source_dir = store.run_dir(&run.identity).join("source"); + let passwd_path = source_dir.join("includes/passwd.bu"); + let staged_once = fs::read(&passwd_path).unwrap(); + let args = arguments(); + stage_butane_source(args.0, args.1, args.2, args.3, args.4, args.5, args.6).unwrap(); + assert_eq!(fs::read(&passwd_path).unwrap(), staged_once); + + let symlink_config = config.with_file_name("linked-clamps.bu"); + symlink(&config, &symlink_config).unwrap(); + assert!(stage_butane_source( + &store, + &run, + &symlink_config, + &includes, + &host_binary, + &generic_binary, + &image + ) + .is_err()); + store.mark_started(&run.identity).unwrap(); + assert!(stage_butane_source( + &store, + &run, + &config, + &includes, + &host_binary, + &generic_binary, + &image + ) + .is_err()); +} From ea61803735aed98bb9cf42d83bda63e943f3a4fc Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 20:10:15 +0200 Subject: [PATCH 137/252] refactor(vm): move native define and start into Rust --- ublue/butane/ACCEPTANCE.md | 17 ++ ublue/butane/bin/coreos-install | 51 ++---- ublue/butane/bin/test-vm | 33 ++-- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 21 +-- ublue/skillet/crates/cli/src/main.rs | 25 ++- ublue/skillet/crates/cli/src/vm.rs | 51 +++--- ublue/skillet/crates/vm/src/backend.rs | 53 ++++++ ublue/skillet/crates/vm/src/backend/tests.rs | 22 +++ ublue/skillet/crates/vm/src/creation.rs | 129 ++++++++++++++ ublue/skillet/crates/vm/src/creation_tests.rs | 165 ++++++++++++++++++ ublue/skillet/crates/vm/src/lib.rs | 3 + ublue/skillet/crates/vm/src/process.rs | 16 ++ 12 files changed, 477 insertions(+), 109 deletions(-) create mode 100644 ublue/skillet/crates/vm/src/creation.rs create mode 100644 ublue/skillet/crates/vm/src/creation_tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 57e7b8ae..00691c77 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -774,3 +774,20 @@ host tool; it still uses normal command approvals. - No VM was created or changed. The staged tree was exercised through fixture tests, but a fresh VM create through the combined flow was not verified. Native define/start remain in Bash; Flatpak creation is still unverified. + +### Rust native VM define and start, 2026-10-03 + +- Native VM creation now resolves the x86_64 HVM emulator from the selected + libvirt connection, captures tool versions, renders the owned domain XML, + defines and starts the domain, and records its UUID in Rust. The lifecycle + validates UUID and disk ownership after each backend observation. A retry + after define or start can continue from the manifest and current domain + state. `test-vm` delegates native resume to the same Rust command; Flatpak + `virt-install` remains on its compatibility path. +- Fake-backend coverage verifies successful creation, recovery from an + existing defined domain, idempotent running-domain retries, refusal of a + foreign UUID, start-failure retry, and x86_64 emulator selection. The VM crate's 62 tests and the + full workspace test suite and pedantic Clippy with warnings denied passed. + Formatting, ShellCheck, Bash syntax, and `git diff --check` passed. +- No live VM was created or changed. The combined native flow and Flatpak + creation remain to be exercised on their respective backends. diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 3a3553ed..576d04a3 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -68,6 +68,7 @@ vm_name="${vm_name:-$host}" [[ "$vm_name" == "$host-test-"* ]] || { echo "VM name must match the recorded host-test-instance identity" >&2; exit 2; } +instance="${vm_name#"$host-test-"}" if [[ ! "$ssh_port" =~ ^[0-9]+$ ]] || ((ssh_port < 1024 || ssh_port > 65535)); then echo "Invalid SSH port" >&2 exit 2 @@ -141,33 +142,26 @@ ignition_bytes="$(stat -c '%s' "$ignition")" if ((ignition_bytes > 1048576)); then echo "Warning: Ignition config is ${ignition_bytes} bytes; large QEMU fw_cfg configs can take a long time to read" >&2 fi -{ - if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then +if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then + { virt-install --version - else - echo 'Native VM launcher: virsh XML' - fi - vm_command virsh --version - podman --version - yq --version -} > "$output_dir/tool-versions.txt" + vm_command virsh --version + podman --version + yq --version + } > "$output_dir/tool-versions.txt" +else + skillet_cli test vm create-native "$host" "$instance" +fi if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then network=none qemu_commandline="-fw_cfg name=opt/com.coreos/config,file=$ignition -netdev user,id=skilletnet,hostfwd=tcp:127.0.0.1:$ssh_port-:22 -device virtio-net-pci,netdev=skilletnet,bus=pcie.0,addr=0x1d" -else - network="passt,model=virtio,portForward0.proto=tcp,portForward0.address=127.0.0.1,portForward0.range0.start=$ssh_port,portForward0.range0.to=22" - qemu_commandline="-fw_cfg name=opt/com.coreos/config,file=$ignition" -fi -if [[ -n "$vm_uuid" ]]; then + uuid_args=() + [[ -z "$vm_uuid" ]] || uuid_args=(--uuid "$vm_uuid") printf '%s\n' "$vm_uuid" > "$output_dir/domain.uuid.tmp" chmod 600 "$output_dir/domain.uuid.tmp" mv -f "$output_dir/domain.uuid.tmp" "$output_dir/domain.uuid" -fi -echo "Creating $vm_name on $libvirt_uri, disk $output_dir/$vm_name.qcow2, SSH 127.0.0.1:$ssh_port" >&2 -if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then - uuid_args=() - [[ -z "$vm_uuid" ]] || uuid_args=(--uuid "$vm_uuid") + echo "Creating $vm_name on $libvirt_uri, disk $output_dir/$vm_name.qcow2, SSH 127.0.0.1:$ssh_port" >&2 vm_command virt-install --connect "$libvirt_uri" --name "$vm_name" "${uuid_args[@]}" \ --osinfo linux2024 --memory 8192 --vcpus 2 --import \ --disk "path=$output_dir/$vm_name.qcow2,format=qcow2,bus=virtio" \ @@ -177,23 +171,4 @@ if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then --qemu-commandline="$qemu_commandline" else [[ "${TEST_VM_BACKEND:-native}" == native ]] || { echo 'Flatpak backend needs virt-install' >&2; exit 1; } - emulator="$(vm_command virsh -c "$libvirt_uri" capabilities | - xmllint --xpath "string((//guest[os_type='hvm']/arch[@name='x86_64']/emulator)[1])" -)" - [[ -n "$emulator" && -x "$emulator" ]] || { echo 'No usable x86_64 libvirt emulator' >&2; exit 1; } - if [[ -n "${SKILLET_BINARY:-}" ]]; then - domain_xml="$("$SKILLET_BINARY" test vm render-domain "$host" "${vm_name#"$host-test-"}" --emulator "$emulator")" - else - domain_xml="$(cd "$repo_dir/skillet" && cargo run --quiet --release -p skillet -- \ - test vm render-domain "$host" "${vm_name#"$host-test-"}" --emulator "$emulator")" - fi - [[ "$domain_xml" == "$output_dir/domain.xml" ]] || { echo 'Rust returned an unexpected domain XML path' >&2; exit 1; } - virt-xml-validate "$domain_xml" - vm_command virsh -c "$libvirt_uri" define "$domain_xml" - if [[ -n "${SKILLET_BINARY:-}" ]]; then - "$SKILLET_BINARY" test vm defined "$host" "${vm_name#"$host-test-"}" - else - (cd "$repo_dir/skillet" && cargo run --quiet --release -p skillet -- \ - test vm defined "$host" "${vm_name#"$host-test-"}") - fi - vm_command virsh -c "$libvirt_uri" start "$vm_name" fi diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index e7f7c321..aa7b99d4 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -190,9 +190,6 @@ case "$operation" in select_backend if [[ "$backend" == native ]]; then command -v passt >/dev/null || { echo 'Missing native VM tool: passt' >&2; exit 1; } - if ! command -v xmllint >/dev/null || ! command -v virt-xml-validate >/dev/null; then - echo 'Native VM creation needs xmllint and virt-xml-validate' >&2; exit 1 - fi fi if [[ ! -e /dev/kvm ]]; then echo '/dev/kvm is unavailable; enable hardware virtualization and KVM before creating a VM.' >&2 @@ -204,25 +201,31 @@ case "$operation" in echo "Domain already exists without a recorded creation intent; refusing adoption" >&2 exit 1 fi - prepare_args=(test vm prepare "$host" "$instance" --backend "$backend" --runtime-dir "$runtime_dir" --uri "$uri" --port "$port") - uuid="$(skillet_cli "${prepare_args[@]}")" if vm_virsh dominfo "$name" &>/dev/null; then - skillet_cli test vm status "$host" "$instance" - state="$(vm_virsh domstate "$name" | tr -d '\r')" - if [[ "$state" == "shut off" ]]; then - vm_virsh start "$name" - elif [[ "$state" != "running" ]]; then - echo "Cannot resume VM in libvirt state: $state" >&2 - exit 1 + if [[ "$backend" == native ]]; then + skillet_cli test vm create-native "$host" "$instance" + else + skillet_cli test vm status "$host" "$instance" + state="$(vm_virsh domstate "$name" | tr -d '\r')" + if [[ "$state" == "shut off" ]]; then + vm_virsh start "$name" + elif [[ "$state" != "running" ]]; then + echo "Cannot resume VM in libvirt state: $state" >&2 + exit 1 + fi + skillet_cli test vm started "$host" "$instance" fi - skillet_cli test vm started "$host" "$instance" echo "Resumed recorded VM $name" exit 0 fi + prepare_args=(test vm prepare "$host" "$instance" --backend "$backend" --runtime-dir "$runtime_dir" --uri "$uri" --port "$port") + uuid="$(skillet_cli "${prepare_args[@]}")" TEST_VM_BACKEND="$backend" TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" "$script_dir/coreos-install" --name "$name" --output-dir "$run_dir" \ --ssh-port "$port" --libvirt-uri "$uri" --uuid "$uuid" --resume-prepared "$script_dir/../$host.bu" - vm_virsh domuuid "$name" > "$run_dir/domain.uuid" - skillet_cli test vm started "$host" "$instance" + if [[ "$backend" == flatpak ]]; then + vm_virsh domuuid "$name" > "$run_dir/domain.uuid" + skillet_cli test vm started "$host" "$instance" + fi echo "Run: $run_dir" ;; ssh) (($# == 0)) || usage diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 7fe8b01b..1f49f03d 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -47,12 +47,14 @@ local disk atomically, records its source digest, and creates or validates the per-run SSH key pair; `coreos-install` delegates that operation through a hidden CLI command. The same library stages and specializes the Butane source tree and records compatibility metadata and binary hashes. The standalone -Butane compilation, backend define/start, reboot and interactive SSH still use -Bash. Credential delivery has not yet adopted the shared transport. Workstream 2 must consolidate the +Butane compilation, reboot and interactive SSH still use Bash. Native +define/start, emulator discovery, UUID bookkeeping and tool-version capture +now run through the Rust lifecycle and selected backend. Flatpak +`virt-install` creation and version capture remain in Bash. Credential delivery +has not yet adopted the shared transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability -declaration. Next: move native define/start and tool-version capture into Rust, -then migrate reboot and interactive SSH. Flatpak `virt-install` -creation remains pending. Disposal and readiness live acceptance remain +declaration. Next: migrate reboot and interactive SSH, then move Flatpak +`virt-install` creation behind a Rust adapter. Disposal and readiness live acceptance remain deferred. See [creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), @@ -88,11 +90,10 @@ creation inspectable; readiness and target selection remain duplicated. native libvirt, embed shell, or install tools during this migration. 4. Move list/status/create/reboot/update into Rust. Native domain XML now uses `quick-xml`; Flatpak still uses `virt-install`. Rust owns atomic disk - preparation, per-run key creation/validation, and structured staging of - host templates, includes, binaries, compatibility metadata, and captured - hashes. `coreos-install` now invokes the standalone Butane compiler; native - define/start, tool-version capture, and domain UUID bookkeeping remain - there. Build artifacts through a shared Cargo helper. Validate required + preparation, per-run key creation/validation, structured Butane staging, + and native define/start, tool-version capture, and domain UUID bookkeeping. + `coreos-install` invokes the standalone Butane compiler. Build artifacts + through a shared Cargo helper. Validate required tools and SSH-port conflicts before costly provisioning. See [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03). 5. Persist creation intent and a generated UUID before domain definition; diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index d79eae26..36715bbb 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -130,15 +130,12 @@ enum VmCommands { /// Stage VM Butane inputs and captured binaries for the source-tree helper #[command(hide = true)] StageLocal(VmStageLocalArgs), - /// Record successful domain definition for the source-tree helper + /// Define and start an owned native VM from staged artifacts #[command(hide = true)] - Defined(VmTargetArgs), + CreateNative(VmTargetArgs), /// Record successful VM start for the source-tree helper #[command(hide = true)] Started(VmTargetArgs), - /// Render a native domain definition from the recorded run - #[command(hide = true)] - RenderDomain(VmRenderDomainArgs), /// Destroy a disposable host VM and remove its temporary key and artifacts Destroy(VmDestroyArgs), /// List available host templates and their recorded disposable VMs @@ -198,14 +195,6 @@ struct VmStageLocalArgs { generic_binary: PathBuf, } -#[derive(clap::Args, Debug)] -struct VmRenderDomainArgs { - hostname: String, - instance: String, - #[arg(long)] - emulator: PathBuf, -} - #[derive(Clone, Copy, Debug, clap::ValueEnum)] enum VmBackendName { Native, @@ -340,9 +329,8 @@ fn run_vm_command(command: VmCommands) -> Result<()> { VmCommands::Prepare(args) => run_vm_prepare(&args)?, VmCommands::PrepareLocal(args) => vm::prepare_local(&args)?, VmCommands::StageLocal(args) => vm::stage_local(&args)?, - VmCommands::Defined(args) => vm::record_defined(&args)?, + VmCommands::CreateNative(args) => vm::create_native(&args)?, VmCommands::Started(args) => vm::record_started(&args)?, - VmCommands::RenderDomain(args) => vm::render_domain(&args)?, VmCommands::Destroy(args) => run_vm_destroy(&args)?, VmCommands::List(args) => run_vm_list(&args)?, VmCommands::Status(args) => vm::status(&args)?, @@ -735,4 +723,11 @@ mod tests { ]); assert!(parsed.is_ok()); } + + #[test] + fn native_vm_lifecycle_command_parses_identity_fields() { + let parsed = + Args::try_parse_from(["skillet", "test", "vm", "create-native", "clamps", "smoke"]); + assert!(parsed.is_ok()); + } } diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 4b33cdaf..de419373 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -1,7 +1,7 @@ //! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. use super::{ butane_root, secret_delivery, workspace_root, VmDestroyArgs, VmDirectoryArgs, VmListArgs, - VmPrepareLocalArgs, VmRenderDomainArgs, VmStageLocalArgs, VmTargetArgs, + VmPrepareLocalArgs, VmStageLocalArgs, VmTargetArgs, }; use anyhow::{anyhow, Result}; use skillet_vm::{ @@ -47,36 +47,6 @@ pub(super) fn record_started(args: &VmTargetArgs) -> Result<()> { Ok(()) } -pub(super) fn record_defined(args: &VmTargetArgs) -> Result<()> { - let butane = butane_root()?; - let identity = RunIdentity::new(&args.hostname, &args.instance)?; - let store = ManifestStore::new(&butane.join("runs"), current_uid())?; - let _lock = store.lock(&identity)?; - let run = store.load(&identity)?; - let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; - backend - .inspect(&run)? - .ok_or_else(|| anyhow!("VM definition returned without an owned domain"))? - .validate_owned(&run)?; - store.mark_defined(&identity)?; - Ok(()) -} - -pub(super) fn render_domain(args: &VmRenderDomainArgs) -> Result<()> { - let butane = butane_root()?; - let identity = RunIdentity::new(&args.hostname, &args.instance)?; - let store = ManifestStore::new(&butane.join("runs"), current_uid())?; - let run = store.load(&identity)?; - if run.phase != skillet_vm::Phase::Preparing { - return Err(anyhow!( - "domain XML can only be rendered for a preparing VM" - )); - } - let path = skillet_vm::domain_xml::write_native_domain_xml(&store, &run, &args.emulator)?; - println!("{}", path.display()); - Ok(()) -} - pub(super) fn prepare_local(args: &VmPrepareLocalArgs) -> Result<()> { let butane = butane_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; @@ -104,6 +74,25 @@ pub(super) fn stage_local(args: &VmStageLocalArgs) -> Result<()> { Ok(()) } +pub(super) fn create_native(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load(&identity)?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let emulator = backend.x86_64_emulator(&run)?; + let versions = format!( + "Native VM launcher: virsh XML\n{}{}{}", + backend.version(&run)?, + skillet_vm::capture_version("podman")?, + skillet_vm::capture_version("yq")?, + ); + let run = + skillet_vm::creation::create_native(&store, &identity, &backend, &emulator, &versions)?; + println!("Started {} ({})", identity.domain_name(), run.uuid); + Ok(()) +} + pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { let butane = butane_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; diff --git a/ublue/skillet/crates/vm/src/backend.rs b/ublue/skillet/crates/vm/src/backend.rs index 1f8bd413..58a8a762 100644 --- a/ublue/skillet/crates/vm/src/backend.rs +++ b/ublue/skillet/crates/vm/src/backend.rs @@ -5,6 +5,7 @@ use std::{ collections::BTreeMap, ffi::OsString, fs, + os::unix::fs::PermissionsExt as _, path::{Path, PathBuf}, process::Command, time::Duration, @@ -167,6 +168,38 @@ impl VirshBackend { } Ok(()) } + + /// Resolve the emulator advertised for the architecture used by native + /// test guests. Keep capability parsing beside the selected libvirt + /// adapter so alternate runtimes use their own connection and environment. + pub fn x86_64_emulator(&self, run: &VmRun) -> Result { + self.validate_connection(run)?; + let capabilities = self.invoke(&["capabilities"])?; + let capabilities: CapabilitiesXml = quick_xml::de::from_str(&capabilities)?; + let emulator = capabilities + .guests + .into_iter() + .filter(|guest| guest.os_type == "hvm") + .flat_map(|guest| guest.arches) + .find(|arch| arch.name == "x86_64") + .and_then(|arch| arch.emulator) + .ok_or_else(|| Error::Invalid("libvirt has no x86_64 HVM emulator".into()))?; + let metadata = fs::metadata(&emulator)?; + if !emulator.is_absolute() + || !metadata.is_file() + || metadata.permissions().mode() & 0o111 == 0 + { + return Err(Error::Invalid( + "libvirt x86_64 emulator is not an executable file".into(), + )); + } + Ok(emulator) + } + + pub fn version(&self, run: &VmRun) -> Result { + self.validate_connection(run)?; + self.invoke(&["--version"]) + } } impl VmBackend for VirshBackend { @@ -220,6 +253,26 @@ struct DomainXml { uuid: Uuid, devices: DevicesXml, } + +#[derive(Deserialize)] +struct CapabilitiesXml { + #[serde(rename = "guest", default)] + guests: Vec, +} + +#[derive(Deserialize)] +struct GuestXml { + os_type: String, + #[serde(rename = "arch", default)] + arches: Vec, +} + +#[derive(Deserialize)] +struct ArchitectureXml { + #[serde(rename = "@name")] + name: String, + emulator: Option, +} #[derive(Deserialize)] struct DevicesXml { #[serde(rename = "disk", default)] diff --git a/ublue/skillet/crates/vm/src/backend/tests.rs b/ublue/skillet/crates/vm/src/backend/tests.rs index 968bf206..b7077076 100644 --- a/ublue/skillet/crates/vm/src/backend/tests.rs +++ b/ublue/skillet/crates/vm/src/backend/tests.rs @@ -92,6 +92,28 @@ fn missing_domain_requires_successful_lists_not_an_error_message() { assert!(matches!(backend.inspect(&run), Err(Error::Command { .. }))); } +#[test] +fn capabilities_select_executable_x86_64_hvm_emulator() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let emulator = dir.path().join("qemu-system-x86_64"); + std::fs::write(&emulator, b"test emulator").unwrap(); + std::fs::set_permissions(&emulator, std::fs::Permissions::from_mode(0o700)).unwrap(); + let capabilities = format!( + "hvm/not/selected{}", + emulator.display() + ); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![output(&capabilities)]), + ) + .unwrap(); + assert_eq!(backend.x86_64_emulator(&run).unwrap(), emulator); +} + #[test] fn reused_name_and_renamed_uuid_are_not_owned() { let (_tmp, store, identity) = legacy_run(); diff --git a/ublue/skillet/crates/vm/src/creation.rs b/ublue/skillet/crates/vm/src/creation.rs new file mode 100644 index 00000000..886ce37e --- /dev/null +++ b/ublue/skillet/crates/vm/src/creation.rs @@ -0,0 +1,129 @@ +//! Native VM definition and start orchestration with ownership recovery. +use crate::{ + backend::{DomainSnapshot, VmBackend}, + domain_xml, Backend, Error, ManifestStore, Phase, Result, RunIdentity, VmRun, +}; +use std::{ + fs::{self, File}, + io::Write as _, + os::unix::fs::PermissionsExt as _, + path::Path, +}; + +/// Define and start a native VM, persisting ownership after each successful +/// external transition so a retry can inspect and continue safely. +pub fn create_native( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + emulator: &Path, + tool_versions: &str, +) -> Result { + let _lock = store.lock(identity)?; + let mut run = store.load(identity)?; + store.validate(&run, identity)?; + if run.connection.backend != Backend::Native + || !matches!( + run.phase, + Phase::Preparing | Phase::Defined | Phase::Started | Phase::Ready + ) + { + return Err(Error::Invalid( + "native VM creation requires an active native run".into(), + )); + } + save_tool_versions(store, &run, tool_versions)?; + + let mut domain = backend.inspect(&run)?; + if let Some(existing) = &domain { + existing.validate_owned(&run)?; + if run.phase == Phase::Preparing { + run = store.mark_defined(identity)?; + } + } else { + if run.phase != Phase::Preparing { + return Err(Error::Invalid( + "recorded native VM domain is absent; refusing to recreate it".into(), + )); + } + let xml = domain_xml::write_native_domain_xml(store, &run, emulator)?; + backend.define(&run, &xml)?; + domain = backend.inspect(&run)?; + let defined = owned_domain(domain.as_ref(), &run)?; + if run.phase == Phase::Preparing { + run = store.mark_defined(identity)?; + } + domain = Some(defined.clone()); + } + + let domain = owned_domain(domain.as_ref(), &run)?; + if domain.state == "shut off" { + backend.start(&run)?; + } else if domain.state != "running" { + return Err(Error::Invalid(format!( + "cannot start VM in libvirt state: {}", + domain.state + ))); + } + + let running = backend + .inspect(&run)? + .ok_or_else(|| Error::Invalid("VM disappeared after start".into()))?; + running.validate_owned(&run)?; + if running.state != "running" { + return Err(Error::Invalid(format!( + "VM start returned in libvirt state: {}", + running.state + ))); + } + save_domain_uuid(store, &run)?; + if matches!(run.phase, Phase::Preparing | Phase::Defined) { + run = store.mark_started(identity)?; + } + Ok(run) +} + +fn owned_domain<'a>(domain: Option<&'a DomainSnapshot>, run: &VmRun) -> Result<&'a DomainSnapshot> { + let domain = + domain.ok_or_else(|| Error::Invalid("VM definition returned without a domain".into()))?; + domain.validate_owned(run)?; + Ok(domain) +} + +fn save_tool_versions(store: &ManifestStore, run: &VmRun, content: &str) -> Result<()> { + if content.trim().is_empty() { + return Err(Error::Invalid("tool version record cannot be empty".into())); + } + save_private_file(store, run, "tool-versions.txt", content.as_bytes()) +} + +fn save_domain_uuid(store: &ManifestStore, run: &VmRun) -> Result<()> { + save_private_file( + store, + run, + "domain.uuid", + format!("{}\n", run.uuid).as_bytes(), + ) +} + +fn save_private_file(store: &ManifestStore, run: &VmRun, name: &str, content: &[u8]) -> Result<()> { + store.validate(run, &run.identity)?; + let dir = store.run_dir(&run.identity); + let path = dir.join(name); + crate::manifest::reject_symlinks(&path)?; + let mut output = tempfile::NamedTempFile::new_in(&dir)?; + output + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + output.write_all(content)?; + output.as_file().sync_all()?; + output + .persist(&path) + .map_err(|error| Error::Io(error.error))?; + File::open(dir)?.sync_all()?; + Ok(()) +} + +#[cfg(test)] +#[path = "creation_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/creation_tests.rs b/ublue/skillet/crates/vm/src/creation_tests.rs new file mode 100644 index 00000000..4e7402d1 --- /dev/null +++ b/ublue/skillet/crates/vm/src/creation_tests.rs @@ -0,0 +1,165 @@ +use super::*; +use crate::{backend::DomainSnapshot, Connection}; +use std::{ + cell::{Cell, RefCell}, + fs, + path::{Path, PathBuf}, +}; +use uuid::Uuid; + +struct FakeBackend { + domain: RefCell>, + calls: RefCell>, + fail_start: Cell, +} + +impl VmBackend for FakeBackend { + fn inspect(&self, _: &VmRun) -> Result> { + Ok(self.domain.borrow().clone()) + } + fn define(&self, run: &VmRun, _: &Path) -> Result<()> { + self.calls.borrow_mut().push("define"); + *self.domain.borrow_mut() = Some(snapshot(run, "shut off")); + Ok(()) + } + fn start(&self, _: &VmRun) -> Result<()> { + self.calls.borrow_mut().push("start"); + if self.fail_start.get() { + return Err(Error::Command { + operation: "start".into(), + code: Some(1), + }); + } + self.domain.borrow_mut().as_mut().unwrap().state = "running".into(); + Ok(()) + } + fn reboot(&self, _: &VmRun) -> Result<()> { + unreachable!() + } + fn stop(&self, _: &VmRun) -> Result<()> { + unreachable!() + } + fn undefine(&self, _: &VmRun) -> Result<()> { + unreachable!() + } +} + +fn snapshot(run: &VmRun, state: &str) -> DomainSnapshot { + DomainSnapshot { + name: run.identity.domain_name(), + uuid: run.uuid, + state: state.into(), + disks: vec![run.disk.clone(), run.ignition.clone()], + } +} + +fn fixture() -> (tempfile::TempDir, ManifestStore, RunIdentity, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(temp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("clamps", "create-test").unwrap(); + let run = store + .prepare_intent( + &identity, + Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir: PathBuf::from(format!("/run/user/{}", crate::current_uid())), + }, + 2205, + "source-revision", + ) + .unwrap(); + fs::write(&run.disk, b"disk").unwrap(); + fs::create_dir_all(run.ignition.parent().unwrap()).unwrap(); + fs::write(&run.ignition, b"ignition").unwrap(); + let dir = store.run_dir(&identity); + fs::write(dir.join("skillet.sha256"), format!("{}\n", "a".repeat(64))).unwrap(); + fs::write( + dir.join("skillet-generic.sha256"), + format!("{}\n", "b".repeat(64)), + ) + .unwrap(); + let emulator = temp.path().join("qemu-system-x86_64"); + fs::write(&emulator, b"qemu").unwrap(); + fs::set_permissions(&emulator, fs::Permissions::from_mode(0o700)).unwrap(); + (temp, store, identity, emulator) +} + +#[test] +fn native_creation_defines_records_and_starts_owned_domain() { + let (_temp, store, identity, emulator) = fixture(); + let backend = FakeBackend { + domain: RefCell::new(None), + calls: RefCell::default(), + fail_start: Cell::new(false), + }; + let run = create_native( + &store, + &identity, + &backend, + &emulator, + "virsh\npodman\nyq\n", + ) + .unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["define", "start"]); + assert_eq!( + fs::read_to_string(store.run_dir(&identity).join("domain.uuid")).unwrap(), + format!("{}\n", run.uuid) + ); + assert_eq!( + fs::read_to_string(store.run_dir(&identity).join("tool-versions.txt")).unwrap(), + "virsh\npodman\nyq\n" + ); +} + +#[test] +fn native_creation_recovers_defined_domain_and_is_idempotent_when_running() { + let (_temp, store, identity, emulator) = fixture(); + let preparing = store.load(&identity).unwrap(); + let backend = FakeBackend { + domain: RefCell::new(Some(snapshot(&preparing, "shut off"))), + calls: RefCell::default(), + fail_start: Cell::new(false), + }; + let run = create_native(&store, &identity, &backend, &emulator, "versions\n").unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["start"]); + let run = create_native(&store, &identity, &backend, &emulator, "versions\n").unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["start"]); +} + +#[test] +fn native_creation_rejects_unowned_existing_domain_without_mutating_it() { + let (_temp, store, identity, emulator) = fixture(); + let run = store.load(&identity).unwrap(); + let mut foreign = snapshot(&run, "shut off"); + foreign.uuid = Uuid::new_v4(); + let backend = FakeBackend { + domain: RefCell::new(Some(foreign)), + calls: RefCell::default(), + fail_start: Cell::new(false), + }; + assert!(create_native(&store, &identity, &backend, &emulator, "versions\n").is_err()); + assert!(backend.calls.borrow().is_empty()); + assert_eq!(store.load(&identity).unwrap().phase, Phase::Preparing); +} + +#[test] +fn failed_start_retains_defined_state_for_retry() { + let (_temp, store, identity, emulator) = fixture(); + let backend = FakeBackend { + domain: RefCell::new(None), + calls: RefCell::default(), + fail_start: Cell::new(true), + }; + assert!(create_native(&store, &identity, &backend, &emulator, "versions\n").is_err()); + assert_eq!(store.load(&identity).unwrap().phase, Phase::Defined); + assert!(!store.run_dir(&identity).join("domain.uuid").exists()); + + backend.fail_start.set(false); + let run = create_native(&store, &identity, &backend, &emulator, "versions\n").unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["define", "start", "start"]); +} diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index 29a46d5d..5718b6d4 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -3,6 +3,7 @@ pub mod artifacts; pub mod backend; pub mod catalog; +pub mod creation; pub mod delivery; pub mod domain_xml; pub mod lifecycle; @@ -58,6 +59,8 @@ pub enum Error { pub type Result = std::result::Result; +pub use process::capture_version; + pub fn current_uid() -> u32 { users::get_current_uid() } diff --git a/ublue/skillet/crates/vm/src/process.rs b/ublue/skillet/crates/vm/src/process.rs index 76f35afd..3c8dca1a 100644 --- a/ublue/skillet/crates/vm/src/process.rs +++ b/ublue/skillet/crates/vm/src/process.rs @@ -14,6 +14,22 @@ pub(crate) fn capture(command: Command, timeout: Duration) -> Result { capture_with_input(command, timeout, None) } +/// Capture a local tool's version with the same output and time bounds used +/// for lifecycle subprocesses. +pub fn capture_version(program: &str) -> Result { + let mut command = Command::new(program); + command.arg("--version"); + let output = capture(command, Duration::from_secs(30))?; + if !output.status.success() { + return Err(Error::Command { + operation: format!("{program} --version"), + code: output.status.code(), + }); + } + String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid(format!("{program} version output is not UTF8"))) +} + pub(crate) fn capture_with_input( mut command: Command, timeout: Duration, From ba1142e521d721164592f6573ae4c5d2ddb18895 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 22:12:59 +0200 Subject: [PATCH 138/252] refactor(vm): move reboot and guest access into Rust --- ublue/butane/ACCEPTANCE.md | 17 +++ ublue/butane/README.md | 12 +- ublue/butane/bin/test-vm | 67 +--------- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 8 +- ublue/skillet/crates/cli/src/main.rs | 9 ++ ublue/skillet/crates/cli/src/vm.rs | 116 +++++++++++++++++- ublue/skillet/crates/vm/src/manifest.rs | 14 +++ ublue/skillet/crates/vm/src/manifest/tests.rs | 13 ++ ublue/skillet/crates/vm/src/transport.rs | 17 ++- .../skillet/crates/vm/src/transport/tests.rs | 14 +++ 10 files changed, 211 insertions(+), 76 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 00691c77..bcb65871 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -791,3 +791,20 @@ host tool; it still uses normal command approvals. Formatting, ShellCheck, Bash syntax, and `git diff --check` passed. - No live VM was created or changed. The combined native flow and Flatpak creation remain to be exercised on their respective backends. + +### Rust VM reboot and guest access, 2026-10-03 + +- `test-vm` now delegates reboot, interactive SSH, and guest diagnostics to the + Skillet CLI. Rust holds the per-run lock, validates domain UUID/disks before + guest access, and validates ownership after each operation. Reboot marks a + previously Ready run Started before issuing the backend command, so stale + readiness cannot survive an interrupted reboot. Interactive SSH attaches a + terminal directly; captured logs remain bounded through the shared transport. +- Unit coverage verifies that interactive SSH allocates a terminal and uses + the manifest target, and that a reboot invalidates Ready state. Workspace + tests and pedantic Clippy passed; shell checks and formatting passed. +- No live VM was contacted. Reboot and interactive access remain unverified + against a live guest in this slice. The CI-style container integration + command was unavailable: Podman cannot set the sticky bit on + `/run/user/4000/libpod` because the filesystem is read-only. No container + was started. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index a1c363de..ee9c88f0 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -11,8 +11,7 @@ Design decisions: [disposable VM lifecycle](../design/smoke-vms.md) and `/dev/vda`, the virtio disk attached by this launcher. Do not use it as a physical-disk install file. The host needs Podman, `yq`, `rg`, `ssh-keygen`, and KVM. Native libvirt additionally needs `virsh`, `virtqemud`, -`virtstoraged`, `passt`, `xmllint`, and `virt-xml-validate`; the helper -defines native VMs with `virsh`. The Flatpak backend uses QEMU user networking +`virtstoraged`, and `passt`; Rust defines native VMs through `virsh`. The Flatpak backend uses QEMU user networking and an existing virt-manager with its QEMU extension. The shared [data storage design](../design/storage.md) is implemented by `includes/data-storage.bu`. On a fresh VM it mounts the Btrfs `data` subvolume @@ -91,8 +90,9 @@ cargo run --release -p skillet -- test vm provision clamps smoke cargo run --release -p skillet -- test vm destroy clamps smoke ``` -`list`, `status`, `ready`, `update` and `destroy` now use the Rust ownership implementation; -the corresponding `test-vm` commands delegate to it. Reading status does not +`list`, `status`, `ready`, `update`, `destroy`, `reboot`, `ssh` and `logs` use +the Rust ownership implementation; the corresponding `test-vm` commands +delegate to it. Reading status does not import or rewrite a legacy run. Disposal creates a private versioned manifest while retaining original files until cleanup finishes. If the owned guest is already absent, disposal still cleans recorded external resources before @@ -107,8 +107,8 @@ the `skillet-clamps` host binary. The lower-level launcher accepts `--artifact P to use a specific host binary; the generic CLI is built from the workspace. Rust prepares the run's disk and SSH key, stages and specializes the Butane source tree, and records captured binary hashes in `runs/NAME/`. The standalone -Butane compiler builds Ignition; native domain definition and start are still -handled by `coreos-install` during the migration. `test-vm ready` transfers +Butane compiler builds Ignition; Rust handles native domain definition and +start. `test-vm ready` transfers both binaries over SSH after first boot and installs them at `/var/usrlocal/bin/skillet` and `/var/usrlocal/bin/skillet-clamps`. `ready` uses those captured binaries on every run. The shared base unit reads the diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index aa7b99d4..0a47eb32 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -32,18 +32,6 @@ select_backend() { exit 1 fi } -runtime_from_manifest() { - backend="$(config_value backend)" - runtime_dir="$(config_value runtime_dir)" - if [[ -z "$backend" && "$runtime_dir" == /run/user/"$(id -u)"/* ]]; then - backend=native - fi - [[ "$backend" == native || "$backend" == flatpak ]] || { echo 'Invalid VM backend in manifest' >&2; exit 1; } - if [[ "$backend" == flatpak ]]; then - export TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" - export PATH="$script_dir:$PATH" - fi -} ensure_runtime() { if [[ "$backend" == native ]]; then ensure_native_runtime @@ -97,7 +85,6 @@ usage() { echo "Usage: $0 [HOST] list | $0 HOST create|ready|update|status|ssh|logs|reboot|destroy INSTANCE [--port PORT for create]" >&2 exit 2 } -config_value() { sed -n "s/^$1=//p" "$run_dir/run.conf" | head -n 1; } skillet_cli() { if [[ -n "${SKILLET_BINARY:-}" ]]; then "$SKILLET_BINARY" "$@" @@ -136,46 +123,13 @@ shift name="$host-test-$instance" run_dir="$root/$name" [[ ! -L "$root" && ! -L "$run_dir" ]] || { echo "Symlinked artifact path refused" >&2; exit 1; } -# Migrate commands one slice at a time. All disposal now uses the Rust owner, -# including provider cleanup when the owned domain has already disappeared. -if [[ "$operation" == destroy || "$operation" == status || "$operation" == update || "$operation" == ready ]]; then +# All retained-run operations use the Rust lifecycle owner. +if [[ "$operation" == destroy || "$operation" == status || "$operation" == update || "$operation" == ready || + "$operation" == reboot || "$operation" == ssh || "$operation" == logs ]]; then (($# == 0)) || usage cd "$script_dir/../../skillet" exec cargo run --quiet --release -p skillet -- test vm "$operation" "$host" "$instance" fi -inspect_owned() { - [[ -d "$run_dir" && -f "$run_dir/run.conf" && -f "$run_dir/domain.uuid" ]] || { - echo "Missing owned run manifest for $name" >&2; exit 1; - } - [[ "$(realpath -e "$run_dir")" == "$run_dir" ]] || { echo "Artifact path escapes root" >&2; exit 1; } - [[ "$(config_value vm)" == "$name" && "$(config_value host)" == "$host" && "$(config_value uri)" == "$uri" ]] || { - echo "Run manifest does not match requested domain" >&2; exit 1; - } - runtime_from_manifest - if [[ "$backend" == native ]]; then - [[ "$runtime_dir" == "/run/user/$(id -u)" || "$runtime_dir" == /run/user/"$(id -u)"/* ]] || { echo "Invalid recorded libvirt runtime" >&2; exit 1; } - else - [[ "$runtime_dir" == "/run/user/$(id -u)/skvm" ]] || { echo "Invalid Flatpak runtime" >&2; exit 1; } - fi - ensure_runtime - actual_uuid="$(vm_virsh domuuid "$name")" - [[ "$actual_uuid" == "$(cat "$run_dir/domain.uuid")" ]] || { echo "Domain UUID mismatch" >&2; exit 1; } - disk="$run_dir/$name.qcow2" - [[ -f "$disk" && ! -L "$disk" ]] || { echo "Expected test disk is missing or symlinked" >&2; exit 1; } - ignition="$run_dir/ignition/$host.ign" - [[ -f "$ignition" && ! -L "$ignition" ]] || { echo "Expected Ignition file is missing or symlinked" >&2; exit 1; } - mapfile -t disks < <(vm_virsh domblklist "$name" --details | awk '$2 == "disk" {print $4}') - [[ "${#disks[@]}" -eq 2 && "${disks[0]}" == "$disk" && "${disks[1]}" == "$ignition" ]] || { - echo "Domain disks do not match test artifacts" >&2; exit 1; - } - port="$(config_value ssh_port)" - [[ "$port" =~ ^22[0-9][0-9]$ ]] || { echo "Invalid recorded port" >&2; exit 1; } -} -ssh_args() { - args=(-i "$run_dir/ssh/id_ed25519" -p "$port" -o BatchMode=yes - -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new - -o UserKnownHostsFile="$run_dir/ssh/known_hosts") -} case "$operation" in create) if (($# == 0)); then @@ -227,20 +181,5 @@ case "$operation" in skillet_cli test vm started "$host" "$instance" fi echo "Run: $run_dir" ;; - ssh) - (($# == 0)) || usage - inspect_owned - ssh_args - exec ssh "${args[@]}" giacomo@127.0.0.1 ;; - logs) - (($# == 0)) || usage - inspect_owned - ssh_args - ssh "${args[@]}" giacomo@127.0.0.1 \ - 'sudo -n rpm-ostree status; sudo -n journalctl -b -u ucore-bootstrap.service -u skillet-apply.service --no-pager -n 200' ;; - reboot) - (($# == 0)) || usage - inspect_owned - vm_virsh reboot "$name" ;; *) usage ;; esac diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 1f49f03d..2b79f48d 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -47,14 +47,16 @@ local disk atomically, records its source digest, and creates or validates the per-run SSH key pair; `coreos-install` delegates that operation through a hidden CLI command. The same library stages and specializes the Butane source tree and records compatibility metadata and binary hashes. The standalone -Butane compilation, reboot and interactive SSH still use Bash. Native +Butane compilation still uses Bash. Native define/start, emulator discovery, UUID bookkeeping and tool-version capture now run through the Rust lifecycle and selected backend. Flatpak `virt-install` creation and version capture remain in Bash. Credential delivery has not yet adopted the shared transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability -declaration. Next: migrate reboot and interactive SSH, then move Flatpak -`virt-install` creation behind a Rust adapter. Disposal and readiness live acceptance remain +declaration. Reboot, interactive SSH and guest diagnostics now use Rust's +ownership-checked backend and SSH transport. Next: move Flatpak +`virt-install` creation behind a Rust adapter and make the shell create wrapper +delegate without lifecycle decisions. Disposal and readiness live acceptance remain deferred. See [creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 36715bbb..9ec71dab 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -142,6 +142,12 @@ enum VmCommands { List(VmListArgs), /// Inspect the UUID and disks recorded for an owned disposable VM Status(VmTargetArgs), + /// Reboot a retained, owned disposable VM + Reboot(VmTargetArgs), + /// Open an interactive SSH shell to a retained, owned disposable VM + Ssh(VmTargetArgs), + /// Read bootstrap and Skillet journals from a retained VM + Logs(VmTargetArgs), /// Verify signed boot and apply the captured base/user environment Ready(VmTargetArgs), /// Compatibility adapter for the former `RUN_DIR` readiness helper @@ -334,6 +340,9 @@ fn run_vm_command(command: VmCommands) -> Result<()> { VmCommands::Destroy(args) => run_vm_destroy(&args)?, VmCommands::List(args) => run_vm_list(&args)?, VmCommands::Status(args) => vm::status(&args)?, + VmCommands::Reboot(args) => vm::reboot(&args)?, + VmCommands::Ssh(args) => vm::ssh(&args)?, + VmCommands::Logs(args) => vm::logs(&args)?, VmCommands::Ready(args) => vm::ready(&args)?, VmCommands::ReadyDirectory(args) => vm::ready_directory(&args)?, VmCommands::Provision(args) => secret_delivery::provision_vm(&args)?, diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index de419373..348a48b3 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -5,9 +5,11 @@ use super::{ }; use anyhow::{anyhow, Result}; use skillet_vm::{ - backend::{VirshBackend, VmBackend}, - ManifestStore, RunIdentity, + backend::{DomainSnapshot, VirshBackend, VmBackend}, + transport::{GuestCommand, GuestTransport, HostKeyPolicy, SshTransport}, + ManifestStore, Phase, RunIdentity, VmRun, }; +use std::io::Write as _; pub(super) fn status(args: &VmTargetArgs) -> Result<()> { let butane = butane_root()?; @@ -93,6 +95,116 @@ pub(super) fn create_native(args: &VmTargetArgs) -> Result<()> { Ok(()) } +pub(super) fn reboot(args: &VmTargetArgs) -> Result<()> { + let (butane, store, identity) = context(args)?; + let _lock = store.lock(&identity)?; + let mut run = store.load(&identity)?; + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(anyhow!("VM must be started or ready before reboot")); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + require_owned_domain(&backend, &run)?; + run = store.mark_reboot_pending(&identity)?; + backend.reboot(&run)?; + require_owned_domain(&backend, &run)?; + println!("Reboot requested for {}", identity.domain_name()); + Ok(()) +} + +pub(super) fn ssh(args: &VmTargetArgs) -> Result<()> { + let (butane, store, identity) = context(args)?; + let _lock = store.lock(&identity)?; + let run = store.load(&identity)?; + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(anyhow!("VM must be started or ready before opening SSH")); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + require_running_domain(&backend, &run)?; + let transport = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Enroll)?; + let status = transport.interactive()?; + require_owned_domain(&backend, &run)?; + if !status.success() { + return Err(anyhow!( + "interactive SSH exited with status {:?}", + status.code() + )); + } + Ok(()) +} + +pub(super) fn logs(args: &VmTargetArgs) -> Result<()> { + let (butane, store, identity) = context(args)?; + let _lock = store.lock(&identity)?; + let run = store.load(&identity)?; + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(anyhow!("VM must be started or ready before reading logs")); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + require_running_domain(&backend, &run)?; + let transport = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Enroll)?; + for command in [ + GuestCommand { + program: "sudo", + arguments: &["-n", "rpm-ostree", "status"], + }, + GuestCommand { + program: "sudo", + arguments: &[ + "-n", + "journalctl", + "-b", + "-u", + "ucore-bootstrap.service", + "-u", + "skillet-apply.service", + "--no-pager", + "-n", + "200", + ], + }, + ] { + require_running_domain(&backend, &run)?; + let output = transport.execute(&command, None)?; + require_owned_domain(&backend, &run)?; + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + if !output.status.success() { + return Err(anyhow!( + "guest diagnostic command {} failed with status {:?}", + command.program, + output.status.code() + )); + } + } + Ok(()) +} + +fn context(args: &VmTargetArgs) -> Result<(std::path::PathBuf, ManifestStore, RunIdentity)> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + Ok((butane, store, identity)) +} + +fn require_owned_domain(backend: &impl VmBackend, run: &VmRun) -> Result { + let domain = backend + .inspect(run)? + .ok_or_else(|| anyhow!("owned VM domain is absent"))?; + domain.validate_owned(run)?; + Ok(domain) +} + +fn require_running_domain(backend: &impl VmBackend, run: &VmRun) -> Result { + let domain = require_owned_domain(backend, run)?; + if domain.state != "running" { + return Err(anyhow!( + "VM is not running (libvirt state: {})", + domain.state + )); + } + Ok(domain) +} + pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { let butane = butane_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs index d9bd77eb..054ad1cc 100644 --- a/ublue/skillet/crates/vm/src/manifest.rs +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -264,6 +264,20 @@ impl ManifestStore { Ok(run) } + /// Reboot invalidates the previously accepted readiness result before the + /// backend action, so interruption cannot leave stale Ready state behind. + pub fn mark_reboot_pending(&self, identity: &RunIdentity) -> Result { + let mut run = self.load(identity)?; + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(Error::Invalid( + "only a started or ready VM can be rebooted".into(), + )); + } + run.phase = Phase::Started; + self.save(&run)?; + Ok(run) + } + /// Compatibility entry points obtain identity from recorded fields, never /// by splitting a potentially ambiguous composite directory name. pub fn load_directory(&self, dir: &Path) -> Result { diff --git a/ublue/skillet/crates/vm/src/manifest/tests.rs b/ublue/skillet/crates/vm/src/manifest/tests.rs index bf0ca115..d87183ca 100644 --- a/ublue/skillet/crates/vm/src/manifest/tests.rs +++ b/ublue/skillet/crates/vm/src/manifest/tests.rs @@ -247,6 +247,19 @@ fn partial_cleanup_can_load_when_owned_artifacts_are_already_absent() { assert_eq!(store.load(&identity).unwrap().phase, Phase::DomainRemoved); } +#[test] +fn reboot_invalidates_readiness_before_the_backend_operation() { + let (_tmp, store, identity) = legacy_run(); + let mut ready = store.import(&identity).unwrap(); + ready.phase = Phase::Ready; + store.save(&ready).unwrap(); + let ready = store.load(&identity).unwrap(); + assert_eq!(ready.phase, Phase::Ready); + let rebooting = store.mark_reboot_pending(&identity).unwrap(); + assert_eq!(rebooting.phase, Phase::Started); + assert_eq!(store.load(&identity).unwrap().phase, Phase::Started); +} + #[test] fn flatpak_and_native_connections_have_distinct_validated_runtime_paths() { let uid = users::get_current_uid(); diff --git a/ublue/skillet/crates/vm/src/transport.rs b/ublue/skillet/crates/vm/src/transport.rs index b370fa04..919ad554 100644 --- a/ublue/skillet/crates/vm/src/transport.rs +++ b/ublue/skillet/crates/vm/src/transport.rs @@ -2,7 +2,7 @@ use crate::{Error, Result, SshTarget}; use std::{ path::Path, - process::{Command, Output}, + process::{Command, ExitStatus, Output}, time::Duration, }; @@ -153,6 +153,21 @@ impl SshTransport { command.arg(source).arg(format!("{target}:{destination}")); Ok(command) } + + fn interactive_command(&self) -> Command { + let mut command = Command::new("ssh"); + command.arg("-tt"); + self.options(&mut command, "-p"); + command.arg(self.remote_target()); + command + } + + /// Run an interactive guest shell with the caller's terminal attached. + /// Unlike captured guest commands this is intentionally unbounded until + /// the user exits, and inherits stdin/stdout/stderr directly. + pub fn interactive(&self) -> Result { + self.interactive_command().status().map_err(Error::Io) + } } impl GuestTransport for SshTransport { diff --git a/ublue/skillet/crates/vm/src/transport/tests.rs b/ublue/skillet/crates/vm/src/transport/tests.rs index 9780f544..23ed332e 100644 --- a/ublue/skillet/crates/vm/src/transport/tests.rs +++ b/ublue/skillet/crates/vm/src/transport/tests.rs @@ -58,6 +58,20 @@ fn enroll_policy_does_not_replace_a_recorded_host_key_and_verify_requires_it() { .any(|arg| arg == "StrictHostKeyChecking=accept-new")); } +#[test] +fn interactive_session_inherits_a_terminal_and_uses_recorded_target() { + let dir = tempfile::tempdir().unwrap(); + let transport = SshTransport::new(target(dir.path()), HostKeyPolicy::Enroll).unwrap(); + let command = transport.interactive_command(); + let arguments: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(arguments.contains(&"-tt")); + assert!(!arguments.contains(&"-T")); + assert_eq!(arguments.last(), Some(&"fixture@127.0.0.1")); +} + #[test] fn upload_uses_recorded_port_and_refuses_shell_or_path_traversal() { let dir = tempfile::tempdir().unwrap(); From b2591191e0b73027acacce361301c034593bab8c Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 22:20:43 +0200 Subject: [PATCH 139/252] refactor(vm): add Rust Flatpak creation adapter --- ublue/butane/ACCEPTANCE.md | 16 ++ ublue/butane/README.md | 9 +- ublue/butane/bin/coreos-install | 39 +---- ublue/butane/bin/test-vm | 14 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 23 +-- ublue/skillet/crates/cli/src/main.rs | 13 +- ublue/skillet/crates/cli/src/vm.rs | 39 +++-- ublue/skillet/crates/vm/src/backend.rs | 147 ++++++++++++++++++ ublue/skillet/crates/vm/src/backend/tests.rs | 65 ++++++++ ublue/skillet/crates/vm/src/creation.rs | 48 +++++- ublue/skillet/crates/vm/src/creation_tests.rs | 33 +++- 11 files changed, 362 insertions(+), 84 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index bcb65871..8529f923 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -808,3 +808,19 @@ host tool; it still uses normal command approvals. command was unavailable: Podman cannot set the sticky bit on `/run/user/4000/libpod` because the filesystem is read-only. No container was started. + +### Rust Flatpak VM creation adapter, 2026-10-03 + +- Added a bounded `virt-install` adapter that uses the recorded Flatpak runtime + and preserves the manifest UUID, disk, Ignition, QEMU user-networking, and + forwarded SSH port. Flatpak definition now uses the common Rust phase and + ownership recovery logic, including a retry when `virt-install` created or + started the guest before an interrupted response. `coreos-install` delegates + both native and Flatpak domain creation; Bash no longer records the domain + UUID or start phase. +- Fake adapter and lifecycle coverage checks the executable wrapper, runtime + environment, UUID, disk/network arguments, successful transition and retry + behavior. The workspace suite passed, including 66 VM crate tests, and + pedantic Clippy passed; formatting, ShellCheck, Bash syntax, and + `git diff --check` passed. +- No Flatpak VM was created. Live acceptance remains unverified on Bazzite. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index ee9c88f0..e9bfa341 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -11,8 +11,9 @@ Design decisions: [disposable VM lifecycle](../design/smoke-vms.md) and `/dev/vda`, the virtio disk attached by this launcher. Do not use it as a physical-disk install file. The host needs Podman, `yq`, `rg`, `ssh-keygen`, and KVM. Native libvirt additionally needs `virsh`, `virtqemud`, -`virtstoraged`, and `passt`; Rust defines native VMs through `virsh`. The Flatpak backend uses QEMU user networking -and an existing virt-manager with its QEMU extension. +`virtstoraged`, and `passt`; Rust defines native VMs through `virsh`. The +Flatpak backend uses `virt-install`, QEMU user networking, and an existing +virt-manager installation with its QEMU extension. The shared [data storage design](../design/storage.md) is implemented by `includes/data-storage.bu`. On a fresh VM it mounts the Btrfs `data` subvolume at `/var/lib/data` and sets rootful Podman's graphroot there. Recreate a @@ -107,8 +108,8 @@ the `skillet-clamps` host binary. The lower-level launcher accepts `--artifact P to use a specific host binary; the generic CLI is built from the workspace. Rust prepares the run's disk and SSH key, stages and specializes the Butane source tree, and records captured binary hashes in `runs/NAME/`. The standalone -Butane compiler builds Ignition; Rust handles native domain definition and -start. `test-vm ready` transfers +Butane compiler builds Ignition; Rust handles native and Flatpak domain +definition and start. `test-vm ready` transfers both binaries over SSH after first boot and installs them at `/var/usrlocal/bin/skillet` and `/var/usrlocal/bin/skillet-clamps`. `ready` uses those captured binaries on every run. The shared base unit reads the diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install index 576d04a3..b719707d 100755 --- a/ublue/butane/bin/coreos-install +++ b/ublue/butane/bin/coreos-install @@ -85,7 +85,9 @@ else [[ -z "$vm_uuid" ]] || { echo "--uuid requires --resume-prepared" >&2; exit 2; } fi vm_command virsh -c "$libvirt_uri" uri >/dev/null || { echo "Cannot access libvirt URI $libvirt_uri" >&2; exit 1; } -command -v passt >/dev/null || { echo "passt is required for loopback SSH forwarding" >&2; exit 1; } +if [[ "${TEST_VM_BACKEND:-native}" == native ]]; then + command -v passt >/dev/null || { echo "passt is required for loopback SSH forwarding" >&2; exit 1; } +fi if vm_command virsh -c "$libvirt_uri" dominfo "$vm_name" &>/dev/null; then echo "Refusing existing libvirt domain $vm_name on $libvirt_uri" >&2 exit 1 @@ -142,33 +144,8 @@ ignition_bytes="$(stat -c '%s' "$ignition")" if ((ignition_bytes > 1048576)); then echo "Warning: Ignition config is ${ignition_bytes} bytes; large QEMU fw_cfg configs can take a long time to read" >&2 fi -if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then - { - virt-install --version - vm_command virsh --version - podman --version - yq --version - } > "$output_dir/tool-versions.txt" -else - skillet_cli test vm create-native "$host" "$instance" -fi - -if [[ "${TEST_VM_BACKEND:-native}" == flatpak ]]; then - network=none - qemu_commandline="-fw_cfg name=opt/com.coreos/config,file=$ignition -netdev user,id=skilletnet,hostfwd=tcp:127.0.0.1:$ssh_port-:22 -device virtio-net-pci,netdev=skilletnet,bus=pcie.0,addr=0x1d" - uuid_args=() - [[ -z "$vm_uuid" ]] || uuid_args=(--uuid "$vm_uuid") - printf '%s\n' "$vm_uuid" > "$output_dir/domain.uuid.tmp" - chmod 600 "$output_dir/domain.uuid.tmp" - mv -f "$output_dir/domain.uuid.tmp" "$output_dir/domain.uuid" - echo "Creating $vm_name on $libvirt_uri, disk $output_dir/$vm_name.qcow2, SSH 127.0.0.1:$ssh_port" >&2 - vm_command virt-install --connect "$libvirt_uri" --name "$vm_name" "${uuid_args[@]}" \ - --osinfo linux2024 --memory 8192 --vcpus 2 --import \ - --disk "path=$output_dir/$vm_name.qcow2,format=qcow2,bus=virtio" \ - --disk "path=$ignition,format=raw,bus=virtio,readonly=on" \ - --network "$network" --graphics none \ - --console pty,target_type=serial --noautoconsole \ - --qemu-commandline="$qemu_commandline" -else - [[ "${TEST_VM_BACKEND:-native}" == native ]] || { echo 'Flatpak backend needs virt-install' >&2; exit 1; } -fi +case "${TEST_VM_BACKEND:-native}" in + native) skillet_cli test vm create-native "$host" "$instance" ;; + flatpak) skillet_cli test vm create-flatpak "$host" "$instance" ;; + *) echo "Unknown VM backend: ${TEST_VM_BACKEND:-native}" >&2; exit 2 ;; +esac diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 0a47eb32..9ab252c4 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -159,15 +159,7 @@ case "$operation" in if [[ "$backend" == native ]]; then skillet_cli test vm create-native "$host" "$instance" else - skillet_cli test vm status "$host" "$instance" - state="$(vm_virsh domstate "$name" | tr -d '\r')" - if [[ "$state" == "shut off" ]]; then - vm_virsh start "$name" - elif [[ "$state" != "running" ]]; then - echo "Cannot resume VM in libvirt state: $state" >&2 - exit 1 - fi - skillet_cli test vm started "$host" "$instance" + skillet_cli test vm create-flatpak "$host" "$instance" fi echo "Resumed recorded VM $name" exit 0 @@ -176,10 +168,6 @@ case "$operation" in uuid="$(skillet_cli "${prepare_args[@]}")" TEST_VM_BACKEND="$backend" TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" "$script_dir/coreos-install" --name "$name" --output-dir "$run_dir" \ --ssh-port "$port" --libvirt-uri "$uri" --uuid "$uuid" --resume-prepared "$script_dir/../$host.bu" - if [[ "$backend" == flatpak ]]; then - vm_virsh domuuid "$name" > "$run_dir/domain.uuid" - skillet_cli test vm started "$host" "$instance" - fi echo "Run: $run_dir" ;; *) usage ;; esac diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 2b79f48d..6861ec70 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -47,16 +47,17 @@ local disk atomically, records its source digest, and creates or validates the per-run SSH key pair; `coreos-install` delegates that operation through a hidden CLI command. The same library stages and specializes the Butane source tree and records compatibility metadata and binary hashes. The standalone -Butane compilation still uses Bash. Native +Butane compilation still uses Bash. Native and Flatpak define/start, emulator discovery, UUID bookkeeping and tool-version capture -now run through the Rust lifecycle and selected backend. Flatpak -`virt-install` creation and version capture remain in Bash. Credential delivery +now run through Rust lifecycle adapters. Credential delivery has not yet adopted the shared transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability declaration. Reboot, interactive SSH and guest diagnostics now use Rust's -ownership-checked backend and SSH transport. Next: move Flatpak -`virt-install` creation behind a Rust adapter and make the shell create wrapper -delegate without lifecycle decisions. Disposal and readiness live acceptance remain +ownership-checked backend and SSH transport. The Flatpak `virt-install` +adapter shares creation recovery and manifest updates with native libvirt. +Next: move backend selection, runtime preparation and create orchestration +behind Rust, leaving shell entry points as forwarding wrappers. Disposal and +readiness live acceptance remain deferred. See [creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), @@ -91,11 +92,11 @@ creation inspectable; readiness and target selection remain duplicated. current connection/runtime selection and networking behavior. Do not link native libvirt, embed shell, or install tools during this migration. 4. Move list/status/create/reboot/update into Rust. Native domain XML now uses - `quick-xml`; Flatpak still uses `virt-install`. Rust owns atomic disk - preparation, per-run key creation/validation, structured Butane staging, - and native define/start, tool-version capture, and domain UUID bookkeeping. - `coreos-install` invokes the standalone Butane compiler. Build artifacts - through a shared Cargo helper. Validate required + `quick-xml`; Flatpak uses a focused `virt-install` adapter. Rust owns atomic + disk preparation, per-run key creation/validation, structured Butane + staging, backend definition/start, tool-version capture, and domain UUID + bookkeeping. `coreos-install` invokes the standalone Butane compiler. Build + artifacts through a shared Cargo helper. Validate required tools and SSH-port conflicts before costly provisioning. See [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03). 5. Persist creation intent and a generated UUID before domain definition; diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 9ec71dab..b3a6add6 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -133,9 +133,9 @@ enum VmCommands { /// Define and start an owned native VM from staged artifacts #[command(hide = true)] CreateNative(VmTargetArgs), - /// Record successful VM start for the source-tree helper + /// Define and start an owned Flatpak-backed VM from staged artifacts #[command(hide = true)] - Started(VmTargetArgs), + CreateFlatpak(VmTargetArgs), /// Destroy a disposable host VM and remove its temporary key and artifacts Destroy(VmDestroyArgs), /// List available host templates and their recorded disposable VMs @@ -336,7 +336,7 @@ fn run_vm_command(command: VmCommands) -> Result<()> { VmCommands::PrepareLocal(args) => vm::prepare_local(&args)?, VmCommands::StageLocal(args) => vm::stage_local(&args)?, VmCommands::CreateNative(args) => vm::create_native(&args)?, - VmCommands::Started(args) => vm::record_started(&args)?, + VmCommands::CreateFlatpak(args) => vm::create_flatpak(&args)?, VmCommands::Destroy(args) => run_vm_destroy(&args)?, VmCommands::List(args) => run_vm_list(&args)?, VmCommands::Status(args) => vm::status(&args)?, @@ -739,4 +739,11 @@ mod tests { Args::try_parse_from(["skillet", "test", "vm", "create-native", "clamps", "smoke"]); assert!(parsed.is_ok()); } + + #[test] + fn flatpak_vm_lifecycle_command_parses_identity_fields() { + let parsed = + Args::try_parse_from(["skillet", "test", "vm", "create-flatpak", "clamps", "smoke"]); + assert!(parsed.is_ok()); + } } diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 348a48b3..a4a54e8a 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -5,7 +5,7 @@ use super::{ }; use anyhow::{anyhow, Result}; use skillet_vm::{ - backend::{DomainSnapshot, VirshBackend, VmBackend}, + backend::{DomainSnapshot, FlatpakVirtInstall, VirshBackend, VmBackend}, transport::{GuestCommand, GuestTransport, HostKeyPolicy, SshTransport}, ManifestStore, Phase, RunIdentity, VmRun, }; @@ -34,21 +34,6 @@ pub(super) fn status(args: &VmTargetArgs) -> Result<()> { Ok(()) } -pub(super) fn record_started(args: &VmTargetArgs) -> Result<()> { - let butane = butane_root()?; - let identity = RunIdentity::new(&args.hostname, &args.instance)?; - let store = ManifestStore::new(&butane.join("runs"), current_uid())?; - let _lock = store.lock(&identity)?; - let run = store.load(&identity)?; - let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; - backend - .inspect(&run)? - .ok_or_else(|| anyhow!("VM creation returned without an owned domain"))? - .validate_owned(&run)?; - store.mark_started(&identity)?; - Ok(()) -} - pub(super) fn prepare_local(args: &VmPrepareLocalArgs) -> Result<()> { let butane = butane_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; @@ -95,6 +80,28 @@ pub(super) fn create_native(args: &VmTargetArgs) -> Result<()> { Ok(()) } +pub(super) fn create_flatpak(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load(&identity)?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let creator = FlatpakVirtInstall::for_run(&run, &butane.join("bin/virt-install"))?; + let versions = format!( + "Flatpak VM launcher: virt-install\n{}{}{}{}", + creator.version()?, + backend.version(&run)?, + skillet_vm::capture_version("podman")?, + skillet_vm::capture_version("yq")?, + ); + let run = + skillet_vm::creation::create_flatpak(&store, &identity, &backend, &versions, |run| { + creator.define(run) + })?; + println!("Started {} ({})", identity.domain_name(), run.uuid); + Ok(()) +} + pub(super) fn reboot(args: &VmTargetArgs) -> Result<()> { let (butane, store, identity) = context(args)?; let _lock = store.lock(&identity)?; diff --git a/ublue/skillet/crates/vm/src/backend.rs b/ublue/skillet/crates/vm/src/backend.rs index 58a8a762..d27cd99a 100644 --- a/ublue/skillet/crates/vm/src/backend.rs +++ b/ublue/skillet/crates/vm/src/backend.rs @@ -63,6 +63,18 @@ pub trait VirshExecutor { fn execute(&self, invocation: &VirshInvocation) -> Result; } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VirtInstallInvocation { + pub program: PathBuf, + pub arguments: Vec, + pub environment: BTreeMap, +} + +/// Focused execution boundary for Flatpak's one-shot VM definition tool. +pub trait VirtInstallExecutor { + fn execute(&self, invocation: &VirtInstallInvocation) -> Result; +} + pub struct ProcessExecutor { pub timeout: Duration, } @@ -91,6 +103,141 @@ impl VirshExecutor for ProcessExecutor { } } +pub struct VirtInstallProcessExecutor { + pub timeout: Duration, +} + +impl Default for VirtInstallProcessExecutor { + fn default() -> Self { + Self { + timeout: Duration::from_mins(1), + } + } +} + +impl VirtInstallExecutor for VirtInstallProcessExecutor { + fn execute(&self, invocation: &VirtInstallInvocation) -> Result { + let mut command = Command::new(&invocation.program); + command + .args(&invocation.arguments) + .envs(&invocation.environment); + let output = crate::process::capture(command, self.timeout)?; + Ok(VirshOutput { + success: output.status.success(), + code: output.status.code(), + stdout: String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid("virt-install output is not UTF8".into()))?, + }) + } +} + +pub struct FlatpakVirtInstall { + program: PathBuf, + runtime_dir: PathBuf, + executor: E, +} + +impl FlatpakVirtInstall { + pub fn for_run(run: &VmRun, program: &Path) -> Result { + Self::new(run, program, VirtInstallProcessExecutor::default()) + } +} + +impl FlatpakVirtInstall { + pub fn new(run: &VmRun, program: &Path, executor: E) -> Result { + if run.connection.backend != Backend::Flatpak + || !program.is_absolute() + || !program.is_file() + || fs::metadata(program)?.permissions().mode() & 0o111 == 0 + { + return Err(Error::Invalid( + "Flatpak virt-install requires an executable absolute wrapper".into(), + )); + } + run.connection.validate(run.owner_uid)?; + Ok(Self { + program: program.into(), + runtime_dir: run.connection.runtime_dir.clone(), + executor, + }) + } + + pub fn version(&self) -> Result { + self.invoke(vec![OsString::from("--version")]) + } + + pub fn define(&self, run: &VmRun) -> Result<()> { + if run.connection.backend != Backend::Flatpak + || run.connection.runtime_dir != self.runtime_dir + { + return Err(Error::Invalid( + "virt-install adapter connection differs from the run manifest".into(), + )); + } + crate::manifest::reject_symlinks(&run.disk)?; + crate::manifest::reject_symlinks(&run.ignition)?; + if !run.disk.is_file() || !run.ignition.is_file() { + return Err(Error::Invalid( + "Flatpak VM disk and Ignition must be regular files".into(), + )); + } + let arguments = vec![ + "--connect".into(), + run.connection.uri.clone().into(), + "--name".into(), + run.identity.domain_name().into(), + "--uuid".into(), + run.uuid.to_string().into(), + "--osinfo".into(), + "linux2024".into(), + "--memory".into(), + "8192".into(), + "--vcpus".into(), + "2".into(), + "--import".into(), + "--disk".into(), + format!("path={},format=qcow2,bus=virtio", run.disk.display()).into(), + "--disk".into(), + format!( + "path={},format=raw,bus=virtio,readonly=on", + run.ignition.display() + ) + .into(), + "--network".into(), + "none".into(), + "--graphics".into(), + "none".into(), + "--console".into(), + "pty,target_type=serial".into(), + "--noautoconsole".into(), + format!( + "--qemu-commandline=-fw_cfg name=opt/com.coreos/config,file={} -netdev user,id=skilletnet,hostfwd=tcp:127.0.0.1:{}-:22 -device virtio-net-pci,netdev=skilletnet,bus=pcie.0,addr=0x1d", + run.ignition.display(), run.ssh.port + ) + .into(), + ]; + self.invoke(arguments).map(|_| ()) + } + + fn invoke(&self, arguments: Vec) -> Result { + let output = self.executor.execute(&VirtInstallInvocation { + program: self.program.clone(), + arguments, + environment: BTreeMap::from([( + "TEST_VM_LIBVIRT_RUNTIME_DIR".into(), + self.runtime_dir.as_os_str().into(), + )]), + })?; + if !output.success { + return Err(Error::Command { + operation: "virt-install".into(), + code: output.code, + }); + } + Ok(output.stdout) + } +} + pub struct VirshBackend { connection: Connection, program: PathBuf, diff --git a/ublue/skillet/crates/vm/src/backend/tests.rs b/ublue/skillet/crates/vm/src/backend/tests.rs index b7077076..2ecfbd98 100644 --- a/ublue/skillet/crates/vm/src/backend/tests.rs +++ b/ublue/skillet/crates/vm/src/backend/tests.rs @@ -8,6 +8,19 @@ struct FakeExecutor { calls: RefCell>, } +#[derive(Default)] +struct FakeVirtInstallExecutor { + responses: RefCell>, + calls: RefCell>, +} + +impl VirtInstallExecutor for FakeVirtInstallExecutor { + fn execute(&self, invocation: &VirtInstallInvocation) -> Result { + self.calls.borrow_mut().push(invocation.clone()); + Ok(self.responses.borrow_mut().pop_front().unwrap()) + } +} + impl VirshExecutor for FakeExecutor { fn execute(&self, invocation: &VirshInvocation) -> Result { self.calls.borrow_mut().push(invocation.clone()); @@ -114,6 +127,58 @@ fn capabilities_select_executable_x86_64_hvm_emulator() { assert_eq!(backend.x86_64_emulator(&run).unwrap(), emulator); } +#[test] +fn flatpak_virt_install_preserves_uuid_paths_network_and_recorded_runtime() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.load(&identity).unwrap(); + run.connection.backend = Backend::Flatpak; + run.connection.runtime_dir = format!("/run/user/{}/skvm", run.owner_uid).into(); + fs::write(&run.disk, "disk").unwrap(); + fs::create_dir_all(run.ignition.parent().unwrap()).unwrap(); + fs::write(&run.ignition, "ignition").unwrap(); + let directory = tempfile::tempdir().unwrap(); + let wrapper = directory.path().join("virt-install"); + fs::write(&wrapper, "fixture wrapper").unwrap(); + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o700)).unwrap(); + let executor = FakeVirtInstallExecutor { + responses: RefCell::new(vec![output("virt-install 4.0\n"), output("")].into()), + calls: RefCell::default(), + }; + let creator = FlatpakVirtInstall::new(&run, &wrapper, executor).unwrap(); + assert_eq!(creator.version().unwrap(), "virt-install 4.0\n"); + creator.define(&run).unwrap(); + let calls = creator.executor.calls.borrow(); + let invocation = &calls[1]; + let arguments: Vec<_> = invocation + .arguments + .iter() + .map(|argument| argument.to_str().unwrap()) + .collect(); + let uuid = run.uuid.to_string(); + assert_eq!(invocation.program, wrapper); + assert_eq!( + invocation.environment[&OsString::from("TEST_VM_LIBVIRT_RUNTIME_DIR")], + run.connection.runtime_dir.as_os_str() + ); + assert!(arguments + .windows(2) + .any(|pair| pair == ["--uuid", uuid.as_str()])); + assert!(arguments + .windows(2) + .any(|pair| pair == ["--network", "none"])); + assert!(arguments.iter().any(|argument| { + argument.starts_with("--qemu-commandline=-fw_cfg ") + && argument.contains(&format!("hostfwd=tcp:127.0.0.1:{}-:22", run.ssh.port)) + })); + assert!(arguments.iter().any(|argument| { + argument + == &format!( + "path={},format=raw,bus=virtio,readonly=on", + run.ignition.display() + ) + })); +} + #[test] fn reused_name_and_renamed_uuid_are_not_owned() { let (_tmp, store, identity) = legacy_run(); diff --git a/ublue/skillet/crates/vm/src/creation.rs b/ublue/skillet/crates/vm/src/creation.rs index 886ce37e..560b9dc0 100644 --- a/ublue/skillet/crates/vm/src/creation.rs +++ b/ublue/skillet/crates/vm/src/creation.rs @@ -18,18 +18,59 @@ pub fn create_native( backend: &impl VmBackend, emulator: &Path, tool_versions: &str, +) -> Result { + create_with( + store, + identity, + backend, + Backend::Native, + tool_versions, + |run| { + let xml = domain_xml::write_native_domain_xml(store, run, emulator)?; + backend.define(run, &xml) + }, + ) +} + +/// Create a Flatpak-backed guest through the caller's focused virt-install +/// adapter, then use the same ownership and recovery sequence as native VM +/// creation. `virt-install` may start the guest as part of definition. +pub fn create_flatpak( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + tool_versions: &str, + define: impl FnOnce(&VmRun) -> Result<()>, +) -> Result { + create_with( + store, + identity, + backend, + Backend::Flatpak, + tool_versions, + define, + ) +} + +fn create_with( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + expected_backend: Backend, + tool_versions: &str, + define: impl FnOnce(&VmRun) -> Result<()>, ) -> Result { let _lock = store.lock(identity)?; let mut run = store.load(identity)?; store.validate(&run, identity)?; - if run.connection.backend != Backend::Native + if run.connection.backend != expected_backend || !matches!( run.phase, Phase::Preparing | Phase::Defined | Phase::Started | Phase::Ready ) { return Err(Error::Invalid( - "native VM creation requires an active native run".into(), + "VM creation requires an active run for the selected backend".into(), )); } save_tool_versions(store, &run, tool_versions)?; @@ -46,8 +87,7 @@ pub fn create_native( "recorded native VM domain is absent; refusing to recreate it".into(), )); } - let xml = domain_xml::write_native_domain_xml(store, &run, emulator)?; - backend.define(&run, &xml)?; + define(&run)?; domain = backend.inspect(&run)?; let defined = owned_domain(domain.as_ref(), &run)?; if run.phase == Phase::Preparing { diff --git a/ublue/skillet/crates/vm/src/creation_tests.rs b/ublue/skillet/crates/vm/src/creation_tests.rs index 4e7402d1..32a97c3d 100644 --- a/ublue/skillet/crates/vm/src/creation_tests.rs +++ b/ublue/skillet/crates/vm/src/creation_tests.rs @@ -54,6 +54,10 @@ fn snapshot(run: &VmRun, state: &str) -> DomainSnapshot { } fn fixture() -> (tempfile::TempDir, ManifestStore, RunIdentity, PathBuf) { + fixture_for(Backend::Native) +} + +fn fixture_for(backend: Backend) -> (tempfile::TempDir, ManifestStore, RunIdentity, PathBuf) { let temp = tempfile::tempdir().unwrap(); let store = ManifestStore::new(temp.path(), crate::current_uid()).unwrap(); let identity = RunIdentity::new("clamps", "create-test").unwrap(); @@ -61,9 +65,14 @@ fn fixture() -> (tempfile::TempDir, ManifestStore, RunIdentity, PathBuf) { .prepare_intent( &identity, Connection { - backend: Backend::Native, + backend, uri: "qemu:///session".into(), - runtime_dir: PathBuf::from(format!("/run/user/{}", crate::current_uid())), + runtime_dir: match backend { + Backend::Native => PathBuf::from(format!("/run/user/{}", crate::current_uid())), + Backend::Flatpak => { + PathBuf::from(format!("/run/user/{}/skvm", crate::current_uid())) + } + }, }, 2205, "source-revision", @@ -163,3 +172,23 @@ fn failed_start_retains_defined_state_for_retry() { assert_eq!(run.phase, Phase::Started); assert_eq!(*backend.calls.borrow(), ["define", "start", "start"]); } + +#[test] +fn flatpak_creation_uses_one_shot_definition_then_shared_recovery() { + let (_temp, store, identity, _) = fixture_for(Backend::Flatpak); + let backend = FakeBackend { + domain: RefCell::new(None), + calls: RefCell::default(), + fail_start: Cell::new(false), + }; + let run = create_flatpak( + &store, + &identity, + &backend, + "virt-install\nvirsh\npodman\nyq\n", + |run| backend.define(run, Path::new("/flatpak-creator-test")), + ) + .unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["define", "start"]); +} From d341498f81b6480669574f00a2d93beaae5488d1 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 22:57:44 +0200 Subject: [PATCH 140/252] refactor(vm): move create orchestration into Rust --- ublue/butane/bin/butane | 2 +- ublue/butane/bin/coreos-install | 151 ------- ublue/butane/bin/test-vm | 189 ++------- ublue/butane/clamps.bu | 2 +- ublue/skillet/crates/cli/src/main.rs | 182 +-------- ublue/skillet/crates/cli/src/vm.rs | 252 +++++++++--- ublue/skillet/crates/vm/src/backend.rs | 17 + ublue/skillet/crates/vm/src/backend/tests.rs | 19 + ublue/skillet/crates/vm/src/lib.rs | 9 + ublue/skillet/crates/vm/src/manifest.rs | 15 +- ublue/skillet/crates/vm/src/manifest/tests.rs | 3 + ublue/skillet/crates/vm/src/provisioning.rs | 148 ++++++- .../crates/vm/src/provisioning_tests.rs | 46 ++- ublue/skillet/crates/vm/src/runtime.rs | 371 ++++++++++++++++++ ublue/skillet/crates/vm/src/runtime_tests.rs | 32 ++ 15 files changed, 899 insertions(+), 539 deletions(-) delete mode 100755 ublue/butane/bin/coreos-install create mode 100644 ublue/skillet/crates/vm/src/runtime.rs create mode 100644 ublue/skillet/crates/vm/src/runtime_tests.rs diff --git a/ublue/butane/bin/butane b/ublue/butane/bin/butane index c1a046c0..b773c617 100755 --- a/ublue/butane/bin/butane +++ b/ublue/butane/bin/butane @@ -45,7 +45,7 @@ if [[ -d "$(dirname "$input")/files" ]]; then fi # Host configs include the generic CLI and a host-specific credential CLI. -# coreos-install stages freshly built artifacts for VM-only Ignition; physical +# VM provisioning stages freshly built artifacts for VM-only Ignition; physical # host compilation builds them here when callers have not staged binaries. if rg -q -F 'includes/skillet.bu' "$input"; then host="$(basename "$input" .bu)" diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install deleted file mode 100755 index b719707d..00000000 --- a/ublue/butane/bin/coreos-install +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" -butane_dir="$(realpath "$script_dir/..")" -repo_dir="$(realpath "$butane_dir/..")" - -usage() { - cat <= 2)) || { usage >&2; exit 2; } - case "$1" in - --name) vm_name="$2" ;; - --output-dir) output_dir="$2" ;; - --ssh-port) ssh_port="$2" ;; - --artifact) artifact="$2"; custom_artifact=true ;; - --image) image="$2" ;; - --libvirt-uri) libvirt_uri="$2" ;; - --uuid) vm_uuid="$2" ;; - esac - shift 2 ;; - --resume-prepared) resume_prepared=true; shift ;; - -h|--help) usage; exit 0 ;; - --) shift; break ;; - -*) usage >&2; exit 2 ;; - *) break ;; - esac -done -(($# == 1)) && [[ "$1" == *.bu && -f "$1" ]] || { usage >&2; exit 2; } -input="$(realpath "$1")" -host="$(basename "$input" .bu)" -[[ "$host" =~ ^[a-z][a-z0-9-]*$ && "$input" == "$butane_dir/$host.bu" ]] || { echo "Use a host Butane file from $butane_dir" >&2; exit 2; } -vm_name="${vm_name:-$host}" -[[ "$vm_name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] || { echo "Invalid VM name" >&2; exit 2; } -[[ "$vm_name" == "$host-test-"* ]] || { - echo "VM name must match the recorded host-test-instance identity" >&2; exit 2; -} -instance="${vm_name#"$host-test-"}" -if [[ ! "$ssh_port" =~ ^[0-9]+$ ]] || ((ssh_port < 1024 || ssh_port > 65535)); then - echo "Invalid SSH port" >&2 - exit 2 -fi -output_dir="$(realpath -m "${output_dir:-$butane_dir/runs/$vm_name}")" -if [[ "$resume_prepared" == true ]]; then - [[ "$output_dir" == "$butane_dir/runs/$vm_name" && -f "$output_dir/vm.json" ]] || { - echo "Prepared VM output must be its recorded run directory" >&2; exit 1; - } - [[ "$vm_uuid" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] || { - echo "A valid recorded UUID is required for prepared VM creation" >&2; exit 2; - } -else - [[ -z "$vm_uuid" ]] || { echo "--uuid requires --resume-prepared" >&2; exit 2; } -fi -vm_command virsh -c "$libvirt_uri" uri >/dev/null || { echo "Cannot access libvirt URI $libvirt_uri" >&2; exit 1; } -if [[ "${TEST_VM_BACKEND:-native}" == native ]]; then - command -v passt >/dev/null || { echo "passt is required for loopback SSH forwarding" >&2; exit 1; } -fi -if vm_command virsh -c "$libvirt_uri" dominfo "$vm_name" &>/dev/null; then - echo "Refusing existing libvirt domain $vm_name on $libvirt_uri" >&2 - exit 1 -fi -if [[ -e "$output_dir" && "$resume_prepared" != true || -L "$output_dir" ]]; then - echo "Refusing existing output path $output_dir" >&2 - exit 1 -fi -if [[ -z "$artifact" ]]; then - artifact_target_dir="${CARGO_TARGET_DIR:-$repo_dir/skillet/target}" - if [[ "$artifact_target_dir" != /* ]]; then - artifact_target_dir="$repo_dir/skillet/$artifact_target_dir" - fi - artifact="$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet-$host" - generic_artifact="$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet" - (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p skillet -p "skillet-$host") -fi -[[ -f "$artifact" ]] || { echo "Missing Skillet artifact: $artifact" >&2; exit 1; } -artifact="$(realpath "$artifact")" -if [[ -z "$generic_artifact" ]]; then - artifact_target_dir="${CARGO_TARGET_DIR:-$repo_dir/skillet/target}" - if [[ "$artifact_target_dir" != /* ]]; then artifact_target_dir="$repo_dir/skillet/$artifact_target_dir"; fi - generic_artifact="$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet" -fi -if [[ "$custom_artifact" == true || ! -f "$generic_artifact" ]]; then - (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p skillet) -fi -[[ -f "$generic_artifact" ]] || { echo "Missing generic Skillet artifact: $generic_artifact" >&2; exit 1; } -generic_artifact="$(realpath "$generic_artifact")" -if [[ -z "$image" ]]; then - mapfile -t images < <(find "$butane_dir/images" -maxdepth 1 -name 'fedora-coreos-*-qemu.x86_64.qcow2' -type f 2>/dev/null | sort) - if ((${#images[@]} == 0)); then - mkdir -p "$butane_dir/images" - podman run --pull=always --rm --security-opt label=disable \ - -v "$butane_dir/images:/data" -w /data \ - quay.io/coreos/coreos-installer:release \ - download -s stable -p qemu -f qcow2.xz --decompress - mapfile -t images < <(find "$butane_dir/images" -maxdepth 1 -name 'fedora-coreos-*-qemu.x86_64.qcow2' -type f | sort) - fi - ((${#images[@]} == 1)) || { echo "Specify --image: found ${#images[@]} candidate FCOS images" >&2; exit 1; } - image="${images[0]}" -fi -[[ -f "$image" ]] || { echo "Missing FCOS image: $image" >&2; exit 1; } -image="$(realpath "$image")" -instance="${vm_name#"$host-test-"}" -skillet_cli test vm prepare-local "$host" "$instance" --image "$image" >/dev/null -skillet_cli test vm stage-local "$host" "$instance" \ - --image "$image" --host-binary "$artifact" --generic-binary "$generic_artifact" -# Keep the multi-megabyte static binary out of QEMU fw_cfg. Ignition reads -# fw_cfg very slowly for large configs; test-vm-ready installs the artifacts -# over SSH after the guest has completed its first Ignition boot. -ignition="$("$script_dir/butane" -s -o "$output_dir/ignition" "$output_dir/source/$host.bu" "$host.ign")" -ignition_bytes="$(stat -c '%s' "$ignition")" -if ((ignition_bytes > 1048576)); then - echo "Warning: Ignition config is ${ignition_bytes} bytes; large QEMU fw_cfg configs can take a long time to read" >&2 -fi -case "${TEST_VM_BACKEND:-native}" in - native) skillet_cli test vm create-native "$host" "$instance" ;; - flatpak) skillet_cli test vm create-flatpak "$host" "$instance" ;; - *) echo "Unknown VM backend: ${TEST_VM_BACKEND:-native}" >&2; exit 2 ;; -esac diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index 9ab252c4..c0851152 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -1,173 +1,54 @@ #!/usr/bin/env bash set -euo pipefail -# This source-tree helper is reviewable, but must be installed read-only outside -# agent-writable roots before any enduring command-prefix approval is safe. +# Compatibility command surface. VM lifecycle policy and implementation live +# in the Rust CLI; this script only translates the long-standing argument form. script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" -root="$(realpath -m "$script_dir/../runs")" -uri=qemu:///session -runtime_dir="/run/user/$(id -u)/skillet-test-libvirt" -backend= -vm_virsh() { - if [[ "$backend" == flatpak ]]; then - "$script_dir/virsh" -c "$uri" "$@" - else - env XDG_RUNTIME_DIR="$runtime_dir" /usr/bin/virsh -c "$uri" "$@" - fi -} -select_backend() { - if command -v virsh >/dev/null && command -v virtqemud >/dev/null && command -v virtstoraged >/dev/null; then - backend=native - if /usr/bin/virsh -c "$uri" uri >/dev/null 2>&1; then - runtime_dir="/run/user/$(id -u)" - fi - elif flatpak info org.virt_manager.virt-manager >/dev/null 2>&1 && - flatpak info org.virt_manager.virt_manager.Extension.Qemu >/dev/null 2>&1; then - backend=flatpak - runtime_dir="/run/user/$(id -u)/skvm" - export TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" - export PATH="$script_dir:$PATH" - else - echo 'Neither native libvirt nor Flatpak virt-manager is available.' >&2 - exit 1 - fi -} -ensure_runtime() { - if [[ "$backend" == native ]]; then - ensure_native_runtime - else - if ! flatpak info org.virt_manager.virt-manager >/dev/null 2>&1 || - ! flatpak info org.virt_manager.virt_manager.Extension.Qemu >/dev/null 2>&1; then - echo 'Flatpak virt-manager or its QEMU extension is unavailable' >&2; exit 1 - fi - [[ "$runtime_dir" == "/run/user/$(id -u)/skvm" && ! -L "$runtime_dir" ]] || { - echo 'Invalid Flatpak libvirt runtime path' >&2; exit 1; - } - install -d -m 0700 "$runtime_dir" - if [[ ! -f "$runtime_dir/flatpak.pid" ]] || ! kill -0 "$(cat "$runtime_dir/flatpak.pid")" 2>/dev/null; then - setsid "$script_dir/flatpak-virt" sh -c \ - 'virtlogd --timeout 0 & virtstoraged --timeout 0 & virtqemud --timeout 0 & wait' \ - > "$runtime_dir/flatpak.log" 2>&1 < /dev/null & - echo "$!" > "$runtime_dir/flatpak.pid" - fi - for ((attempt=0; attempt<20; attempt++)); do - if [[ -S "$runtime_dir/libvirt/virtqemud-sock" && -S "$runtime_dir/libvirt/virtstoraged-sock" ]]; then - break - fi - sleep 1 - done - [[ -S "$runtime_dir/libvirt/virtqemud-sock" && -S "$runtime_dir/libvirt/virtstoraged-sock" ]] || { - echo 'Flatpak libvirt daemons did not start' >&2 - tail -30 "$runtime_dir/flatpak.log" >&2 - exit 1 - } - fi -} -ensure_native_runtime() { - if [[ "$runtime_dir" == "/run/user/$(id -u)" ]]; then - /usr/bin/virsh -c "$uri" uri >/dev/null || { - echo "Cannot access the native user libvirt session at $uri" >&2; exit 1; - } - return - fi - [[ ! -L "$runtime_dir" ]] || { echo "Symlinked libvirt runtime refused" >&2; exit 1; } - if [[ ! -d "$runtime_dir" ]]; then - mkdir -m 0700 "$runtime_dir" - fi - if [[ ! -S "$runtime_dir/libvirt/virtqemud-sock" ]]; then - env XDG_RUNTIME_DIR="$runtime_dir" /usr/bin/virtqemud --daemon --timeout 0 - fi - if [[ ! -S "$runtime_dir/libvirt/virtstoraged-sock" ]]; then - env XDG_RUNTIME_DIR="$runtime_dir" /usr/bin/virtstoraged --daemon --timeout 0 - fi -} +repo_dir="$(realpath "$script_dir/../..")" + usage() { - echo "Usage: $0 [HOST] list | $0 HOST create|ready|update|status|ssh|logs|reboot|destroy INSTANCE [--port PORT for create]" >&2 + echo "Usage: $0 [HOST] list | $0 HOST create|ready|update|status|ssh|logs|reboot|destroy INSTANCE [--port PORT] [--image IMAGE]" >&2 exit 2 } + skillet_cli() { if [[ -n "${SKILLET_BINARY:-}" ]]; then - "$SKILLET_BINARY" "$@" - else - (cd "$script_dir/../../skillet" && cargo run --quiet --release -p skillet -- "$@") + exec "$SKILLET_BINARY" "$@" fi + cd "$repo_dir/skillet" + exec cargo run --quiet --release -p skillet -- "$@" } -host_exists() { - [[ -f "$script_dir/../$1.bu" || -f "$script_dir/../../skillet/crates/hosts/$1/Cargo.toml" ]] -} -host_available() { - [[ -f "$script_dir/../$1.bu" && -f "$script_dir/../../skillet/crates/hosts/$1/Cargo.toml" ]] -} + if (($# == 1)) && [[ "$1" == list ]]; then - cd "$script_dir/../../skillet" - exec cargo run --quiet --release -p skillet -- test vm list + skillet_cli test vm list +fi +if (($# == 2)) && [[ "$2" == list ]]; then + skillet_cli test vm list "$1" fi -(($# >= 2)) || usage +(($# >= 3)) || usage host="$1" operation="$2" -shift 2 -if [[ ! "$host" =~ ^[a-z][a-z0-9-]*$ ]] || - { [[ "$operation" != destroy && "$operation" != status && "$operation" != list && "$operation" != update && "$operation" != ready ]] && ! host_exists "$host"; }; then - echo "Unknown host: $host" >&2 - exit 2 -fi -if [[ "$operation" == list ]]; then - (($# == 0)) || usage - cd "$script_dir/../../skillet" - exec cargo run --quiet --release -p skillet -- test vm list "$host" -fi -(($# >= 1)) || usage -instance="$1" -shift -[[ "$instance" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage -name="$host-test-$instance" -run_dir="$root/$name" -[[ ! -L "$root" && ! -L "$run_dir" ]] || { echo "Symlinked artifact path refused" >&2; exit 1; } -# All retained-run operations use the Rust lifecycle owner. -if [[ "$operation" == destroy || "$operation" == status || "$operation" == update || "$operation" == ready || - "$operation" == reboot || "$operation" == ssh || "$operation" == logs ]]; then - (($# == 0)) || usage - cd "$script_dir/../../skillet" - exec cargo run --quiet --release -p skillet -- test vm "$operation" "$host" "$instance" -fi +instance="$3" +shift 3 + case "$operation" in create) - if (($# == 0)); then - port=2201 - elif (($# == 2)) && [[ "$1" == --port ]]; then - port="$2" - else - usage - fi - [[ "$port" =~ ^22[0-9][0-9]$ ]] || { echo "Use a port from 2200 to 2299" >&2; exit 2; } - host_available "$host" || { echo "Host $host needs both $host.bu and a skillet-$host crate" >&2; exit 1; } - select_backend - if [[ "$backend" == native ]]; then - command -v passt >/dev/null || { echo 'Missing native VM tool: passt' >&2; exit 1; } - fi - if [[ ! -e /dev/kvm ]]; then - echo '/dev/kvm is unavailable; enable hardware virtualization and KVM before creating a VM.' >&2 - exit 1 - fi - ensure_runtime - mkdir -p "$root" - if vm_virsh dominfo "$name" &>/dev/null && [[ ! -f "$run_dir/vm.json" ]]; then - echo "Domain already exists without a recorded creation intent; refusing adoption" >&2 - exit 1 - fi - if vm_virsh dominfo "$name" &>/dev/null; then - if [[ "$backend" == native ]]; then - skillet_cli test vm create-native "$host" "$instance" - else - skillet_cli test vm create-flatpak "$host" "$instance" - fi - echo "Resumed recorded VM $name" - exit 0 - fi - prepare_args=(test vm prepare "$host" "$instance" --backend "$backend" --runtime-dir "$runtime_dir" --uri "$uri" --port "$port") - uuid="$(skillet_cli "${prepare_args[@]}")" - TEST_VM_BACKEND="$backend" TEST_VM_LIBVIRT_RUNTIME_DIR="$runtime_dir" "$script_dir/coreos-install" --name "$name" --output-dir "$run_dir" \ - --ssh-port "$port" --libvirt-uri "$uri" --uuid "$uuid" --resume-prepared "$script_dir/../$host.bu" - echo "Run: $run_dir" ;; + args=(test vm create "$host" "$instance") + while (($#)); do + case "$1" in + --port|--image) + (($# >= 2)) || usage + args+=("$1" "$2") + shift 2 + ;; + *) usage ;; + esac + done + skillet_cli "${args[@]}" + ;; + ready|update|status|ssh|logs|reboot|destroy) + (($# == 0)) || usage + skillet_cli test vm "$operation" "$host" "$instance" + ;; *) usage ;; esac diff --git a/ublue/butane/clamps.bu b/ublue/butane/clamps.bu index 0e24042d..79a83e4e 100644 --- a/ublue/butane/clamps.bu +++ b/ublue/butane/clamps.bu @@ -1,6 +1,6 @@ variant: fcos version: 1.6.0 -# VM-only layout: coreos-install attaches the FCOS disk as virtio /dev/vda. +# VM-only layout: the Skillet VM provisioner attaches FCOS as virtio /dev/vda. # A physical installer must use an explicitly selected hardware disk instead. ignition: config: diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index b3a6add6..028d95ae 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,11 +1,7 @@ use anyhow::{anyhow, Context, Result}; use clap::Parser; use skillet_cli_common::hosts::ApplyPhase; -use std::{ - fs, - path::{Path, PathBuf}, - process::Command, -}; +use std::{fs, path::PathBuf, process::Command}; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; @@ -121,21 +117,6 @@ enum TestCommands { enum VmCommands { /// Provision a disposable host VM and wait for it to become ready Create(VmCreateArgs), - /// Persist a recoverable VM creation intent for the source-tree helper - #[command(hide = true)] - Prepare(VmPrepareArgs), - /// Prepare local image and SSH key artifacts for the source-tree helper - #[command(hide = true)] - PrepareLocal(VmPrepareLocalArgs), - /// Stage VM Butane inputs and captured binaries for the source-tree helper - #[command(hide = true)] - StageLocal(VmStageLocalArgs), - /// Define and start an owned native VM from staged artifacts - #[command(hide = true)] - CreateNative(VmTargetArgs), - /// Define and start an owned Flatpak-backed VM from staged artifacts - #[command(hide = true)] - CreateFlatpak(VmTargetArgs), /// Destroy a disposable host VM and remove its temporary key and artifacts Destroy(VmDestroyArgs), /// List available host templates and their recorded disposable VMs @@ -165,46 +146,9 @@ struct VmCreateArgs { instance: String, #[arg(long, default_value_t = 2201)] port: u16, -} - -#[derive(clap::Args, Debug)] -struct VmPrepareArgs { - hostname: String, - instance: String, - #[arg(long, value_enum)] - backend: VmBackendName, - #[arg(long)] - runtime_dir: PathBuf, - #[arg(long, default_value = "qemu:///session")] - uri: String, - #[arg(long, default_value_t = 2201)] - port: u16, -} - -#[derive(clap::Args, Debug)] -struct VmPrepareLocalArgs { - hostname: String, - instance: String, + /// Use a specific Fedora `CoreOS` QEMU image instead of selecting the cached image #[arg(long)] - image: PathBuf, -} - -#[derive(clap::Args, Debug)] -struct VmStageLocalArgs { - hostname: String, - instance: String, - #[arg(long)] - image: PathBuf, - #[arg(long)] - host_binary: PathBuf, - #[arg(long)] - generic_binary: PathBuf, -} - -#[derive(Clone, Copy, Debug, clap::ValueEnum)] -enum VmBackendName { - Native, - Flatpak, + image: Option, } #[derive(clap::Args, Debug)] @@ -332,11 +276,6 @@ fn main() -> Result<()> { fn run_vm_command(command: VmCommands) -> Result<()> { match command { VmCommands::Create(args) => run_vm_create(&args)?, - VmCommands::Prepare(args) => run_vm_prepare(&args)?, - VmCommands::PrepareLocal(args) => vm::prepare_local(&args)?, - VmCommands::StageLocal(args) => vm::stage_local(&args)?, - VmCommands::CreateNative(args) => vm::create_native(&args)?, - VmCommands::CreateFlatpak(args) => vm::create_flatpak(&args)?, VmCommands::Destroy(args) => run_vm_destroy(&args)?, VmCommands::List(args) => run_vm_list(&args)?, VmCommands::Status(args) => vm::status(&args)?, @@ -395,71 +334,7 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { } fn run_vm_create(args: &VmCreateArgs) -> Result<()> { - let name = vm_name(&args.hostname, &args.instance)?; - if !(2200..=2299).contains(&args.port) { - return Err(anyhow!("VM SSH port must be between 2200 and 2299")); - } - let butane = butane_root()?; - let helper = butane.join("bin/test-vm"); - let port = args.port.to_string(); - let binary = std::env::current_exe().context("locating the running Skillet binary failed")?; - run_helper_with_binary( - &helper, - &[&args.hostname, "create", &args.instance, "--port", &port], - &binary, - )?; - - let run_dir = butane.join("runs").join(&name); - if let Err(error) = vm::ready(&VmTargetArgs { - hostname: args.hostname.clone(), - instance: args.instance.clone(), - }) { - return Err(anyhow!( - "VM created but readiness failed; inspect it with `test-vm {} logs {}` or destroy it with `skillet test vm destroy {} {}`: {error}", - args.hostname, args.instance, args.hostname, args.instance - )); - } - info!( - "Disposable VM {} is ready at giacomo@127.0.0.1:{}; smoke key: {}", - name, - args.port, - run_dir.join("ssh/id_ed25519").display() - ); - Ok(()) -} - -fn run_vm_prepare(args: &VmPrepareArgs) -> Result<()> { - let identity = skillet_vm::RunIdentity::new(&args.hostname, &args.instance)?; - let root = butane_root()?.join("runs"); - let source_commit = Command::new("git") - .args(["-C"]) - .arg(workspace_root()?) - .args(["rev-parse", "--verify", "HEAD"]) - .output() - .context("reading the source revision for VM creation failed")?; - if !source_commit.status.success() { - return Err(anyhow!("git could not identify the source revision")); - } - let source_commit = String::from_utf8(source_commit.stdout) - .context("git returned a non-UTF-8 source revision")? - .trim() - .to_owned(); - let backend = match args.backend { - VmBackendName::Native => skillet_vm::Backend::Native, - VmBackendName::Flatpak => skillet_vm::Backend::Flatpak, - }; - let run = skillet_vm::ManifestStore::new(&root, skillet_vm::current_uid())?.prepare_intent( - &identity, - skillet_vm::Connection { - backend, - uri: args.uri.clone(), - runtime_dir: args.runtime_dir.clone(), - }, - args.port, - &source_commit, - )?; - println!("{}", run.uuid); - Ok(()) + vm::create(args) } fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { @@ -478,18 +353,6 @@ fn vm_name(hostname: &str, instance: &str) -> Result { Ok(skillet_vm::RunIdentity::new(hostname, instance)?.domain_name()) } -fn run_helper_with_binary(path: &Path, args: &[&str], binary: &Path) -> Result<()> { - let status = Command::new(path) - .env("SKILLET_BINARY", binary) - .args(args) - .status() - .with_context(|| format!("running {} failed", path.display()))?; - if !status.success() { - return Err(anyhow!("{} failed with status {status}", path.display())); - } - Ok(()) -} - fn butane_root() -> Result { let root = workspace_root()?; let butane = root @@ -700,12 +563,12 @@ mod tests { } #[test] - fn vm_local_preparation_parses_profile_instance_and_image() { + fn vm_create_parses_identity_and_optional_image() { let parsed = Args::try_parse_from([ "skillet", "test", "vm", - "prepare-local", + "create", "clamps", "smoke", "--image", @@ -713,37 +576,4 @@ mod tests { ]); assert!(parsed.is_ok()); } - - #[test] - fn vm_local_staging_parses_explicit_artifacts() { - let parsed = Args::try_parse_from([ - "skillet", - "test", - "vm", - "stage-local", - "clamps", - "smoke", - "--image", - "/tmp/fcos.qcow2", - "--host-binary", - "/tmp/skillet-clamps", - "--generic-binary", - "/tmp/skillet", - ]); - assert!(parsed.is_ok()); - } - - #[test] - fn native_vm_lifecycle_command_parses_identity_fields() { - let parsed = - Args::try_parse_from(["skillet", "test", "vm", "create-native", "clamps", "smoke"]); - assert!(parsed.is_ok()); - } - - #[test] - fn flatpak_vm_lifecycle_command_parses_identity_fields() { - let parsed = - Args::try_parse_from(["skillet", "test", "vm", "create-flatpak", "clamps", "smoke"]); - assert!(parsed.is_ok()); - } } diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index a4a54e8a..9b852428 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -1,15 +1,16 @@ //! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. use super::{ - butane_root, secret_delivery, workspace_root, VmDestroyArgs, VmDirectoryArgs, VmListArgs, - VmPrepareLocalArgs, VmStageLocalArgs, VmTargetArgs, + butane_root, secret_delivery, workspace_root, VmCreateArgs, VmDestroyArgs, VmDirectoryArgs, + VmListArgs, VmTargetArgs, }; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use skillet_vm::{ backend::{DomainSnapshot, FlatpakVirtInstall, VirshBackend, VmBackend}, transport::{GuestCommand, GuestTransport, HostKeyPolicy, SshTransport}, ManifestStore, Phase, RunIdentity, VmRun, }; use std::io::Write as _; +use std::process::Command; pub(super) fn status(args: &VmTargetArgs) -> Result<()> { let butane = butane_root()?; @@ -34,72 +35,219 @@ pub(super) fn status(args: &VmTargetArgs) -> Result<()> { Ok(()) } -pub(super) fn prepare_local(args: &VmPrepareLocalArgs) -> Result<()> { +pub(super) fn create(args: &VmCreateArgs) -> Result<()> { let butane = butane_root()?; + let workspace = workspace_root()?; let identity = RunIdentity::new(&args.hostname, &args.instance)?; + if !butane.join(format!("{}.bu", identity.host())).is_file() + || !workspace + .join("crates/hosts") + .join(identity.host()) + .join("Cargo.toml") + .is_file() + { + return Err(anyhow!( + "host {} needs both its Butane configuration and host crate", + identity.host() + )); + } + let source_commit = skillet_vm::provisioning::source_revision(&workspace)?; let store = ManifestStore::new(&butane.join("runs"), current_uid())?; - let public_key = - skillet_vm::provisioning::prepare_local_artifacts(&store, &identity, &args.image)?; - println!("{public_key}"); + let run_dir = store.run_dir(&identity); + let _prepared_run = if run_dir.exists() { + let run = store.load(&identity)?; + if run.ssh.port != args.port { + return Err(anyhow!( + "recorded VM uses SSH port {}; requested port is {}", + run.ssh.port, + args.port + )); + } + if matches!(run.phase, Phase::Preparing | Phase::Defined) + && run.source_commit != source_commit + { + return Err(anyhow!( + "unfinished VM was prepared from a different source revision; inspect or dispose it before retrying" + )); + } + skillet_vm::runtime::ensure_connection(&butane, current_uid(), &run.connection)?; + run + } else { + skillet_vm::provisioning::validate_ssh_port(args.port)?; + let runtime = skillet_vm::runtime::prepare(&butane, current_uid())?; + let backend = VirshBackend::new( + runtime.connection.clone(), + current_uid(), + &runtime.virsh_wrapper, + skillet_vm::backend::ProcessExecutor::default(), + )?; + if backend.contains_name(&identity.domain_name())? { + return Err(anyhow!( + "refusing unrecorded existing libvirt domain {}", + identity.domain_name() + )); + } + store.prepare_intent(&identity, runtime.connection, args.port, &source_commit)? + }; + let _create_lock = store.lock_create(&identity)?; + let run = store.load(&identity)?; + if matches!(run.phase, Phase::Preparing | Phase::Defined) && run.source_commit != source_commit + { + return Err(anyhow!( + "unfinished VM was prepared from a different source revision; inspect or dispose it before retrying" + )); + } + skillet_vm::runtime::validate_kvm_access()?; + if run.phase == Phase::Preparing { + skillet_vm::provisioning::validate_ssh_port(run.ssh.port)?; + } + + if run.phase == Phase::Preparing { + prepare_guest_artifacts( + &butane, + &workspace, + &store, + &identity, + args.image.as_deref(), + )?; + } else if args.image.is_some() { + return Err(anyhow!( + "--image can only be selected for a new or preparing VM" + )); + } + + create_recorded_domain(&butane, &store, &identity)?; + if let Err(error) = ready(&VmTargetArgs { + hostname: args.hostname.clone(), + instance: args.instance.clone(), + }) { + return Err(anyhow!( + "VM was started but readiness failed; inspect it with `test-vm {} logs {}` or destroy it with `skillet test vm destroy {} {}`: {error}", + args.hostname, args.instance, args.hostname, args.instance + )); + } + println!( + "Ready: {} at giacomo@127.0.0.1:{}; SSH key: {}", + identity.domain_name(), + args.port, + run.ssh.identity.display() + ); Ok(()) } -pub(super) fn stage_local(args: &VmStageLocalArgs) -> Result<()> { - let butane = butane_root()?; - let identity = RunIdentity::new(&args.hostname, &args.instance)?; - let store = ManifestStore::new(&butane.join("runs"), current_uid())?; - let run = store.load(&identity)?; +fn prepare_guest_artifacts( + butane: &std::path::Path, + workspace: &std::path::Path, + store: &ManifestStore, + identity: &RunIdentity, + requested_image: Option<&std::path::Path>, +) -> Result<()> { + let host_package = format!("skillet-{}", identity.host()); + let packages = ["skillet", host_package.as_str()]; + let artifacts = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace, + packages: &packages, + target: "x86_64-unknown-linux-musl", + profile: "release", + })?; + let host_binary = artifacts + .get(&host_package) + .ok_or_else(|| anyhow!("Cargo did not report {host_package}"))?; + let generic_binary = artifacts + .get("skillet") + .ok_or_else(|| anyhow!("Cargo did not report skillet"))?; + let image = + skillet_vm::provisioning::resolve_coreos_image(&butane.join("images"), requested_image)?; + skillet_vm::provisioning::prepare_local_artifacts(store, identity, &image)?; + let run = store.load(identity)?; skillet_vm::staging::stage_butane_source( - &store, + store, &run, &butane.join(format!("{}.bu", identity.host())), &butane.join("includes"), - &args.host_binary, - &args.generic_binary, - &args.image, + host_binary, + generic_binary, + &image, )?; - Ok(()) + compile_butane(butane, &store.run_dir(identity), identity) } -pub(super) fn create_native(args: &VmTargetArgs) -> Result<()> { - let butane = butane_root()?; - let identity = RunIdentity::new(&args.hostname, &args.instance)?; - let store = ManifestStore::new(&butane.join("runs"), current_uid())?; - let run = store.load(&identity)?; - let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; - let emulator = backend.x86_64_emulator(&run)?; - let versions = format!( - "Native VM launcher: virsh XML\n{}{}{}", - backend.version(&run)?, - skillet_vm::capture_version("podman")?, - skillet_vm::capture_version("yq")?, - ); - let run = - skillet_vm::creation::create_native(&store, &identity, &backend, &emulator, &versions)?; - println!("Started {} ({})", identity.domain_name(), run.uuid); +fn compile_butane( + butane: &std::path::Path, + run_dir: &std::path::Path, + identity: &RunIdentity, +) -> Result<()> { + let compiler = butane.join("bin/butane"); + let source = run_dir + .join("source") + .join(format!("{}.bu", identity.host())); + let output_dir = run_dir.join("ignition"); + let mut command = Command::new(&compiler); + command + .args(["-s", "-o"]) + .arg(&output_dir) + .arg(source) + .arg(format!("{}.ign", identity.host())); + let output = skillet_vm::capture_command(command, std::time::Duration::from_mins(30)) + .with_context(|| { + format!( + "starting the Butane compiler at {} failed", + compiler.display() + ) + })?; + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + if !output.status.success() { + return Err(anyhow!( + "Butane compilation failed with status {:?}", + output.status.code() + )); + } + let run_ignition = run_dir + .join("ignition") + .join(format!("{}.ign", identity.host())); + if !run_ignition.is_file() { + return Err(anyhow!("Butane did not produce {}", run_ignition.display())); + } Ok(()) } -pub(super) fn create_flatpak(args: &VmTargetArgs) -> Result<()> { - let butane = butane_root()?; - let identity = RunIdentity::new(&args.hostname, &args.instance)?; - let store = ManifestStore::new(&butane.join("runs"), current_uid())?; - let run = store.load(&identity)?; +fn create_recorded_domain( + butane: &std::path::Path, + store: &ManifestStore, + identity: &RunIdentity, +) -> Result { + let run = store.load(identity)?; let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; - let creator = FlatpakVirtInstall::for_run(&run, &butane.join("bin/virt-install"))?; - let versions = format!( - "Flatpak VM launcher: virt-install\n{}{}{}{}", - creator.version()?, - backend.version(&run)?, - skillet_vm::capture_version("podman")?, - skillet_vm::capture_version("yq")?, - ); - let run = - skillet_vm::creation::create_flatpak(&store, &identity, &backend, &versions, |run| { - creator.define(run) - })?; - println!("Started {} ({})", identity.domain_name(), run.uuid); - Ok(()) + let podman_version = skillet_vm::capture_version("podman")?; + let yq_version = skillet_vm::capture_version("yq")?; + match run.connection.backend { + skillet_vm::Backend::Native => { + let emulator = backend.x86_64_emulator(&run)?; + let versions = format!( + "Native VM launcher: virsh XML\n{}{}{}", + backend.version(&run)?, + podman_version, + yq_version + ); + skillet_vm::creation::create_native(store, identity, &backend, &emulator, &versions) + .map_err(Into::into) + } + skillet_vm::Backend::Flatpak => { + let creator = FlatpakVirtInstall::for_run(&run, &butane.join("bin/virt-install"))?; + let versions = format!( + "Flatpak VM launcher: virt-install\n{}{}{}{}", + creator.version()?, + backend.version(&run)?, + podman_version, + yq_version + ); + skillet_vm::creation::create_flatpak(store, identity, &backend, &versions, |run| { + creator.define(run) + }) + .map_err(Into::into) + } + } } pub(super) fn reboot(args: &VmTargetArgs) -> Result<()> { diff --git a/ublue/skillet/crates/vm/src/backend.rs b/ublue/skillet/crates/vm/src/backend.rs index d27cd99a..832bc4bb 100644 --- a/ublue/skillet/crates/vm/src/backend.rs +++ b/ublue/skillet/crates/vm/src/backend.rs @@ -347,6 +347,23 @@ impl VirshBackend { self.validate_connection(run)?; self.invoke(&["--version"]) } + + pub fn probe(&self) -> Result<()> { + self.invoke(&["uri"])?; + Ok(()) + } + + pub fn contains_name(&self, name: &str) -> Result { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(Error::Invalid("invalid libvirt domain name".into())); + } + let names = self.invoke(&["list", "--all", "--name"])?; + Ok(names.lines().any(|line| line.trim() == name)) + } } impl VmBackend for VirshBackend { diff --git a/ublue/skillet/crates/vm/src/backend/tests.rs b/ublue/skillet/crates/vm/src/backend/tests.rs index 2ecfbd98..125c7a9e 100644 --- a/ublue/skillet/crates/vm/src/backend/tests.rs +++ b/ublue/skillet/crates/vm/src/backend/tests.rs @@ -105,6 +105,25 @@ fn missing_domain_requires_successful_lists_not_an_error_message() { assert!(matches!(backend.inspect(&run), Err(Error::Command { .. }))); } +#[test] +fn domain_name_preflight_requires_a_successful_complete_name_list() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![ + output("another-domain\nfixture-test-retained-2\n"), + output("another-domain\nfixture-test-retained-2\n"), + ]), + ) + .unwrap(); + assert!(backend.contains_name("fixture-test-retained-2").unwrap()); + assert!(!backend.contains_name("fixture-test-other").unwrap()); + assert!(backend.contains_name("bad;name").is_err()); +} + #[test] fn capabilities_select_executable_x86_64_hvm_emulator() { let (_tmp, store, identity) = legacy_run(); diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index 5718b6d4..b38bf94f 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -11,6 +11,7 @@ pub mod manifest; mod process; pub mod provisioning; pub mod readiness; +pub mod runtime; pub mod staging; pub mod transport; @@ -61,6 +62,14 @@ pub type Result = std::result::Result; pub use process::capture_version; +/// Capture a bounded host tool invocation for workstation provisioning. +pub fn capture_command( + command: std::process::Command, + timeout: std::time::Duration, +) -> Result { + process::capture(command, timeout) +} + pub fn current_uid() -> u32 { users::get_current_uid() } diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs index 054ad1cc..9505f17f 100644 --- a/ublue/skillet/crates/vm/src/manifest.rs +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -316,8 +316,19 @@ impl ManifestStore { pub fn lock(&self, identity: &RunIdentity) -> Result { let dir = self.run_dir(identity); self.validate_directory(&dir)?; - let path = dir.join(".vm.lock"); - reject_symlinks(&path)?; + self.lock_path(&dir.join(".vm.lock")) + } + + /// Serialize the complete create orchestration separately from the shorter + /// per-step manifest mutations it invokes. + pub fn lock_create(&self, identity: &RunIdentity) -> Result { + let dir = self.run_dir(identity); + self.validate_directory(&dir)?; + self.lock_path(&dir.join(".vm-create.lock")) + } + + fn lock_path(&self, path: &Path) -> Result { + reject_symlinks(path)?; let file = OpenOptions::new() .create(true) .truncate(false) diff --git a/ublue/skillet/crates/vm/src/manifest/tests.rs b/ublue/skillet/crates/vm/src/manifest/tests.rs index d87183ca..7b9c3a3d 100644 --- a/ublue/skillet/crates/vm/src/manifest/tests.rs +++ b/ublue/skillet/crates/vm/src/manifest/tests.rs @@ -294,6 +294,9 @@ fn shared_run_lock_is_nonblocking_and_refuses_linked_files() { let (tmp, store, identity) = legacy_run(); let first = store.lock(&identity).unwrap(); assert!(matches!(store.lock(&identity), Err(Error::Busy))); + let create = store.lock_create(&identity).unwrap(); + assert!(matches!(store.lock_create(&identity), Err(Error::Busy))); + drop(create); drop(first); let lock_path = store.run_dir(&identity).join(".vm.lock"); fs::remove_file(&lock_path).unwrap(); diff --git a/ublue/skillet/crates/vm/src/provisioning.rs b/ublue/skillet/crates/vm/src/provisioning.rs index 18ed4dc2..aabb5da4 100644 --- a/ublue/skillet/crates/vm/src/provisioning.rs +++ b/ublue/skillet/crates/vm/src/provisioning.rs @@ -2,14 +2,160 @@ use crate::{manifest::reject_symlinks, Error, ManifestStore, Phase, Result, RunIdentity, VmRun}; use sha2::{Digest, Sha256}; use std::{ + ffi::OsString, fs::{self, File, OpenOptions}, io::{Read, Write as _}, + net::TcpListener, os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}, - path::Path, + path::{Path, PathBuf}, process::Command, time::Duration, }; +/// Fail early when another local listener already owns the VM's loopback SSH +/// forwarding port. +pub fn validate_ssh_port(port: u16) -> Result<()> { + if !(2200..=2299).contains(&port) { + return Err(Error::Invalid( + "VM SSH port must be between 2200 and 2299".into(), + )); + } + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).map_err(|error| { + Error::Preparation(format!("VM SSH port {port} is unavailable: {error}")) + })?; + drop(listener); + Ok(()) +} + +/// Resolve one supported Fedora `CoreOS` QEMU image, downloading it through the +/// existing `CoreOS` installer container only when none is already present. +pub fn resolve_coreos_image(images_dir: &Path, requested: Option<&Path>) -> Result { + if let Some(image) = requested { + reject_symlinks(image)?; + if image.is_file() { + return fs::canonicalize(image).map_err(Into::into); + } + return Err(Error::Invalid( + "requested VM image is not a regular file".into(), + )); + } + let mut candidates = coreos_images(images_dir)?; + if candidates.is_empty() { + download_coreos_image(images_dir)?; + candidates = coreos_images(images_dir)?; + } + match candidates.as_slice() { + [image] => fs::canonicalize(image).map_err(Into::into), + [] => Err(Error::Invalid( + "CoreOS installer completed without producing a QEMU image".into(), + )), + _ => Err(Error::Invalid(format!( + "multiple Fedora CoreOS QEMU images exist under {}; pass --image to choose one", + images_dir.display() + ))), + } +} + +fn coreos_images(images_dir: &Path) -> Result> { + reject_symlinks(images_dir)?; + if !images_dir.is_dir() { + return Ok(Vec::new()); + } + let mut candidates = Vec::new(); + for entry in fs::read_dir(images_dir)? { + let path = entry?.path(); + let matches_name = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with("fedora-coreos-") && name.ends_with("-qemu.x86_64.qcow2") + }); + if !matches_name { + continue; + } + reject_symlinks(&path)?; + if fs::metadata(&path)?.is_file() { + candidates.push(path); + } + } + candidates.sort(); + Ok(candidates) +} + +fn download_coreos_image(images_dir: &Path) -> Result<()> { + if !images_dir.is_absolute() + || images_dir + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err(Error::Invalid( + "image directory must be an absolute safe path".into(), + )); + } + reject_symlinks(images_dir)?; + fs::create_dir_all(images_dir)?; + let mount_source = images_dir + .to_str() + .filter(|path| !path.contains(',') && !path.contains('\n')) + .ok_or_else(|| { + Error::Invalid("image directory is not valid for Podman mount syntax".into()) + })?; + let mut command = Command::new("podman"); + command.args([ + "run", + "--pull=always", + "--rm", + "--security-opt", + "label=disable", + "--volume", + ]); + command.arg(format!("{mount_source}:/data")); + command.args([ + "--workdir", + "/data", + "quay.io/coreos/coreos-installer:release", + "download", + "-s", + "stable", + "-p", + "qemu", + "-f", + "qcow2.xz", + "--decompress", + ]); + let output = crate::process::capture(command, Duration::from_mins(30))?; + if !output.status.success() { + return Err(Error::Command { + operation: "download Fedora CoreOS QEMU image".into(), + code: output.status.code(), + }); + } + Ok(()) +} + +/// Return the recorded source revision used to identify a resumable run. +pub fn source_revision(workspace: &Path) -> Result { + let mut command = Command::new("git"); + command.args([OsString::from("-C"), workspace.as_os_str().to_owned()]); + command.args(["rev-parse", "--verify", "HEAD"]); + let output = crate::process::capture(command, Duration::from_secs(15))?; + if !output.status.success() { + return Err(Error::Preparation( + "git could not identify the source revision for VM creation".into(), + )); + } + let revision = String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid("git source revision is not UTF8".into()))? + .trim() + .to_owned(); + if revision.is_empty() || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::Invalid( + "git returned an invalid source revision".into(), + )); + } + Ok(revision) +} + /// Prepare the private SSH key and VM disk for a recorded, not-yet-defined run. /// Repeated calls reuse a matching key and image, and repair a changed disk. pub fn prepare_local_artifacts( diff --git a/ublue/skillet/crates/vm/src/provisioning_tests.rs b/ublue/skillet/crates/vm/src/provisioning_tests.rs index 9ac70bf5..cbea17aa 100644 --- a/ublue/skillet/crates/vm/src/provisioning_tests.rs +++ b/ublue/skillet/crates/vm/src/provisioning_tests.rs @@ -1,6 +1,50 @@ use super::*; use crate::{Backend, Connection}; -use std::{fs, os::unix::fs::symlink, path::PathBuf}; +use std::{fs, net::TcpListener, os::unix::fs::symlink, path::PathBuf}; + +#[test] +fn ssh_forward_port_preflight_rejects_invalid_and_occupied_ports() { + assert!(validate_ssh_port(2199).is_err()); + assert!(validate_ssh_port(2300).is_err()); + let (port, listener) = (2200..=2299) + .find_map(|port| { + TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) + .ok() + .map(|listener| (port, listener)) + }) + .expect("at least one test SSH port should be available"); + assert!(validate_ssh_port(port).is_err()); + drop(listener); +} + +#[test] +fn image_resolution_requires_one_regular_image_or_an_explicit_selection() { + let temp = tempfile::tempdir().unwrap(); + let images = temp.path().join("images"); + fs::create_dir(&images).unwrap(); + let first = images.join("fedora-coreos-44.1-qemu.x86_64.qcow2"); + fs::write(&first, "image").unwrap(); + assert_eq!(resolve_coreos_image(&images, None).unwrap(), first); + + let second = images.join("fedora-coreos-44.2-qemu.x86_64.qcow2"); + fs::write(&second, "image").unwrap(); + assert!(resolve_coreos_image(&images, None).is_err()); + assert_eq!( + resolve_coreos_image(&images, Some(&second)).unwrap(), + second + ); +} + +#[test] +fn image_resolution_rejects_a_matching_symlink() { + let temp = tempfile::tempdir().unwrap(); + let images = temp.path().join("images"); + fs::create_dir(&images).unwrap(); + let target = temp.path().join("image.qcow2"); + fs::write(&target, "image").unwrap(); + symlink(&target, images.join("fedora-coreos-44.1-qemu.x86_64.qcow2")).unwrap(); + assert!(resolve_coreos_image(&images, None).is_err()); +} fn preparing_run() -> (tempfile::TempDir, ManifestStore, RunIdentity, PathBuf) { let temp = tempfile::tempdir().unwrap(); diff --git a/ublue/skillet/crates/vm/src/runtime.rs b/ublue/skillet/crates/vm/src/runtime.rs new file mode 100644 index 00000000..67bf9950 --- /dev/null +++ b/ublue/skillet/crates/vm/src/runtime.rs @@ -0,0 +1,371 @@ +//! Workstation-side selection and startup of the local libvirt runtime. +use crate::{ + backend::{ProcessExecutor, VirshBackend}, + Backend, Connection, Error, Result, +}; +use std::{ + env, fs, + fs::OpenOptions, + os::unix::{ + fs::{FileTypeExt as _, MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}, + process::CommandExt as _, + }, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +const URI: &str = "qemu:///session"; +const READY_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug)] +pub struct SelectedRuntime { + pub connection: Connection, + pub virsh_wrapper: PathBuf, + pub virt_install_wrapper: PathBuf, +} + +/// Select a supported backend and make its user-session daemons available. +/// Native libvirt is preferred when its focused tools are present; otherwise +/// use the already installed Flatpak virt-manager and its QEMU extension. +pub fn prepare(butane_dir: &Path, owner_uid: u32) -> Result { + validate_kvm_access()?; + let user_runtime = standard_runtime_dir(owner_uid); + validate_user_runtime_dir(&user_runtime, owner_uid)?; + let virsh_wrapper = butane_dir.join("bin/virsh"); + let virt_install_wrapper = butane_dir.join("bin/virt-install"); + if native_tools_available() { + let standard = native_connection(standard_runtime_dir(owner_uid)); + if ensure_connection(butane_dir, owner_uid, &standard).is_ok() { + return Ok(SelectedRuntime { + connection: standard, + virsh_wrapper, + virt_install_wrapper, + }); + } + + let isolated_dir = standard_runtime_dir(owner_uid).join("skillet-test-libvirt"); + let isolated = native_connection(isolated_dir); + ensure_connection(butane_dir, owner_uid, &isolated)?; + return Ok(SelectedRuntime { + connection: isolated, + virsh_wrapper, + virt_install_wrapper, + }); + } + + let runtime_dir = standard_runtime_dir(owner_uid).join("skvm"); + let connection = Connection { + backend: Backend::Flatpak, + uri: URI.into(), + runtime_dir: runtime_dir.clone(), + }; + ensure_connection(butane_dir, owner_uid, &connection)?; + Ok(SelectedRuntime { + connection, + virsh_wrapper, + virt_install_wrapper, + }) +} + +/// Check the same host virtualization prerequisite before image downloads or +/// artifact builds begin. +pub fn validate_kvm_access() -> Result<()> { + OpenOptions::new() + .read(true) + .write(true) + .open("/dev/kvm") + .map_err(|error| Error::Invalid(format!("/dev/kvm is unavailable: {error}")))?; + Ok(()) +} + +/// Start and verify the recorded backend for an existing run without changing +/// its selected URI or runtime path. +pub fn ensure_connection(butane_dir: &Path, owner_uid: u32, connection: &Connection) -> Result<()> { + validate_user_runtime_dir(&standard_runtime_dir(owner_uid), owner_uid)?; + connection.validate(owner_uid)?; + let virsh_wrapper = butane_dir.join("bin/virsh"); + let backend = VirshBackend::new( + connection.clone(), + owner_uid, + &virsh_wrapper, + ProcessExecutor::default(), + )?; + match connection.backend { + Backend::Native => { + if !native_tools_available() { + return Err(Error::Invalid( + "native libvirt tools are unavailable".into(), + )); + } + let standard = standard_runtime_dir(owner_uid); + let isolated = standard.join("skillet-test-libvirt"); + if connection.runtime_dir == standard { + return backend.probe(); + } + if connection.runtime_dir != isolated { + return Err(Error::Invalid( + "native VM uses an unsupported runtime directory".into(), + )); + } + ensure_private_runtime_dir(&isolated, owner_uid)?; + start_native_daemons(&isolated)?; + backend.probe() + } + Backend::Flatpak => { + ensure_flatpak_installed()?; + let runtime_dir = standard_runtime_dir(owner_uid).join("skvm"); + if connection.runtime_dir != runtime_dir { + return Err(Error::Invalid( + "Flatpak VM uses an unsupported runtime directory".into(), + )); + } + ensure_private_runtime_dir(&runtime_dir, owner_uid)?; + let flatpak_runner = butane_dir.join("bin/flatpak-virt"); + let qemu_socket = runtime_socket(&runtime_dir, "virtqemud-sock"); + let storage_socket = runtime_socket(&runtime_dir, "virtstoraged-sock"); + let qemu_ready = + fs::metadata(qemu_socket).is_ok_and(|metadata| metadata.file_type().is_socket()); + let storage_ready = + fs::metadata(storage_socket).is_ok_and(|metadata| metadata.file_type().is_socket()); + match (qemu_ready, storage_ready) { + (true, true) => backend.probe()?, + (false, false) => { + start_flatpak_daemons(&runtime_dir, &flatpak_runner)?; + backend.probe()?; + } + _ => { + return Err(Error::Invalid( + "Flatpak libvirt runtime has only some expected sockets; inspect its retained log" + .into(), + )); + } + } + Ok(()) + } + } +} + +fn standard_runtime_dir(owner_uid: u32) -> PathBuf { + PathBuf::from(format!("/run/user/{owner_uid}")) +} + +fn native_connection(runtime_dir: PathBuf) -> Connection { + Connection { + backend: Backend::Native, + uri: URI.into(), + runtime_dir, + } +} + +fn native_tools_available() -> bool { + fs::metadata("/usr/bin/virsh") + .is_ok_and(|metadata| metadata.is_file() && metadata.mode() & 0o111 != 0) + && ["virtqemud", "virtstoraged", "passt"] + .iter() + .all(|tool| find_executable(tool).is_some()) +} + +fn find_executable(name: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|directory| directory.join(name)) + .find(|candidate| { + fs::metadata(candidate) + .is_ok_and(|metadata| metadata.is_file() && metadata.mode() & 0o111 != 0) + }) +} + +fn ensure_flatpak_installed() -> Result<()> { + for app in [ + "org.virt_manager.virt-manager", + "org.virt_manager.virt_manager.Extension.Qemu", + ] { + let mut command = Command::new("flatpak"); + command.args(["info", app]); + let output = crate::process::capture(command, Duration::from_secs(20))?; + if !output.status.success() { + return Err(Error::Invalid(format!( + "required Flatpak application is unavailable: {app}" + ))); + } + } + Ok(()) +} + +fn ensure_private_runtime_dir(path: &Path, owner_uid: u32) -> Result<()> { + if !path.is_absolute() + || path + .components() + .any(|part| part == std::path::Component::ParentDir) + { + return Err(Error::Invalid("invalid libvirt runtime directory".into())); + } + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.uid() != owner_uid + { + return Err(Error::Invalid( + "libvirt runtime directory is not a user-owned directory".into(), + )); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Err(error) => return Err(error.into()), + } + Ok(()) +} + +fn validate_user_runtime_dir(path: &Path, owner_uid: u32) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.uid() != owner_uid + || metadata.permissions().mode() & 0o077 != 0 + { + return Err(Error::Invalid( + "user runtime directory is not a private directory owned by this user".into(), + )); + } + Ok(()) +} + +fn start_native_daemons(runtime_dir: &Path) -> Result<()> { + let sockets = [ + runtime_socket(runtime_dir, "virtqemud-sock"), + runtime_socket(runtime_dir, "virtstoraged-sock"), + ]; + let qemu_socket = + fs::metadata(&sockets[0]).is_ok_and(|metadata| metadata.file_type().is_socket()); + let storage_socket = + fs::metadata(&sockets[1]).is_ok_and(|metadata| metadata.file_type().is_socket()); + match (qemu_socket, storage_socket) { + (true, true) => return Ok(()), + (false, false) => (), + _ => { + return Err(Error::Invalid( + "native libvirt runtime has only some expected sockets".into(), + )); + } + } + for daemon in ["virtqemud", "virtstoraged"] { + let program = find_executable(daemon) + .ok_or_else(|| Error::Invalid(format!("required VM tool is unavailable: {daemon}")))?; + let mut command = Command::new(program); + command + .args(["--daemon", "--timeout", "0"]) + .env("XDG_RUNTIME_DIR", runtime_dir); + let output = crate::process::capture(command, Duration::from_secs(20))?; + if !output.status.success() { + return Err(Error::Command { + operation: daemon.into(), + code: output.status.code(), + }); + } + } + let mut no_children: [Child; 0] = []; + wait_for_sockets(&sockets, &mut no_children, runtime_dir) +} + +fn start_flatpak_daemons(runtime_dir: &Path, flatpak_runner: &Path) -> Result<()> { + if !flatpak_runner.is_file() || fs::metadata(flatpak_runner)?.mode() & 0o111 == 0 { + return Err(Error::Invalid( + "Flatpak libvirt wrapper is missing or not executable".into(), + )); + } + let log_path = runtime_dir.join("flatpak.log"); + if fs::symlink_metadata(&log_path).is_ok_and(|metadata| metadata.file_type().is_symlink()) { + return Err(Error::Invalid("Flatpak runtime log is a symlink".into())); + } + let log = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(&log_path)?; + let mut children = Vec::new(); + for daemon in ["virtlogd", "virtstoraged", "virtqemud"] { + let mut command = Command::new(flatpak_runner); + command + .args([daemon, "--timeout", "0"]) + .env("TEST_VM_LIBVIRT_RUNTIME_DIR", runtime_dir) + .stdin(Stdio::null()) + .stdout(Stdio::from(log.try_clone()?)) + .stderr(Stdio::from(log.try_clone()?)) + .process_group(0); + match command.spawn() { + Ok(child) => children.push(child), + Err(error) => { + stop_children(&mut children); + return Err(error.into()); + } + } + } + let sockets = [ + runtime_socket(runtime_dir, "virtqemud-sock"), + runtime_socket(runtime_dir, "virtstoraged-sock"), + ]; + wait_for_sockets(&sockets, &mut children, runtime_dir) +} + +fn wait_for_sockets(sockets: &[PathBuf], children: &mut [Child], log_dir: &Path) -> Result<()> { + let deadline = Instant::now() + READY_TIMEOUT; + loop { + if sockets + .iter() + .all(|path| fs::metadata(path).is_ok_and(|metadata| metadata.file_type().is_socket())) + { + return Ok(()); + } + let mut exited_early = false; + for child in children.iter_mut() { + if let Some(status) = child.try_wait()? { + if !status.success() { + exited_early = true; + } + } + } + if exited_early { + stop_children(children); + return Err(Error::Invalid(format!( + "Flatpak libvirt daemon exited early; inspect {}", + log_dir.join("flatpak.log").display() + ))); + } + if Instant::now() >= deadline { + stop_children(children); + return Err(Error::Timeout); + } + thread::sleep(Duration::from_millis(250)); + } +} + +fn stop_children(children: &mut [Child]) { + for child in children.iter_mut() { + let alive = child.try_wait().ok().flatten().is_none(); + if alive { + if let Ok(process_group) = i32::try_from(child.id()) { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(process_group), + nix::sys::signal::Signal::SIGTERM, + ); + } + let _ = child.kill(); + } + let _ = child.wait(); + } +} + +fn runtime_socket(runtime_dir: &Path, name: &str) -> PathBuf { + runtime_dir.join("libvirt").join(name) +} + +#[cfg(test)] +#[path = "runtime_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/runtime_tests.rs b/ublue/skillet/crates/vm/src/runtime_tests.rs new file mode 100644 index 00000000..f66805cf --- /dev/null +++ b/ublue/skillet/crates/vm/src/runtime_tests.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn runtime_paths_keep_native_and_flatpak_sessions_distinct() { + let uid = 1234; + let standard = standard_runtime_dir(uid); + assert_eq!(standard, PathBuf::from("/run/user/1234")); + assert_eq!( + runtime_socket(&standard.join("skillet-test-libvirt"), "virtqemud-sock"), + PathBuf::from("/run/user/1234/skillet-test-libvirt/libvirt/virtqemud-sock") + ); + assert_eq!( + runtime_socket(&standard.join("skvm"), "virtstoraged-sock"), + PathBuf::from("/run/user/1234/skvm/libvirt/virtstoraged-sock") + ); +} + +#[test] +fn runtime_selection_preserves_the_qemu_session_uri() { + for backend in [Backend::Native, Backend::Flatpak] { + let connection = Connection { + backend, + uri: URI.into(), + runtime_dir: match backend { + Backend::Native => standard_runtime_dir(1234), + Backend::Flatpak => standard_runtime_dir(1234).join("skvm"), + }, + }; + assert!(connection.validate(1234).is_ok()); + assert_eq!(connection.uri, "qemu:///session"); + } +} From 7b8dd0aed37bdec16ac08cc8e8fd4bd8470316a5 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 22:58:28 +0200 Subject: [PATCH 141/252] docs(vm): record Rust lifecycle migration --- ublue/AGENTS.md | 3 ++ ublue/butane/ACCEPTANCE.md | 32 ++++++++++++++++++++++ ublue/butane/README.md | 30 ++++++++++---------- ublue/design/smoke-vms.md | 36 +++++++++++++----------- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 38 ++++++++++---------------- ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/plan/TPM-ENCRYPTED-ROOT.md | 2 +- 7 files changed, 86 insertions(+), 57 deletions(-) diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 157d54c2..01c236e0 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -44,6 +44,9 @@ - Give each resource lifecycle one owner. Every public entry point must use the same ownership validation, recovery, and cleanup policy; transport adapters must not provide a shortcut that bypasses those guarantees. +- Keep shell entry points as narrow argument adapters when Rust owns a + lifecycle. Backend selection, resource inspection, retries, and cleanup + belong to the shared Rust owner rather than being duplicated in wrappers. - Keep host profile, environment, deployment instance, runtime identity, and connection target distinct. Derive conventions in one place and persist the resolved values needed for recovery. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 8529f923..3cf9a769 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -824,3 +824,35 @@ host tool; it still uses normal command approvals. pedantic Clippy passed; formatting, ShellCheck, Bash syntax, and `git diff --check` passed. - No Flatpak VM was created. Live acceptance remains unverified on Bazzite. + +### Rust-owned VM create orchestration, 2026-10-03 + +- `skillet test vm create HOST INSTANCE` now owns the full create path: backend + selection/runtime startup, image resolution, guest binary build, disk/key + preparation, Butane staging/compilation, native or Flatpak define/start, and + readiness. Create holds a dedicated per-run orchestration lock while calling + the lower-level locked operations. Repeated create skips the SSH-port + availability probe once a guest is already running. It preflights KVM and + the forwarding port before expensive preparation. `bin/test-vm` is now a + narrow compatibility argument adapter; the separate Bash `coreos-install` + and hidden preparation/staging/creation CLI commands were removed. +- Passed `cargo fmt --all --check`, offline workspace tests (72 VM crate tests + plus all other workspace suites), and offline workspace all-target Clippy + with warnings denied. Built the static-musl CLI using + `PATH=/opt/x86_64-linux-musl-cross/bin:$PATH` and the configured external + Cargo target directory. `shellcheck -x` and `bash -n` passed for all affected + VM/compiler wrapper scripts. CLI create help and `git diff --check` passed. +- Passed the CI integration command: + ```bash + CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target \ + /home/giacomo/.cache/devmachine-target/x86_64-unknown-linux-musl/debug/skillet \ + test run beezelbot --phase base --image fedora:latest + ``` + Both applies + succeeded and the repeat issued no service restart; the test container was + removed. +- Live VM acceptance was not run because this workstation has no `/dev/kvm` + device (`stat /dev/kvm` reports “No such file or directory”). The recorded + `clamps/smoke` run was reported as unavailable by `test vm list`; it was not + altered. Native/Flatpak create, readiness, reboot, and destroy remain + unverified live. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index e9bfa341..0ab6d9fc 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -79,8 +79,9 @@ hyphens. The helper records `clamps` plus `smoke` as the internal domain crate exist. Instance rows show the libvirt state; `missing`, `mismatch`, or `invalid` identify runs needing inspection. `unavailable` means the recorded runtime could not be queried; it does not establish that a guest is absent. -The Skillet wrapper runs both -create and ready: +Rust owns VM creation, including backend selection and startup. The shell helper +keeps the established command shape and forwards each request to Skillet; the +Rust command is also available directly: ```bash cargo run --release -p skillet -- test vm create clamps smoke @@ -91,25 +92,24 @@ cargo run --release -p skillet -- test vm provision clamps smoke cargo run --release -p skillet -- test vm destroy clamps smoke ``` -`list`, `status`, `ready`, `update`, `destroy`, `reboot`, `ssh` and `logs` use -the Rust ownership implementation; the corresponding `test-vm` commands -delegate to it. Reading status does not +All VM lifecycle commands use the Rust ownership implementation; `bin/test-vm` +is a compatibility argument adapter. Reading status does not import or rewrite a legacy run. Disposal creates a private versioned manifest while retaining original files until cleanup finishes. If the owned guest is already absent, disposal still cleans recorded external resources before removing local artifacts. A stopped/inaccessible libvirt runtime is reported as an error; retry after making its recorded connection available. An enrolled VM may require the normal KeePassXC unlock to remove its provider resources. -Failed cleanup retains recovery metadata. Other helper commands are still -being migrated; see [the roadmap](../plan/SKILLET-REFACTOR.md). - -The helper calls `coreos-install`, which builds the generic `skillet` CLI and -the `skillet-clamps` host binary. The lower-level launcher accepts `--artifact PATH` -to use a specific host binary; the generic CLI is built from the workspace. -Rust prepares the run's disk and SSH key, stages and specializes the Butane -source tree, and records captured binary hashes in `runs/NAME/`. The standalone -Butane compiler builds Ignition; Rust handles native and Flatpak domain -definition and start. `test-vm ready` transfers +Failed cleanup retains recovery metadata. See the +[refactoring roadmap](../plan/SKILLET-REFACTOR.md) for remaining acceptance +and follow-on workstreams. + +Rust builds the generic `skillet` CLI and the selected host binary from Cargo's +reported artifact paths. It prepares the run's disk and SSH key, stages and +specializes the Butane source tree, and records captured binary hashes in +`runs/NAME/`. The standalone Butane compiler builds Ignition; Rust selects and +starts native libvirt or the installed Flatpak backend, then runs readiness. +`test-vm ready` transfers both binaries over SSH after first boot and installs them at `/var/usrlocal/bin/skillet` and `/var/usrlocal/bin/skillet-clamps`. `ready` uses those captured binaries on every run. The shared base unit reads the diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 19b4882c..eb2cc5ec 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -1,8 +1,9 @@ # Disposable smoke VM lifecycle -Decision: keep VM creation, readiness, repeatable smoke checks, and destruction -separate. A retained VM supports inspection and repeated convergence checks; -a fresh VM establishes that bootstrap is reproducible. The existing fixture +Decision: keep create, readiness, repeatable smoke checks, and destruction as +separate commands. Create includes a readiness barrier; the explicit ready +command supports retries. A retained VM supports inspection and repeated +convergence checks; a fresh VM establishes reproducible bootstrap. The fixture tests real systemd/Podman convergence. Pi-hole credential delivery, Tailscale enrollment/device cleanup, and opt-in Cloudflare/Caddy provisioning are implemented. Live staging ACME/HTTPS acceptance passed on 2026-10-02; @@ -24,20 +25,25 @@ libvirt and the existing virt-manager Flatpak support different workstation OSes without installing host packages. The Flatpak runtime grants access to the test artifacts and uses QEMU user networking. -## Planned orchestration migration +## Rust lifecycle ownership -Move VM lifecycle management from Bash to one Rust orchestrator, as recorded -in [the architecture decision](skillet-architecture.md) and -[the migration plan](../plan/REFACTOR-01-VM-LIFECYCLE.md). Preserve public -commands, retained manifests, native/Flatpak support, and guest assertions. -Listing, status, readiness, updates and destruction now delegate to Rust. Updates build +The lifecycle owner and migration order are recorded in +[the architecture decision](skillet-architecture.md) and +[the migration plan](../plan/REFACTOR-01-VM-LIFECYCLE.md). Public commands, +retained manifests, native/Flatpak support, and guest assertions are preserved. +`bin/test-vm` is a thin argument adapter. The separate `coreos-install` +lifecycle script and its hidden CLI bridge commands are retired. Rust selects +and prepares the recorded runtime, builds guest artifacts, resolves/downloads +the FCOS image, prepares and stages the run, invokes the standalone Butane +compiler, defines/starts the guest, and runs readiness. Updates build both binaries, use verified recorded SSH trust, and save deployed hashes after checking installed bytes and metadata. Original capture evidence is retained. Disposal validates identity, cleans external resources, then removes the UUID-addressed domain and local artifacts. It also works with an already-absent guest; connection failures and -partial cleanup preserve journals for retry. A per-run lock prevents concurrent -readiness, updates and disposal. Readiness uses the selected profile's explicit +partial cleanup preserve journals for retry. Per-run locks protect individual +mutations, and a create-level lock serializes the full artifact, compilation, +and boot sequence. Readiness uses the selected profile's explicit boot expectations and captured binaries, and records Ready only after signed boot, unit, SELinux, resolver and user-environment checks. Bounded probes and private diagnostics allow failed checks to be retried. It first persists @@ -48,11 +54,9 @@ includes and binaries, applies VM-only Ignition changes, and records captured hashes and compatibility metadata. The standalone Butane compiler still builds Ignition. The generated UUID is part of native XML and Flatpak creation. Native definition and start are separate manifest phases; failed starts retain the -defined domain for inspection and retry. Native domain XML now renders through -the Rust XML library, preserving disk ownership, fw_cfg and passt networking. -Backend selection, emulator discovery, libvirt define/start, and Flatpak -`virt-install` remain in Bash. Unit/adapter checks pass; live migration -acceptance is deferred. +defined domain for inspection and retry. Native domain XML renders through the +Rust XML library, preserving disk ownership, fw_cfg and passt networking. Unit +and adapter checks pass; live migration acceptance remains deferred. ## States and recovery diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 6861ec70..4dcfc08b 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -37,28 +37,18 @@ Readiness first persists `Started`, invalidating any earlier success; it saves `Ready` and deployed hashes only after acceptance and a final ownership check. Every guest command and upload checks ownership before and after the operation, so a change during a long service start stops subsequent guest work. -Creation now records a Rust-owned Preparing manifest and UUID before invoking -the still-Bash installer; native XML and Flatpak use that UUID. Definition and -start are recorded as separate phases where the backend exposes them. A failed -start keeps its domain and can be validated/restarted through the same create -entry point. Installer staging can resume from a matching Preparing manifest. -Native XML generation now uses `quick-xml`. The VM library also prepares the -local disk atomically, records its source digest, and creates or validates the -per-run SSH key pair; `coreos-install` delegates that operation through a -hidden CLI command. The same library stages and specializes the Butane source -tree and records compatibility metadata and binary hashes. The standalone -Butane compilation still uses Bash. Native and Flatpak -define/start, emulator discovery, UUID bookkeeping and tool-version capture -now run through Rust lifecycle adapters. Credential delivery -has not yet adopted the shared transport. Workstream 2 must consolidate the -interim `boot_policy_for_host` lookup with the canonical capability -declaration. Reboot, interactive SSH and guest diagnostics now use Rust's -ownership-checked backend and SSH transport. The Flatpak `virt-install` -adapter shares creation recovery and manifest updates with native libvirt. -Next: move backend selection, runtime preparation and create orchestration -behind Rust, leaving shell entry points as forwarding wrappers. Disposal and -readiness live acceptance remain -deferred. See +Rust now selects and prepares native or Flatpak libvirt, resolves the FCOS +image, builds its two guest artifacts, prepares the disk/key, stages and +compiles Butane through the standalone compiler, defines/starts the guest, and +runs readiness from one create command. Matching interrupted runs resume from +the manifest. The shell `test-vm` interface only translates arguments; the +independent Bash installer and hidden CLI bridge commands are retired. No +remaining script owns VM creation, reboot, access, inspection, or disposal. +Credential delivery has not yet adopted the shared transport. Workstream 2 +must consolidate the interim `boot_policy_for_host` lookup with the canonical +capability declaration. Native and Flatpak adapter contract tests and +workstation checks remain required; live create/ready/update/reboot/destroy +and retained-run acceptance have not yet passed. See [creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), plus [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). @@ -66,7 +56,7 @@ plus [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-202 ## Read and locate - `../design/{skillet-architecture,smoke-vms,secrets,cloudflare-ui-lifecycle}.md`. -- `../butane/bin/{test-vm,test-vm-ready,coreos-install,butane,flatpak-virt}` and +- `../butane/bin/{test-vm,test-vm-ready,butane,flatpak-virt}` and the `virsh`/`virt-install` wrappers; `../butane/README.md`. - `../skillet/crates/cli/src/main.rs` VM dispatch and `secret_delivery.rs` provisioning, external ownership, cleanup, and VM SSH. @@ -95,7 +85,7 @@ creation inspectable; readiness and target selection remain duplicated. `quick-xml`; Flatpak uses a focused `virt-install` adapter. Rust owns atomic disk preparation, per-run key creation/validation, structured Butane staging, backend definition/start, tool-version capture, and domain UUID - bookkeeping. `coreos-install` invokes the standalone Butane compiler. Build + bookkeeping. Rust invokes the standalone Butane compiler. Build artifacts through a shared Cargo helper. Validate required tools and SSH-port conflicts before costly provisioning. See [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03). diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 7f6f4b8b..f698dcfe 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -20,7 +20,7 @@ assignments to execute concurrently against the same interfaces. | Order | Workstream | Depends on | Status | | --- | --- | --- | --- | -| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | In progress: manifests, list/status/disposal/update/readiness, Cargo artifacts | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Planned | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Planned | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md index ffd7ced1..c7118817 100644 --- a/ublue/plan/TPM-ENCRYPTED-ROOT.md +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -25,7 +25,7 @@ slot. Existing unencrypted development VMs may retain their explicit profile. Read `AGENTS.md`, `skillet/AGENTS.md`, `design/storage.md`, `design/secrets.md`, `design/smoke-vms.md`, `butane/clamps.bu`, `butane/includes/data-storage.bu`, -`butane/bin/{coreos-install,test-vm,test-vm-ready}`, and the ucore-images recipes. +`butane/bin/{test-vm,test-vm-ready,butane}`, and the ucore-images recipes. Make small validated changes in the order below. Never format a populated physical disk as part of Skillet convergence; use only named disposable disks until physical install and restore are explicitly arranged. From c031aee66d063981d0e131698622388347605d3e Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 3 Oct 2026 23:47:37 +0200 Subject: [PATCH 142/252] refactor(hosts): centralize host profiles --- ublue/butane/ACCEPTANCE.md | 21 + ublue/design/skillet-architecture.md | 12 +- ublue/plan/REFACTOR-02-HOST-PROFILES.md | 17 +- ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/skillet/AGENTS.md | 2 + ublue/skillet/Cargo.lock | 30 +- ublue/skillet/Cargo.toml | 2 + ublue/skillet/crates/cli-common/Cargo.toml | 10 +- ublue/skillet/crates/cli-common/src/hosts.rs | 414 +----------------- .../crates/cli-common/src/hosts_tests.rs | 61 --- ublue/skillet/crates/cli/Cargo.toml | 7 + ublue/skillet/crates/cli/src/main.rs | 113 ++++- .../skillet/crates/cli/src/secret_delivery.rs | 70 +-- .../crates/cli/src/secret_delivery/tests.rs | 11 +- .../fixture.rs => cli/src/test_fixture.rs} | 35 +- .../src/test_fixture_tests.rs} | 8 +- ublue/skillet/crates/cli/src/vm.rs | 2 +- ublue/skillet/crates/host-profiles/Cargo.toml | 22 + .../crates/host-profiles/src/hosts_tests.rs | 248 +++++++++++ ublue/skillet/crates/host-profiles/src/lib.rs | 355 +++++++++++++++ .../crates/host-profiles/src/profile.rs | 319 ++++++++++++++ .../skillet/integration_tests/smoke-guest.sh | 25 +- ublue/skillet/integration_tests/smoke-ssh.sh | 26 +- 23 files changed, 1237 insertions(+), 575 deletions(-) delete mode 100644 ublue/skillet/crates/cli-common/src/hosts_tests.rs rename ublue/skillet/crates/{cli-common/src/hosts/fixture.rs => cli/src/test_fixture.rs} (78%) rename ublue/skillet/crates/{cli-common/src/hosts/fixture/tests.rs => cli/src/test_fixture_tests.rs} (82%) create mode 100644 ublue/skillet/crates/host-profiles/Cargo.toml create mode 100644 ublue/skillet/crates/host-profiles/src/hosts_tests.rs create mode 100644 ublue/skillet/crates/host-profiles/src/lib.rs create mode 100644 ublue/skillet/crates/host-profiles/src/profile.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 3cf9a769..29a86ee2 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -856,3 +856,24 @@ host tool; it still uses normal command approvals. `clamps/smoke` run was reported as unavailable by `test vm list`; it was not altered. Native/Flatpak create, readiness, reboot, and destroy remain unverified live. + +### Canonical host profiles, 2026-10-03 + +- Moved host service composition and boot expectations into the canonical + `skillet_hosts` crate. Profile-level unit coverage checks all current hosts' + service sets, UI ports/aliases, credential consumers, network parameters, + mount needs, and snapshot configuration. Added a profile with a credentialed + Pi-hole service without UI exposure, and an explicit no-service baseline. + Unknown hosts are rejected before effects. The smoke fixture is now selected + by a separate hidden test command rather than a synthetic host identity. +- Passed `cargo fmt --manifest-path ublue/skillet/Cargo.toml --all`, + `cargo test --offline --manifest-path ublue/skillet/Cargo.toml --workspace`, + and `cargo clippy --offline --manifest-path ublue/skillet/Cargo.toml + --workspace --all-targets -- -D warnings`. Passed ShellCheck and Bash syntax + checks for `smoke-ssh.sh` and `smoke-guest.sh`. +- Passed the CI container command using the Cargo-reported beezelbot binary: + `ublue/skillet/target/x86_64-unknown-linux-musl/debug/skillet test run + beezelbot --phase base --image fedora:latest`. Both applies succeeded and + the test container was removed. +- Live base/full apply on a retained VM remains unverified because `/dev/kvm` + is absent. No VM was created, contacted, or changed in this slice. diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 4c99fbcf..f70e93ea 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -13,15 +13,21 @@ reported binary and now serves the container runner and retained VM updates. Updates share the ownership lock and SSH transport, preserve captured hashes, and record deployed hashes only after verifying both installed artifacts. Readiness now uses the same locked owner and SSH transport, with explicit -boot expectations from the selected profile, bounded probes and private +boot expectations from the canonical host profile, bounded probes and private diagnostics. It verifies original artifacts and signed boot evidence before recording readiness. A retry first clears the persisted Ready checkpoint and restores it only after acceptance. Guest operations are guarded by ownership -checks before and after each call. The interim profile boot lookup will join the canonical -capability declaration in workstream 2. Remaining VM commands and credential +checks before and after each call. Remaining VM commands and credential delivery stay on their existing paths until their slices land. Transport's bounded I/O stays in memory; payload copies are zeroized after stdin transfer. +Workstream 2 adds `skillet_hosts` as the single host capability declaration. +It owns host composition and supplies boot, service, network, UI, storage, and +credential policy to entry points. The explicit `agent` baseline is the generic +no-service apply profile. Synthetic smoke fixture composition lives in a +separate test command and is not dispatched as a host identity. Retained-VM +runtime acceptance remains pending. + Keep application crates as reusable recipes. Separate canonical host profiles and composition from guest runtime adapters, CLI parsing, workstation provisioning, and synthetic test fixtures. Effects need injectable capability diff --git a/ublue/plan/REFACTOR-02-HOST-PROFILES.md b/ublue/plan/REFACTOR-02-HOST-PROFILES.md index f86078cc..cea09b82 100644 --- a/ublue/plan/REFACTOR-02-HOST-PROFILES.md +++ b/ublue/plan/REFACTOR-02-HOST-PROFILES.md @@ -1,7 +1,7 @@ # 2. Canonical host profiles and capabilities -Status: planned; follows workstream 1 in [the prerequisite roadmap](SKILLET-REFACTOR.md). -Further feature milestones wait for that roadmap's completion. +Status: implementation complete; retained-VM acceptance is pending. Feature +milestones remain gated by [the prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate @@ -67,3 +67,16 @@ keep the VM readiness orchestrator independent of deployment names. DNS ownership conflicts preserve the current design and private values. - Pass roadmap checks and affected retained-VM base/full apply checks. Record evidence and update profile callers, designs and usage before completion. + +## Progress + +- Implemented: `skillet_hosts` is the canonical profile and composition crate + for clamps, beezelbot, and the explicit generic agent baseline. Profile data + owns boot image/masks, enabled services, UI exposure, credentials, bridge + network, storage requirement, and optional btrbk inputs. +- Implemented: generic and per-host apply wrappers, Caddy apply, credential + eligibility, smoke selection, and VM boot expectations query the profile. + Unknown profiles fail closed. The smoke fixture is a separate hidden test + command, not a synthetic host profile. +- Pending: live retained-VM base/full apply checks and the roadmap fresh-VM + cycle. These remain unverified until a named VM/runtime is available. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index f698dcfe..81f3cfdd 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -21,7 +21,7 @@ assignments to execute concurrently against the same interfaces. | Order | Workstream | Depends on | Status | | --- | --- | --- | --- | | 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | -| 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Planned | +| 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Planned | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Planned | diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index b4f1b217..12f284ea 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -57,6 +57,8 @@ tracks existing violations and their migration, not completed implementation. - Keep one authoritative host capability declaration. Application composition, UI exposure, credential requirements, and provisioning eligibility must agree with it. Reject unknown profiles for profile-dependent operations. +- Keep synthetic smoke/test fixtures outside production host identity and profile + dispatch. Invoke them through an explicitly test-scoped command or target. - Keep binary entry points thin. Host composition, guest runtime adapters, workstation provisioning, and test fixtures have distinct responsibilities. Extract modules first and crates where dependency or deployment boundaries diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 5c5ec16b..94641a1f 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2289,8 +2289,12 @@ dependencies = [ "sha2 0.10.9", "skillet_caddy", "skillet_cli_common", + "skillet_core", + "skillet_hosts", + "skillet_podman", "skillet_vm", "tempfile", + "thiserror 1.0.69", "tracing", "tracing-subscriber", ] @@ -2341,21 +2345,13 @@ version = "0.1.0" dependencies = [ "clap", "serde", - "serde_json", "serde_yml", - "skillet_btrbk", - "skillet_caddy", "skillet_core", - "skillet_hardening", - "skillet_pihole", - "skillet_podman", - "skillet_syncthing", - "skillet_unifi", + "skillet_hosts", "tempfile", "thiserror 1.0.69", "tracing", "tracing-subscriber", - "users", ] [[package]] @@ -2386,6 +2382,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "skillet_hosts" +version = "0.1.0" +dependencies = [ + "skillet_btrbk", + "skillet_caddy", + "skillet_core", + "skillet_hardening", + "skillet_pihole", + "skillet_podman", + "skillet_syncthing", + "skillet_unifi", + "thiserror 1.0.69", + "users", +] + [[package]] name = "skillet_pihole" version = "0.1.0" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 70867974..a340fb17 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/hosts/clamps", "crates/cli-common", "crates/vm", + "crates/host-profiles", ] [workspace.dependencies] @@ -27,6 +28,7 @@ skillet_caddy = { path = "crates/caddy" } skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } skillet_vm = { path = "crates/vm" } +skillet_hosts = { path = "crates/host-profiles" } thiserror = "1.0" sha2 = "0.10" users = "0.11" diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index 1fb4c593..dcc3a95d 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -8,21 +8,13 @@ workspace = true [dependencies] skillet_core.workspace = true -skillet_hardening.workspace = true -skillet_pihole.workspace = true -skillet_unifi.workspace = true -skillet_syncthing.workspace = true -skillet_btrbk.workspace = true -skillet_caddy.workspace = true -skillet_podman.workspace = true -users.workspace = true +skillet_hosts.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true thiserror.workspace = true serde.workspace = true serde_yml.workspace = true -serde_json.workspace = true tempfile.workspace = true [dev-dependencies] diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 4d9faf98..030dbdf4 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -1,20 +1,13 @@ -//! Canonical per-host apply functions. -//! -//! Each supported host has exactly one apply function here. The per-host -//! binaries (`skillet-`) and the generic `skillet apply --host ` -//! dispatcher both call these, so host logic can never diverge between the -//! two entry points again. - -use skillet_core::{ - credentials::{CredentialError, CredentialManager}, - files::{FileError, FileResource}, - system::{SystemError, SystemResource}, -}; -use skillet_podman::{ - ContainerUser, PodmanConfig, PodmanNetwork, QuadletSecret, SecretTarget, Volume, +//! CLI phase parsing and compatibility forwarding to canonical host profiles. + +use skillet_core::{files::FileResource, system::SystemResource}; +pub use skillet_hosts::{ + boot_policy_for_host, declared_profiles, profile_for_host, profile_for_name, + ui_config_for_host, ApplyError, CredentialConsumer, HostBootPolicy, HostId, HostProfile, + HostService, HostUiConfig, ServiceConfig, UiServiceDeclaration, CADDY_SITES_CREDENTIAL, + CLOUDFLARE_ACME_TOKEN_CREDENTIAL, PIHOLE_WEB_PASSWORD_CREDENTIAL, + TAILSCALE_AUTH_KEY_CREDENTIAL, }; -use std::collections::BTreeMap; -use thiserror::Error; #[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] pub enum ApplyPhase { @@ -23,393 +16,16 @@ pub enum ApplyPhase { Caddy, } -/// Explicit guest boot expectations. Consolidated with the canonical capability -/// declaration in refactoring workstream 2; the VM orchestrator has no host cases. -pub struct HostBootPolicy { - pub signed_image: String, - pub masked_units: Vec<&'static str>, -} - -pub fn boot_policy_for_host(hostname: &str) -> Option { - let masked_units = match hostname { - "clamps" => vec!["systemd-resolved.service"], - "beezelbot" => Vec::new(), - _ => return None, - }; - Some(HostBootPolicy { - signed_image: format!("ghcr.io/gbagnoli/ucore-{hostname}"), - masked_units, - }) -} - -#[derive(Error, Debug)] -pub enum ApplyError { - #[error("System error: {0}")] - System(#[from] SystemError), - #[error("File error: {0}")] - File(#[from] FileError), - #[error("Credential error: {0}")] - Credential(#[from] CredentialError), - #[error("Hardening apply error: {0}")] - Hardening(String), - #[error("Pihole apply error: {0}")] - Pihole(#[from] skillet_pihole::PiholeError), - #[error("UniFi apply error: {0}")] - Unifi(#[from] skillet_unifi::UnifiError), - #[error("Syncthing apply error: {0}")] - Syncthing(#[from] skillet_syncthing::SyncthingError), - #[error("Btrbk apply error: {0}")] - Btrbk(#[from] skillet_btrbk::BtrbkError), - #[error("Caddy apply error: {0}")] - Caddy(#[from] skillet_caddy::CaddyError), - #[error("Podman error: {0}")] - Podman(#[from] skillet_podman::PodmanError), - #[error("Fixture input error: {0}")] - FixtureInput(String), -} - -mod user_lookup { - use users::{get_group_by_name, get_user_by_name}; - - /// Look up UID for a username, returns None if user doesn't exist - pub fn lookup_uid(username: &str) -> Option { - get_user_by_name(username).map(|user| user.uid()) - } - - /// Look up GID for a group name, returns None if group doesn't exist - pub fn lookup_gid(groupname: &str) -> Option { - get_group_by_name(groupname).map(|group| group.gid()) - } -} - -/// Apply the beezelbot host configuration (hardening + Syncthing). -pub fn apply_beezelbot( - system: &dyn SystemResource, - files: &dyn FileResource, -) -> Result<(), ApplyError> { - apply_full_base(system, files)?; - apply_syncthing(system, files, host_service_network("beezelbot")) -} - -/// Shared host baseline used by both CLI entry points. -pub fn apply_base(system: &dyn SystemResource, files: &dyn FileResource) -> Result<(), ApplyError> { - skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) -} - -/// Shared full-apply baseline. Base apply runs before the signed image is -/// ready, while full apply requires the persistent data mount. -pub fn apply_full_base( - system: &dyn SystemResource, - files: &dyn FileResource, -) -> Result<(), ApplyError> { - apply_base(system, files)?; - files.require_btrfs_subvolume_mount( - std::path::Path::new("/var/lib/data"), - std::path::Path::new("/var"), - "/data", - )?; - Ok(()) -} - -/// Name of the systemd credential (and podman secret) holding the Pi-hole -/// web UI password. Full `clamps` apply reads it from the systemd credential -/// directory supplied to that invocation. -pub const PIHOLE_WEB_PASSWORD_CREDENTIAL: &str = "pihole_web_password"; -/// Name of the systemd credential (and Podman secret) holding the one-use -/// Tailscale enrollment key used when the node has not joined yet. -pub const TAILSCALE_AUTH_KEY_CREDENTIAL: &str = "tailscale_auth_key"; -/// Credential containing private Caddy hostnames and the ACME environment. -pub const CADDY_SITES_CREDENTIAL: &str = "caddy_sites"; -/// Cloudflare token used only by Caddy's DNS-01 challenge provider. -pub const CLOUDFLARE_ACME_TOKEN_CREDENTIAL: &str = "cloudflare_acme_token"; - -/// UI services declared by a host. This is the canonical input used by Caddy -/// both on the workstation (to build delivery payloads) and on the host (to -/// validate and apply them). -pub struct HostUiConfig { - pub network: PodmanNetwork, - pub services: Vec, -} - -pub fn ui_config_for_host(hostname: &str) -> Option { - match hostname { - "clamps" => Some(HostUiConfig { - network: host_service_network("clamps"), - services: vec![ - skillet_caddy::UiService { - name: "pihole".to_string(), - upstream: "pihole".to_string(), - port: 8088, - aliases: Vec::new(), - }, - skillet_caddy::UiService { - name: "syncthing".to_string(), - upstream: "syncthing".to_string(), - port: 8384, - aliases: vec!["sync.{host}".to_string()], - }, - ], - }), - "beezelbot" => Some(HostUiConfig { - network: host_service_network("beezelbot"), - services: vec![skillet_caddy::UiService { - name: "syncthing".to_string(), - upstream: "syncthing".to_string(), - port: 8384, - aliases: vec!["sync.{host}".to_string()], - }], - }), - _ => None, - } -} - -/// Custom DNS records for the clamps Pi-hole (`ip -> fqdn`). -// TODO: replace with the real LAN IP and domain before the production -// cutover; these are still the old placeholder values. -const CLAMPS_CUSTOM_DNS_RECORDS: &[(&str, &str)] = &[("192.168.1.100", "my.custom.domain")]; - -fn host_service_network(host: &str) -> PodmanNetwork { - PodmanNetwork { - unit_name: host.to_string(), - options: vec![ - "DisableDNS=false".to_string(), - "Driver=bridge".to_string(), - "Gateway=172.26.26.1".to_string(), - "Gateway=fd59:4e23:2950:11f5::1".to_string(), - "IPv6=true".to_string(), - format!("NetworkName={host}"), - "Subnet=172.26.26.0/24".to_string(), - "Subnet=fd59:4e23:2950:11f5::/64".to_string(), - ], - } -} - -fn clamps_tailscale_config(hostname: &str, auth_key: String) -> PodmanConfig { - let extra_config = BTreeMap::from([ - ( - "Container".to_string(), - vec![ - "ContainerName=tailscale".to_string(), - "AddCapability=NET_ADMIN".to_string(), - "AddCapability=NET_RAW".to_string(), - "AddDevice=/dev/net/tun:/dev/net/tun".to_string(), - "AutoUpdate=registry".to_string(), - "Environment=TS_ACCEPT_DNS=false".to_string(), - "Environment=TS_AUTH_ONCE=true".to_string(), - format!("Environment=TS_HOSTNAME={hostname}"), - "Environment=TS_STATE_DIR=/var/lib/tailscale".to_string(), - "Environment=TS_USERSPACE=false".to_string(), - "Network=host".to_string(), - ], - ), - ( - "Unit".to_string(), - vec!["After=network-online.target".to_string()], - ), - ]); - PodmanConfig { - name: "tailscale".to_string(), - image: "docker.io/tailscale/tailscale:stable".to_string(), - networks: Vec::new(), - user: ContainerUser { - container_uid: 0, - container_gid: 0, - host_user: None, - }, - create_host_user: false, - volumes: vec![Volume { - host_path: "/var/lib/data/tailscale".to_string(), - container_path: "/var/lib/tailscale".to_string(), - options: Some("Z".to_string()), - }], - secrets: vec![QuadletSecret { - secret_name: TAILSCALE_AUTH_KEY_CREDENTIAL.to_string(), - target: SecretTarget::Environment { - env_var_name: "TS_AUTHKEY".to_string(), - }, - }], - config_revisions: vec![auth_key.into_bytes()], - extra_config, - } -} - -/// Apply the clamps host configuration (hardening + Pi-hole). -/// -/// The credential manager is constructed lazily here: hosts that need no -/// secrets (e.g. beezelbot) never touch `CREDENTIALS_DIRECTORY`. -// pihole uid/gid lookups are intentionally parallel -#[allow(clippy::similar_names)] -pub fn apply_clamps( - system: &dyn SystemResource, - files: &dyn FileResource, -) -> Result<(), ApplyError> { - // Check before Podman can create a graphroot on the fallback /var tree. - apply_full_base(system, files)?; - - // 1. Ingest secrets from systemd (lazily: only clamps needs these values) - let credentials = CredentialManager::new()?; - let secret_payload = credentials.read_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL)?; - let tailscale_auth_key = credentials.read_secret(TAILSCALE_AUTH_KEY_CREDENTIAL)?; - - // 2. Provision the Podman secrets - system.ensure_podman_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL, &secret_payload)?; - system.ensure_podman_secret(TAILSCALE_AUTH_KEY_CREDENTIAL, &tailscale_auth_key)?; - - let hostname = files - .read_file(std::path::Path::new("/etc/hostname"))? - .map(|contents| { - String::from_utf8(contents).map_err(|error| ApplyError::FixtureInput(error.to_string())) - }) - .transpose()? - .unwrap_or_else(|| "clamps".to_string()); - let hostname = hostname.trim(); - if hostname.is_empty() - || hostname.len() > 63 - || !hostname - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - { - return Err(ApplyError::FixtureInput( - "invalid hostname in /etc/hostname for Tailscale".to_string(), - )); - } - - // Tailscale uses host networking so its interface and routes belong to - // the OS. The one-use auth key bootstraps login; state persists separately. - skillet_podman::container( - system, - files, - clamps_tailscale_config(hostname, tailscale_auth_key), - )?; - - // 3. Look up pihole user and group IDs; None if the user/group - // doesn't exist yet (ensure_user/group will assign dynamic IDs). - // Passing the looked-up IDs through (rather than None) keeps the - // secret file ownership and the user record consistent when the - // account already exists, e.g. pre-created by Ignition. - let pihole_uid = user_lookup::lookup_uid("pihole"); - let pihole_gid = user_lookup::lookup_gid("pihole"); - - // 4. Apply pihole with the secret - let secrets = vec![QuadletSecret { - secret_name: PIHOLE_WEB_PASSWORD_CREDENTIAL.to_string(), - target: SecretTarget::File { - target_path: "/run/secrets/pihole_web_password".to_string(), - mode: Some("0400".to_string()), - uid: None, - gid: None, - }, - }]; - - let custom_records: BTreeMap = CLAMPS_CUSTOM_DNS_RECORDS - .iter() - .map(|(ip, fqdn)| ((*ip).to_string(), (*fqdn).to_string())) - .collect(); - - skillet_pihole::apply( - system, - files, - &skillet_pihole::PiholeUser { - uid: pihole_uid, - gid: pihole_gid, - name: "pihole".to_string(), - group_name: "pihole".to_string(), - }, - secrets, - custom_records, - host_service_network("clamps"), - )?; - - apply_syncthing(system, files, host_service_network("clamps"))?; - skillet_unifi::apply(system, files)?; - skillet_btrbk::apply( - system, - files, - &skillet_btrbk::BtrbkConfig { - snapshot_subvolumes: vec![std::path::PathBuf::from("syncthing")], - }, - )?; - Ok(()) -} - -fn apply_syncthing( - system: &dyn SystemResource, - files: &dyn FileResource, - network: PodmanNetwork, -) -> Result<(), ApplyError> { - skillet_syncthing::apply( - system, - files, - skillet_syncthing::SyncthingConfig { - data_path: "/var/lib/data/syncthing".to_string(), - data_owner: "giacomo".to_string(), - data_group: "giacomo".to_string(), - uid: 1000, - gid: 1000, - network, - }, - )?; - Ok(()) -} - -/// Dispatch to the canonical apply function for a hostname. -/// -/// Unknown hostnames fall back to the hardening-only baseline, matching the -/// previous "(Agent Mode)" default behaviour. -pub fn apply_host( - hostname: &str, - system: &dyn SystemResource, - files: &dyn FileResource, -) -> Result<(), ApplyError> { - match hostname { - "clamps" => apply_clamps(system, files), - "skillet-smoke" => fixture::apply(system, files), - "beezelbot" => apply_beezelbot(system, files), - _ => apply_full_base(system, files), - } -} - -#[path = "hosts/fixture.rs"] -mod fixture; - -#[cfg(test)] -#[path = "hosts_tests.rs"] -mod tests; - pub fn apply_host_phase( hostname: &str, phase: ApplyPhase, system: &dyn SystemResource, files: &dyn FileResource, ) -> Result<(), ApplyError> { - match phase { - ApplyPhase::Base => apply_base(system, files), - ApplyPhase::Full => apply_host(hostname, system, files), - ApplyPhase::Caddy => apply_caddy_host(hostname, system, files), - } -} - -fn apply_caddy_host( - hostname: &str, - system: &dyn SystemResource, - files: &dyn FileResource, -) -> Result<(), ApplyError> { - let ui_config = ui_config_for_host(hostname).ok_or_else(|| { - ApplyError::FixtureInput(format!("host {hostname} declares no UI services")) - })?; - files.require_btrfs_subvolume_mount( - std::path::Path::new("/var/lib/data"), - std::path::Path::new("/var"), - "/data", - )?; - let credentials = CredentialManager::new()?; - let sites = skillet_caddy::CaddySites::parse( - &credentials.read_secret(CADDY_SITES_CREDENTIAL)?, - hostname, - &ui_config.services, - )?; - let token = credentials.read_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL)?; - system.ensure_podman_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL, &token)?; - skillet_caddy::apply(system, files, &sites, ui_config.network)?; - Ok(()) + let phase = match phase { + ApplyPhase::Base => skillet_hosts::HostApplyPhase::Base, + ApplyPhase::Full => skillet_hosts::HostApplyPhase::Full, + ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, + }; + skillet_hosts::apply_host_phase(hostname, phase, system, files) } diff --git a/ublue/skillet/crates/cli-common/src/hosts_tests.rs b/ublue/skillet/crates/cli-common/src/hosts_tests.rs deleted file mode 100644 index 299c9bee..00000000 --- a/ublue/skillet/crates/cli-common/src/hosts_tests.rs +++ /dev/null @@ -1,61 +0,0 @@ -use super::{clamps_tailscale_config, ui_config_for_host, TAILSCALE_AUTH_KEY_CREDENTIAL}; -use skillet_podman::SecretTarget; - -#[test] -fn boot_expectations_are_profile_inputs_and_unknown_profiles_are_refused() { - let clamps = super::boot_policy_for_host("clamps").unwrap(); - assert_eq!(clamps.signed_image, "ghcr.io/gbagnoli/ucore-clamps"); - assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); - let other = super::boot_policy_for_host("beezelbot").unwrap(); - assert!(other.masked_units.is_empty()); - assert!(super::boot_policy_for_host("clamps-test-smoke").is_none()); - assert!(super::boot_policy_for_host("unknown").is_none()); -} - -#[test] -fn clamps_tailscale_uses_host_network_and_persistent_state() { - let config = clamps_tailscale_config("clamps-test-smoke", "test-auth-key".to_string()); - - assert_eq!(config.name, "tailscale"); - assert_eq!(config.image, "docker.io/tailscale/tailscale:stable"); - assert!(config.networks.is_empty()); - assert_eq!(config.volumes.len(), 1); - assert_eq!(config.volumes[0].host_path, "/var/lib/data/tailscale"); - assert_eq!(config.volumes[0].container_path, "/var/lib/tailscale"); - let container = &config.extra_config["Container"]; - assert!(container.contains(&"ContainerName=tailscale".to_string())); - assert!(container.contains(&"Network=host".to_string())); - assert!(container.contains(&"AddCapability=NET_ADMIN".to_string())); - assert!(container.contains(&"AddCapability=NET_RAW".to_string())); - assert!(container.contains(&"AddDevice=/dev/net/tun:/dev/net/tun".to_string())); - assert!(container.contains(&"Environment=TS_AUTH_ONCE=true".to_string())); - assert!(container.contains(&"Environment=TS_ACCEPT_DNS=false".to_string())); - assert!(container.contains(&"Environment=TS_HOSTNAME=clamps-test-smoke".to_string())); - assert!(config.secrets.iter().any(|secret| { - secret.secret_name == TAILSCALE_AUTH_KEY_CREDENTIAL - && matches!( - &secret.target, - SecretTarget::Environment { env_var_name } if env_var_name == "TS_AUTHKEY" - ) - })); -} - -#[test] -fn host_ui_declarations_include_only_the_services_each_host_runs() { - let clamps = ui_config_for_host("clamps").expect("clamps UI declaration"); - assert_eq!( - clamps - .services - .iter() - .map(|service| service.name.as_str()) - .collect::>(), - ["pihole", "syncthing"] - ); - assert_eq!(clamps.network.unit_name, "clamps"); - - let beezelbot = ui_config_for_host("beezelbot").expect("beezelbot UI declaration"); - assert_eq!(beezelbot.services.len(), 1); - assert_eq!(beezelbot.services[0].name, "syncthing"); - assert_eq!(beezelbot.network.unit_name, "beezelbot"); - assert!(ui_config_for_host("unknown-host").is_none()); -} diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 4a7987ce..3ceb8969 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -8,7 +8,10 @@ workspace = true [dependencies] skillet_cli_common.workspace = true +skillet_hosts.workspace = true skillet_caddy.workspace = true +skillet_podman.workspace = true +skillet_core.workspace = true skillet_vm.workspace = true clap.workspace = true tracing.workspace = true @@ -23,3 +26,7 @@ sha2.workspace = true tempfile.workspace = true serde_json.workspace = true serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 028d95ae..c6c462c2 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -8,6 +8,7 @@ use tracing_subscriber::FmtSubscriber; mod cloudflare; mod secret_delivery; mod tailscale; +mod test_fixture; mod vm; #[derive(Parser, Debug)] @@ -106,6 +107,9 @@ enum TestCommands { Run(ContainerArgs), /// Exercise the real-systemd VM scenario using an explicit disposable SSH target Smoke(SmokeArgs), + /// Apply the disposable fixture used by the VM acceptance script + #[command(hide = true)] + FixtureApply, /// Create or destroy a disposable host VM Vm { #[command(subcommand)] @@ -207,14 +211,17 @@ struct ContainerArgs { #[derive(clap::Args, Debug)] struct SmokeArgs { - /// Host configuration to exercise (currently `clamps`) + /// Host profile from the recorded disposable VM hostname: String, + /// Recorded disposable VM instance + #[arg(long, default_value = "smoke")] + instance: String, /// Explicit disposable SSH target, USER@HOST - #[arg(long, default_value = "giacomo@127.0.0.1")] - target: String, + #[arg(long)] + target: Option, /// SSH port - #[arg(long, default_value_t = 2201)] - port: u16, + #[arg(long)] + port: Option, /// SSH private key (defaults to the key generated for `test vm create`) #[arg(long)] identity: Option, @@ -247,7 +254,7 @@ fn main() -> Result<()> { host_file, record, } => { - let mut hostname = host.unwrap_or_else(|| "(Agent Mode)".to_string()); + let mut hostname = host.unwrap_or_else(|| "agent".to_string()); if let Some(path) = host_file { hostname = std::fs::read_to_string(path) .context("Failed to read host file")? @@ -266,6 +273,12 @@ fn main() -> Result<()> { Commands::Test { command: TestCommands::Smoke(args), } => run_smoke(&args)?, + Commands::Test { + command: TestCommands::FixtureApply, + } => skillet_cli_common::handle_apply("smoke fixture", None, |system, files| { + test_fixture::apply(system, files).map_err(|error| error.to_string()) + }) + .map_err(|error| anyhow!("Failed to apply smoke fixture: {error}"))?, Commands::Test { command: TestCommands::Vm { command }, } => run_vm_command(command)?, @@ -291,23 +304,65 @@ fn run_vm_command(command: VmCommands) -> Result<()> { } fn run_smoke(args: &SmokeArgs) -> Result<()> { - if args.hostname != "clamps" { + let root = workspace_root()?; + let profile = skillet_hosts::profile_for_name(&args.hostname) + .ok_or_else(|| anyhow!("unknown host profile: {}", args.hostname))?; + if profile.signed_image.is_none() || !profile.requires_data_mount { return Err(anyhow!( - "the VM smoke scenario currently supports only hostname `clamps`" + "host {} does not declare the boot and storage capabilities required by VM smoke", + args.hostname )); } - let root = workspace_root()?; - let identity = args.identity.clone().unwrap_or_else(|| { - root.parent() - .unwrap_or(&root) - .join("butane/runs/clamps-test-smoke/ssh/id_ed25519") - }); + let butane = butane_root()?; + let run_identity = skillet_vm::RunIdentity::new(&args.hostname, &args.instance)?; + let store = skillet_vm::ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let run = store.load(&run_identity)?; + if run.phase != skillet_vm::Phase::Ready { + return Err(anyhow!( + "run `{} {}` must be ready before smoke", + args.hostname, + args.instance + )); + } + let target = format!("{}@{}", run.ssh.user, run.ssh.address); + if args + .target + .as_ref() + .is_some_and(|provided| provided != &target) + { + return Err(anyhow!( + "--target must match the SSH target recorded in the VM manifest: {target}" + )); + } + if args.port.is_some_and(|port| port != run.ssh.port) { + return Err(anyhow!( + "--port must match the SSH port recorded in the VM manifest: {}", + run.ssh.port + )); + } + let identity = if let Some(path) = &args.identity { + if !path.is_file() { + return Err(anyhow!("SSH key not found at {}", path.display())); + } + if std::fs::canonicalize(path)? != std::fs::canonicalize(&run.ssh.identity)? { + return Err(anyhow!( + "--identity must match the SSH key recorded in the VM manifest" + )); + } + path.clone() + } else { + run.ssh.identity.clone() + }; if !identity.is_file() { return Err(anyhow!( - "SSH key not found at {}; create the default VM with `skillet test vm create clamps smoke` or pass --identity", - identity.display() + "SSH key not found at {}; create the VM with `skillet test vm create {} {}`", + identity.display(), + args.hostname, + args.instance )); } + let host_binary = format!("/var/usrlocal/bin/skillet-{}", run.identity.host()); + let credentials_required = profile.requires_full_apply_credentials(); let binary = std::env::current_exe().context("locating the running Skillet binary failed")?; let script = root.join("integration_tests/smoke-ssh.sh"); if !script.is_file() { @@ -315,12 +370,17 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { } let status = std::process::Command::new("bash") .arg(script) - .args(["--target", &args.target, "--disposable-target", "--port"]) - .arg(args.port.to_string()) + .args(["--target", &target, "--disposable-target", "--port"]) + .arg(run.ssh.port.to_string()) + .args(["--host", run.identity.host()]) + .args([ + "--credentials-required", + if credentials_required { "yes" } else { "no" }, + ]) .args(["--binary"]) .arg(&binary) - .args(["--clamps-binary"]) - .arg("/var/usrlocal/bin/skillet-clamps") + .args(["--host-binary"]) + .arg(host_binary) .args(["--identity"]) .arg(&identity) .status() @@ -381,13 +441,20 @@ fn workspace_root() -> Result { } fn run_container_test(args: &ContainerArgs) -> Result<()> { - if !matches!(args.hostname.as_str(), "beezelbot" | "clamps") { + skillet_hosts::profile_for_name(&args.hostname) + .ok_or_else(|| anyhow!("unknown host profile: {}", args.hostname))?; + let root = workspace_root()?; + if !root + .join("crates/hosts") + .join(&args.hostname) + .join("Cargo.toml") + .is_file() + { return Err(anyhow!( - "unsupported host {}; expected beezelbot or clamps", + "host profile {} has no standalone host apply binary", args.hostname )); } - let root = workspace_root()?; let package = format!("skillet-{}", args.hostname); let artifacts = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { workspace: &root, diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index fc89ee82..426e1f22 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -17,11 +17,9 @@ use std::{ mod vault_cache; pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { + validate_delivery_service(&args.hostname, &args.service)?; let ui_config = if args.service == "caddy" { - Some( - skillet_cli_common::hosts::ui_config_for_host(&args.hostname) - .ok_or_else(|| anyhow!("host {} has no declared UI services", args.hostname))?, - ) + skillet_hosts::ui_config_for_host(&args.hostname) } else { None }; @@ -37,19 +35,6 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { let mut vault = open_vault(&database, args.key_file.as_deref())?; match args.service.as_str() { "pihole" => { - let declared = skillet_cli_common::hosts::ui_config_for_host(&args.hostname) - .is_some_and(|config| { - config - .services - .iter() - .any(|service| service.name == "pihole") - }); - if !declared { - return Err(anyhow!( - "host {} does not declare a Pi-hole UI", - args.hostname - )); - } let path = format!("skillet/hosts/{}/pihole/web-password", args.hostname); let secret = if let Some(secret) = lookup(&vault.database, &path)? { ensure_vault_unchanged(&vault)?; @@ -69,17 +54,11 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { install(&mut command, &args.hostname, "pihole_web_password", &secret) } "tailscale" => { - if args.hostname != "clamps" { - return Err(anyhow!( - "host {} does not declare Tailscale credential delivery", - args.hostname - )); - } let credentials = tailscale_credentials(&vault)?; let auth_key = tailscale::create_auth_key( &credentials, tailscale::SERVER_TAG, - "Skillet clamps production host", + &format!("Skillet {0} production host", args.hostname), )?; let mut command = ssh_command(args); install( @@ -94,10 +73,25 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { } } +fn validate_delivery_service(hostname: &str, service: &str) -> Result { + let profile = skillet_hosts::profile_for_name(hostname) + .ok_or_else(|| anyhow!("unknown host profile: {hostname}"))?; + match service { + "pihole" | "tailscale" if !profile.supports_service(service) => Err(anyhow!( + "host {hostname} does not declare {service} credential delivery" + )), + "caddy" if profile.ui_services().is_empty() => { + Err(anyhow!("host {hostname} declares no UI services")) + } + "pihole" | "tailscale" | "caddy" => Ok(profile), + unsupported => Err(anyhow!("unsupported secret service {unsupported}")), + } +} + fn deliver_caddy_from_vault( args: &SecretDeliverArgs, vault: &mut OpenVault, - ui_config: Option<&skillet_cli_common::hosts::HostUiConfig>, + ui_config: Option<&skillet_hosts::HostUiConfig>, ) -> Result<()> { let ui_config = ui_config.ok_or_else(|| anyhow!("missing host UI declaration"))?; let environment = args.environment.as_str(); @@ -275,7 +269,10 @@ enum RemoteCredentialState { fn remote_credential_state(args: &SecretDeliverArgs) -> Result { let output = ssh_command(args) - .arg("sudo -n /var/usrlocal/bin/skillet-clamps credential state pihole_web_password") + .arg(format!( + "sudo -n /var/usrlocal/bin/skillet-{} credential state pihole_web_password", + args.hostname + )) .output() .context("checking host state before generating a production credential")?; if !output.status.success() { @@ -312,9 +309,12 @@ fn database_path_from( } pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { - if args.hostname != "clamps" { + let profile = skillet_hosts::profile_for_name(&args.hostname) + .ok_or_else(|| anyhow!("unknown host profile: {}", args.hostname))?; + if !profile.supports_service("pihole") || !profile.supports_service("tailscale") { return Err(anyhow!( - "application provisioning currently supports clamps only" + "host {} must declare both Pi-hole and Tailscale to use VM application provisioning", + args.hostname )); } let name = vm_name(&args.hostname, &args.instance)?; @@ -332,7 +332,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let identity = run_dir.join("ssh/id_ed25519"); let known_hosts = run_dir.join("ssh/known_hosts"); let port = read_vm_port(&run_dir.join("run.conf"))?; - let mut ssh = VmSsh::new(&identity, &known_hosts, port); + let mut ssh = VmSsh::new(&args.hostname, &identity, &known_hosts, port); validate_vm_tailscale_delivery(&mut ssh)?; let vault_path = match &args.database { Some(path) => path.clone(), @@ -459,7 +459,7 @@ fn provision_vm_ui( let zone = api.zone(&creator, &zone_id)?; let ui_domain = skillet_caddy::resolve_ui_domain(&zone.name, domain_prefix.as_deref()) .context("resolving test relative UI domain beneath its Cloudflare zone")?; - let host_ui = skillet_cli_common::hosts::ui_config_for_host(&args.hostname) + let host_ui = skillet_hosts::ui_config_for_host(&args.hostname) .ok_or_else(|| anyhow!("host {} has no declared UI services", args.hostname))?; let sites = skillet_caddy::CaddySites::from_host( &args.hostname, @@ -664,6 +664,7 @@ fn write_cloudflare_ownership(path: &Path, ownership: &CloudflareVmOwnership) -> } struct VmSsh<'a> { + host: &'a str, identity: &'a Path, known_hosts: &'a Path, port: u16, @@ -736,8 +737,10 @@ impl VmSsh<'_> { { return Err(anyhow!("invalid credential consumer unit")); } - let mut remote = - format!("sudo -n /var/usrlocal/bin/skillet-clamps credential install {name} {unit}"); + let mut remote = format!( + "sudo -n /var/usrlocal/bin/skillet-{} credential install {name} {unit}", + self.host + ); if defer_start { remote.push_str(" --no-start"); } @@ -763,8 +766,9 @@ impl VmSsh<'_> { } impl<'a> VmSsh<'a> { - fn new(identity: &'a Path, known_hosts: &'a Path, port: u16) -> Self { + fn new(host: &'a str, identity: &'a Path, known_hosts: &'a Path, port: u16) -> Self { Self { + host, identity, known_hosts, port, diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 266b78cf..99917557 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,6 +1,6 @@ use super::{ create_entry, database_path_from, lookup, open_vault, parse_tailscale_addresses, read_vm_port, - save_vault, validate_tailscale_unit_config, OpenVault, + save_vault, validate_delivery_service, validate_tailscale_unit_config, OpenVault, }; use keepass::Database; use keepass::DatabaseKey; @@ -63,6 +63,15 @@ fn rejects_vm_without_tailscale_systemd_credential() { assert!(validate_tailscale_unit_config(current_unit).is_ok()); } +#[test] +fn delivery_eligibility_uses_declared_service_capabilities() { + assert!(validate_delivery_service("beezelbot", "pihole").is_err()); + assert!(validate_delivery_service("beezelbot", "tailscale").is_err()); + assert!(validate_delivery_service("beezelbot", "caddy").is_ok()); + assert!(validate_delivery_service("missing-host", "caddy").is_err()); + assert!(validate_delivery_service("clamps", "unknown").is_err()); +} + #[test] fn disposable_database_opens_with_correct_password_only() { let mut db = Database::new(); diff --git a/ublue/skillet/crates/cli-common/src/hosts/fixture.rs b/ublue/skillet/crates/cli/src/test_fixture.rs similarity index 78% rename from ublue/skillet/crates/cli-common/src/hosts/fixture.rs rename to ublue/skillet/crates/cli/src/test_fixture.rs index f0539a64..24cb6947 100644 --- a/ublue/skillet/crates/cli-common/src/hosts/fixture.rs +++ b/ublue/skillet/crates/cli/src/test_fixture.rs @@ -1,27 +1,44 @@ -//! Disposable VM fixture. Only the explicit `skillet-smoke` host selects it. +//! Disposable integration-test fixture; it is not a host profile or apply path. -use super::ApplyError; -use skillet_core::{files::FileResource, system::SystemResource}; -use skillet_podman::{ContainerUser, PodmanConfig, QuadletSecret, SecretTarget, Volume}; +use skillet_core::{ + files::{FileError, FileResource}, + system::{SystemError, SystemResource}, +}; +use skillet_podman::{ + ContainerUser, PodmanConfig, PodmanError, QuadletSecret, SecretTarget, Volume, +}; use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; const INPUT_DIR: &str = "/var/lib/skillet-smoke/desired"; const CONFIG_PATH: &str = "/etc/skillet-smoke/config"; const SECRET_NAME: &str = "skillet-smoke-dummy"; const ENTRYPOINT: &[u8] = b"#!/bin/sh\nset -eu\ncp /fixture/config /data/observed-config\nsha256sum /run/secrets/skillet-smoke-dummy | cut -d' ' -f1 > /data/observed-secret-sha\nwhile :; do sleep 3600; done\n"; +#[derive(Debug, Error)] +pub(super) enum FixtureError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), + #[error("Fixture input error: {0}")] + Input(String), +} + pub(super) fn apply( system: &dyn SystemResource, files: &dyn FileResource, -) -> Result<(), ApplyError> { +) -> Result<(), FixtureError> { let config = files .read_file(&Path::new(INPUT_DIR).join("config"))? - .ok_or_else(|| ApplyError::FixtureInput("missing desired config".to_string()))?; + .ok_or_else(|| FixtureError::Input("missing desired config".to_string()))?; let secret = files .read_file(&Path::new(INPUT_DIR).join("secret"))? - .ok_or_else(|| ApplyError::FixtureInput("missing desired dummy secret".to_string()))?; + .ok_or_else(|| FixtureError::Input("missing desired dummy secret".to_string()))?; let secret = String::from_utf8(secret) - .map_err(|_| ApplyError::FixtureInput("dummy secret must be UTF-8".to_string()))?; + .map_err(|_| FixtureError::Input("dummy secret must be UTF-8".to_string()))?; files.ensure_directory( Path::new("/etc/skillet-smoke"), @@ -113,5 +130,5 @@ pub(super) fn apply( } #[cfg(test)] -#[path = "fixture/tests.rs"] +#[path = "test_fixture_tests.rs"] mod tests; diff --git a/ublue/skillet/crates/cli-common/src/hosts/fixture/tests.rs b/ublue/skillet/crates/cli/src/test_fixture_tests.rs similarity index 82% rename from ublue/skillet/crates/cli-common/src/hosts/fixture/tests.rs rename to ublue/skillet/crates/cli/src/test_fixture_tests.rs index 64aa7233..62e17a7f 100644 --- a/ublue/skillet/crates/cli-common/src/hosts/fixture/tests.rs +++ b/ublue/skillet/crates/cli/src/test_fixture_tests.rs @@ -3,16 +3,14 @@ use skillet_core::test_utils::{MockFiles, MockSystem}; #[test] fn fixture_requires_explicit_inputs() { - let system = MockSystem::new(); - let files = MockFiles::new(); assert!(matches!( - apply(&system, &files), - Err(ApplyError::FixtureInput(_)) + apply(&MockSystem::new(), &MockFiles::new()), + Err(FixtureError::Input(_)) )); } #[test] -fn fixture_uses_the_host_container_resources() { +fn fixture_uses_host_container_resources() { let system = MockSystem::new(); let files = MockFiles::new(); files diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 9b852428..0fc3fe22 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -394,7 +394,7 @@ pub(super) fn ready(args: &VmTargetArgs) -> Result<()> { let store = ManifestStore::new(&butane.join("runs"), current_uid())?; let _lock = store.lock(&identity)?; let run = store.load(&identity)?; - let boot = skillet_cli_common::hosts::boot_policy_for_host(identity.host()) + let boot = skillet_hosts::boot_policy_for_host(identity.host()) .ok_or_else(|| anyhow!("unknown readiness profile: {}", identity.host()))?; let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; backend diff --git a/ublue/skillet/crates/host-profiles/Cargo.toml b/ublue/skillet/crates/host-profiles/Cargo.toml new file mode 100644 index 00000000..6aa86e58 --- /dev/null +++ b/ublue/skillet/crates/host-profiles/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "skillet_hosts" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_hardening.workspace = true +skillet_pihole.workspace = true +skillet_unifi.workspace = true +skillet_syncthing.workspace = true +skillet_btrbk.workspace = true +skillet_caddy.workspace = true +skillet_podman.workspace = true +users.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs new file mode 100644 index 00000000..305397f1 --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -0,0 +1,248 @@ +use super::{ + profile::{ + HostId, HostProfile, HostService, NetworkPolicy, ServiceConfig, UiServiceDeclaration, + }, + tailscale_config, ui_config_for_host, TAILSCALE_AUTH_KEY_CREDENTIAL, +}; +use skillet_core::{files::LocalFileResource, test_utils::MockSystem}; +use skillet_podman::SecretTarget; + +#[test] +fn boot_expectations_are_profile_inputs_and_unknown_profiles_are_refused() { + let clamps = super::boot_policy_for_host("clamps").unwrap(); + assert_eq!(clamps.signed_image, "ghcr.io/gbagnoli/ucore-clamps"); + assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); + let other = super::boot_policy_for_host("beezelbot").unwrap(); + assert!(other.masked_units.is_empty()); + assert!(super::boot_policy_for_host("clamps-test-smoke").is_none()); + assert!(super::boot_policy_for_host("unknown").is_none()); +} + +#[test] +fn clamps_tailscale_uses_host_network_and_persistent_state() { + let config = tailscale_config( + "clamps-test-smoke", + "test-auth-key".to_string(), + "/var/lib/data/tailscale", + ); + + assert_eq!(config.name, "tailscale"); + assert_eq!(config.image, "docker.io/tailscale/tailscale:stable"); + assert!(config.networks.is_empty()); + assert_eq!(config.volumes.len(), 1); + assert_eq!(config.volumes[0].host_path, "/var/lib/data/tailscale"); + assert_eq!(config.volumes[0].container_path, "/var/lib/tailscale"); + let container = &config.extra_config["Container"]; + assert!(container.contains(&"ContainerName=tailscale".to_string())); + assert!(container.contains(&"Network=host".to_string())); + assert!(container.contains(&"AddCapability=NET_ADMIN".to_string())); + assert!(container.contains(&"AddCapability=NET_RAW".to_string())); + assert!(container.contains(&"AddDevice=/dev/net/tun:/dev/net/tun".to_string())); + assert!(container.contains(&"Environment=TS_AUTH_ONCE=true".to_string())); + assert!(container.contains(&"Environment=TS_ACCEPT_DNS=false".to_string())); + assert!(container.contains(&"Environment=TS_HOSTNAME=clamps-test-smoke".to_string())); + assert!(config.secrets.iter().any(|secret| { + secret.secret_name == TAILSCALE_AUTH_KEY_CREDENTIAL + && matches!( + &secret.target, + SecretTarget::Environment { env_var_name } if env_var_name == "TS_AUTHKEY" + ) + })); +} + +#[test] +fn profile_capabilities_are_the_authority_for_services_credentials_network_and_snapshots() { + let clamps = super::profile_for_name("clamps").unwrap(); + assert_eq!( + clamps + .services + .iter() + .map(HostService::name) + .collect::>(), + ["pihole", "tailscale", "syncthing", "unifi", "btrbk"] + ); + let credentials = clamps.credential_consumers(); + assert!(credentials.iter().any(|use_| { + use_.credential == super::PIHOLE_WEB_PASSWORD_CREDENTIAL && use_.unit == "pihole.service" + })); + assert!(credentials.iter().any(|use_| { + use_.credential == super::TAILSCALE_AUTH_KEY_CREDENTIAL && use_.unit == "tailscale.service" + })); + assert!(credentials.iter().any(|use_| { + use_.credential == super::CLOUDFLARE_ACME_TOKEN_CREDENTIAL && use_.unit == "caddy.service" + })); + assert_eq!( + clamps.btrbk_config().unwrap().snapshot_subvolumes, + [std::path::PathBuf::from("syncthing")] + ); + assert_eq!(clamps.signed_image, Some("ghcr.io/gbagnoli/ucore-clamps")); + assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); + assert!(clamps.requires_data_mount); + let network = clamps.service_network(); + assert!(network.options.contains(&"IPv6=true".to_string())); + assert!(network + .options + .contains(&"Subnet=172.26.26.0/24".to_string())); + assert!(network + .options + .contains(&"Subnet=fd59:4e23:2950:11f5::/64".to_string())); + + let beezelbot = super::profile_for_name("beezelbot").unwrap(); + assert_eq!(beezelbot.services.len(), 1); + assert!(beezelbot.supports_service("syncthing")); + assert!(!beezelbot.supports_service("pihole")); + assert!(beezelbot.btrbk_config().is_none()); + assert_eq!( + beezelbot.signed_image, + Some("ghcr.io/gbagnoli/ucore-beezelbot") + ); + assert!(beezelbot.masked_units.is_empty()); + assert!(beezelbot.requires_data_mount); + assert_eq!( + beezelbot.credential_consumers(), + [ + super::CredentialConsumer { + credential: super::CADDY_SITES_CREDENTIAL, + unit: "caddy.service", + }, + super::CredentialConsumer { + credential: super::CLOUDFLARE_ACME_TOKEN_CREDENTIAL, + unit: "caddy.service", + } + ] + ); +} + +#[test] +fn agent_baseline_declares_no_ui_or_credentials_and_unknown_full_profiles_fail() { + let baseline = super::profile_for_name("agent").unwrap(); + assert!(baseline.services.is_empty()); + assert!(baseline.ui_services().is_empty()); + assert!(baseline.credential_consumers().is_empty()); + assert!(super::ui_config_for_host("agent").is_none()); + let system = MockSystem::new(); + let files = LocalFileResource::new(); + assert!(matches!( + super::apply_host("unknown-host", &system, &files), + Err(super::ApplyError::UnknownHost(_)) + )); + assert!(system.podman_secrets.lock().unwrap().is_empty()); +} + +#[test] +fn host_ui_declarations_include_only_the_services_each_host_runs() { + let clamps = ui_config_for_host("clamps").expect("clamps UI declaration"); + assert_eq!( + clamps + .services + .iter() + .map(|service| service.name.as_str()) + .collect::>(), + ["pihole", "syncthing"] + ); + assert_eq!(clamps.network.unit_name, "clamps"); + assert_eq!(clamps.services[0].upstream, "pihole"); + assert_eq!(clamps.services[0].port, 8088); + assert!(clamps.services[0].aliases.is_empty()); + assert_eq!(clamps.services[1].upstream, "syncthing"); + assert_eq!(clamps.services[1].port, 8384); + assert_eq!(clamps.services[1].aliases, ["sync.{host}"]); + + let beezelbot = ui_config_for_host("beezelbot").expect("beezelbot UI declaration"); + assert_eq!(beezelbot.services.len(), 1); + assert_eq!(beezelbot.services[0].name, "syncthing"); + assert_eq!(beezelbot.services[0].aliases, ["sync.{host}"]); + assert_eq!(beezelbot.network.unit_name, "beezelbot"); + assert!(ui_config_for_host("unknown-host").is_none()); +} + +#[test] +fn host_ids_validate_ascii_dns_label_syntax() { + for valid in ["a", "clamps", "remote-node-2"] { + assert_eq!( + HostId::parse(valid).as_ref().map(HostId::as_str), + Some(valid) + ); + } + let max_length = "a".repeat(63); + assert_eq!( + HostId::parse(&max_length).as_ref().map(HostId::as_str), + Some(max_length.as_str()) + ); + for invalid in [ + "", + "Upper", + "-leading", + "trailing-", + "has space", + "a.b", + "é", + ] { + assert!(HostId::parse(invalid).is_none(), "accepted {invalid:?}"); + } +} + +#[test] +fn synthetic_profile_derives_network_and_ui_from_its_identity() { + let profile = HostProfile { + id: HostId::parse("remote-node").unwrap(), + signed_image: None, + masked_units: &[], + network: NetworkPolicy { + ipv4_subnet: "192.0.2.0/24", + ipv4_gateway: "192.0.2.1", + ipv6_subnet: "2001:db8::/64", + ipv6_gateway: "2001:db8::1", + }, + requires_data_mount: false, + services: vec![HostService { + config: ServiceConfig::Syncthing { + data_path: "/srv/sync", + data_owner: "sync", + data_group: "sync", + uid: 1234, + gid: 1234, + }, + ui: Some(UiServiceDeclaration { + name: "sync", + upstream: "sync", + port: 8384, + aliases: &["files.{host}"], + }), + }], + }; + assert_eq!(profile.service_network().unit_name, "remote-node"); + assert_eq!(profile.ui_services()[0].name, "sync"); + assert_eq!(profile.ui_services()[0].aliases, ["files.{host}"]); + assert!(profile.credential_consumers().iter().any(|use_| { + use_.credential == super::CADDY_SITES_CREDENTIAL && use_.unit == "caddy.service" + })); +} + +#[test] +fn a_credential_service_remains_eligible_when_its_ui_is_not_exposed() { + let profile = HostProfile { + id: HostId::parse("pihole-node").unwrap(), + signed_image: None, + masked_units: &[], + network: NetworkPolicy { + ipv4_subnet: "192.0.2.0/24", + ipv4_gateway: "192.0.2.1", + ipv6_subnet: "2001:db8::/64", + ipv6_gateway: "2001:db8::1", + }, + requires_data_mount: false, + services: vec![HostService { + config: ServiceConfig::Pihole { custom_dns: &[] }, + ui: None, + }], + }; + assert!(profile.ui_services().is_empty()); + assert_eq!( + profile.credential_consumers(), + [super::CredentialConsumer { + credential: super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + unit: "pihole.service", + }] + ); +} diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs new file mode 100644 index 00000000..abec08e7 --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -0,0 +1,355 @@ +//! Canonical host profiles and their shared composition. + +use skillet_core::{ + credentials::{CredentialError, CredentialManager}, + files::{FileError, FileResource}, + system::{SystemError, SystemResource}, +}; +use skillet_podman::{ + ContainerUser, PodmanConfig, PodmanNetwork, QuadletSecret, SecretTarget, Volume, +}; +use std::collections::BTreeMap; +use thiserror::Error; + +pub mod profile; +pub use profile::{ + declared_profiles, profile_for_host, profile_for_name, CredentialConsumer, HostId, HostProfile, + HostService, ServiceConfig, UiServiceDeclaration, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HostApplyPhase { + Base, + Full, + Caddy, +} + +/// Explicit guest boot expectations. Consolidated with the canonical capability +/// declaration in refactoring workstream 2; the VM orchestrator has no host cases. +pub struct HostBootPolicy { + pub signed_image: String, + pub masked_units: Vec<&'static str>, +} + +pub fn boot_policy_for_host(hostname: &str) -> Option { + let profile = profile_for_name(hostname)?; + let signed_image = profile.signed_image?; + Some(HostBootPolicy { + signed_image: signed_image.to_string(), + masked_units: profile.masked_units.to_vec(), + }) +} + +#[derive(Error, Debug)] +pub enum ApplyError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Credential error: {0}")] + Credential(#[from] CredentialError), + #[error("Hardening apply error: {0}")] + Hardening(String), + #[error("Pihole apply error: {0}")] + Pihole(#[from] skillet_pihole::PiholeError), + #[error("UniFi apply error: {0}")] + Unifi(#[from] skillet_unifi::UnifiError), + #[error("Syncthing apply error: {0}")] + Syncthing(#[from] skillet_syncthing::SyncthingError), + #[error("Btrbk apply error: {0}")] + Btrbk(#[from] skillet_btrbk::BtrbkError), + #[error("Caddy apply error: {0}")] + Caddy(#[from] skillet_caddy::CaddyError), + #[error("Podman error: {0}")] + Podman(#[from] skillet_podman::PodmanError), + #[error("Fixture input error: {0}")] + FixtureInput(String), + #[error("Unknown host profile: {0}")] + UnknownHost(String), +} + +mod user_lookup { + use users::{get_group_by_name, get_user_by_name}; + + /// Look up UID for a username, returns None if user doesn't exist + pub fn lookup_uid(username: &str) -> Option { + get_user_by_name(username).map(|user| user.uid()) + } + + /// Look up GID for a group name, returns None if group doesn't exist + pub fn lookup_gid(groupname: &str) -> Option { + get_group_by_name(groupname).map(|group| group.gid()) + } +} + +/// Shared host baseline used by both CLI entry points. +pub fn apply_base(system: &dyn SystemResource, files: &dyn FileResource) -> Result<(), ApplyError> { + skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) +} + +/// Name of the systemd credential (and podman secret) holding the Pi-hole +/// web UI password. Full `clamps` apply reads it from the systemd credential +/// directory supplied to that invocation. +pub const PIHOLE_WEB_PASSWORD_CREDENTIAL: &str = "pihole_web_password"; +/// Name of the systemd credential (and Podman secret) holding the one-use +/// Tailscale enrollment key used when the node has not joined yet. +pub const TAILSCALE_AUTH_KEY_CREDENTIAL: &str = "tailscale_auth_key"; +/// Credential containing private Caddy hostnames and the ACME environment. +pub const CADDY_SITES_CREDENTIAL: &str = "caddy_sites"; +/// Cloudflare token used only by Caddy's DNS-01 challenge provider. +pub const CLOUDFLARE_ACME_TOKEN_CREDENTIAL: &str = "cloudflare_acme_token"; + +/// UI services declared by a host. This is the canonical input used by Caddy +/// both on the workstation (to build delivery payloads) and on the host (to +/// validate and apply them). +pub struct HostUiConfig { + pub network: PodmanNetwork, + pub services: Vec, +} + +pub fn ui_config_for_host(hostname: &str) -> Option { + let profile = profile_for_name(hostname)?; + let services = profile.ui_services(); + (!services.is_empty()).then(|| HostUiConfig { + network: profile.service_network(), + services, + }) +} + +fn tailscale_config(hostname: &str, auth_key: String, state_path: &str) -> PodmanConfig { + let extra_config = BTreeMap::from([ + ( + "Container".to_string(), + vec![ + "ContainerName=tailscale".to_string(), + "AddCapability=NET_ADMIN".to_string(), + "AddCapability=NET_RAW".to_string(), + "AddDevice=/dev/net/tun:/dev/net/tun".to_string(), + "AutoUpdate=registry".to_string(), + "Environment=TS_ACCEPT_DNS=false".to_string(), + "Environment=TS_AUTH_ONCE=true".to_string(), + format!("Environment=TS_HOSTNAME={hostname}"), + "Environment=TS_STATE_DIR=/var/lib/tailscale".to_string(), + "Environment=TS_USERSPACE=false".to_string(), + "Network=host".to_string(), + ], + ), + ( + "Unit".to_string(), + vec!["After=network-online.target".to_string()], + ), + ]); + PodmanConfig { + name: "tailscale".to_string(), + image: "docker.io/tailscale/tailscale:stable".to_string(), + networks: Vec::new(), + user: ContainerUser { + container_uid: 0, + container_gid: 0, + host_user: None, + }, + create_host_user: false, + volumes: vec![Volume { + host_path: state_path.to_string(), + container_path: "/var/lib/tailscale".to_string(), + options: Some("Z".to_string()), + }], + secrets: vec![QuadletSecret { + secret_name: TAILSCALE_AUTH_KEY_CREDENTIAL.to_string(), + target: SecretTarget::Environment { + env_var_name: "TS_AUTHKEY".to_string(), + }, + }], + config_revisions: vec![auth_key.into_bytes()], + extra_config, + } +} + +/// Apply a service from a host declaration using only its declared config. +fn apply_syncthing( + system: &dyn SystemResource, + files: &dyn FileResource, + profile: &HostProfile, + service: &HostService, +) -> Result<(), ApplyError> { + let ServiceConfig::Syncthing { + data_path, + data_owner, + data_group, + uid, + gid, + } = service.config + else { + return Err(ApplyError::FixtureInput( + "syncthing apply received an incompatible service declaration".to_string(), + )); + }; + skillet_syncthing::apply( + system, + files, + skillet_syncthing::SyncthingConfig { + data_path: data_path.to_string(), + data_owner: data_owner.to_string(), + data_group: data_group.to_string(), + uid, + gid, + network: profile.service_network(), + }, + )?; + Ok(()) +} + +/// Apply a canonical host profile. Unknown identities are rejected. +pub fn apply_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, +) -> Result<(), ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; + apply_profile(&profile, system, files) +} + +#[allow(clippy::similar_names)] +fn apply_profile( + profile: &HostProfile, + system: &dyn SystemResource, + files: &dyn FileResource, +) -> Result<(), ApplyError> { + apply_base(system, files)?; + if profile.requires_data_mount { + files.require_btrfs_subvolume_mount( + std::path::Path::new("/var/lib/data"), + std::path::Path::new("/var"), + "/data", + )?; + } + for service in &profile.services { + match &service.config { + ServiceConfig::Pihole { custom_dns } => { + let password = + CredentialManager::new()?.read_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL)?; + system.ensure_podman_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL, &password)?; + skillet_pihole::apply( + system, + files, + &skillet_pihole::PiholeUser { + uid: user_lookup::lookup_uid("pihole"), + gid: user_lookup::lookup_gid("pihole"), + name: "pihole".to_string(), + group_name: "pihole".to_string(), + }, + vec![QuadletSecret { + secret_name: PIHOLE_WEB_PASSWORD_CREDENTIAL.to_string(), + target: SecretTarget::File { + target_path: "/run/secrets/pihole_web_password".to_string(), + mode: Some("0400".to_string()), + uid: None, + gid: None, + }, + }], + custom_dns + .iter() + .map(|(ip, domain)| ((*ip).to_string(), (*domain).to_string())) + .collect::>(), + profile.service_network(), + )?; + } + ServiceConfig::Syncthing { .. } => apply_syncthing(system, files, profile, service)?, + ServiceConfig::Unifi => skillet_unifi::apply(system, files)?, + ServiceConfig::Tailscale { state_path } => { + let auth_key = + CredentialManager::new()?.read_secret(TAILSCALE_AUTH_KEY_CREDENTIAL)?; + system.ensure_podman_secret(TAILSCALE_AUTH_KEY_CREDENTIAL, &auth_key)?; + let hostname = runtime_hostname(files, profile.id.as_str())?; + skillet_podman::container( + system, + files, + tailscale_config(&hostname, auth_key, state_path), + )?; + } + ServiceConfig::Btrbk { .. } => { + let config = profile.btrbk_config().ok_or_else(|| { + ApplyError::FixtureInput("btrbk service lacks snapshot configuration".into()) + })?; + skillet_btrbk::apply(system, files, &config)?; + } + } + } + Ok(()) +} + +fn runtime_hostname(files: &dyn FileResource, fallback: &str) -> Result { + let hostname = files + .read_file(std::path::Path::new("/etc/hostname"))? + .map(|contents| { + String::from_utf8(contents).map_err(|error| ApplyError::FixtureInput(error.to_string())) + }) + .transpose()? + .unwrap_or_else(|| fallback.to_string()); + let hostname = hostname.trim(); + if hostname.is_empty() + || hostname.len() > 63 + || !hostname + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(ApplyError::FixtureInput( + "invalid hostname in /etc/hostname for Tailscale".to_string(), + )); + } + Ok(hostname.to_string()) +} + +#[cfg(test)] +#[path = "hosts_tests.rs"] +mod tests; + +pub fn apply_host_phase( + hostname: &str, + phase: HostApplyPhase, + system: &dyn SystemResource, + files: &dyn FileResource, +) -> Result<(), ApplyError> { + match phase { + HostApplyPhase::Base => apply_base(system, files), + HostApplyPhase::Full => apply_host(hostname, system, files), + HostApplyPhase::Caddy => apply_caddy_host(hostname, system, files), + } +} + +fn apply_caddy_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, +) -> Result<(), ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; + let services = profile.ui_services(); + if services.is_empty() { + return Err(ApplyError::FixtureInput(format!( + "host {hostname} declares no UI services" + ))); + } + let ui_config = HostUiConfig { + network: profile.service_network(), + services, + }; + if profile.requires_data_mount { + files.require_btrfs_subvolume_mount( + std::path::Path::new("/var/lib/data"), + std::path::Path::new("/var"), + "/data", + )?; + } + let credentials = CredentialManager::new()?; + let sites = skillet_caddy::CaddySites::parse( + &credentials.read_secret(CADDY_SITES_CREDENTIAL)?, + hostname, + &ui_config.services, + )?; + let token = credentials.read_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL)?; + system.ensure_podman_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL, &token)?; + skillet_caddy::apply(system, files, &sites, ui_config.network)?; + Ok(()) +} diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs new file mode 100644 index 00000000..2240a69e --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -0,0 +1,319 @@ +//! Validated host identity and the single source of host capabilities. +use std::{borrow::Cow, path::PathBuf}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct HostId(Cow<'static, str>); + +impl HostId { + pub fn parse(value: &str) -> Option { + let valid = !value.is_empty() + && value.len() <= 63 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && value.as_bytes()[0].is_ascii_lowercase() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric(); + valid.then(|| Self(Cow::Owned(value.to_string()))) + } + + fn known(value: &'static str) -> Self { + Self(Cow::Borrowed(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UiServiceDeclaration { + pub name: &'static str, + pub upstream: &'static str, + pub port: u16, + pub aliases: &'static [&'static str], +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialConsumer { + pub credential: &'static str, + pub unit: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceConfig { + Pihole { + custom_dns: &'static [(&'static str, &'static str)], + }, + Syncthing { + data_path: &'static str, + data_owner: &'static str, + data_group: &'static str, + uid: u32, + gid: u32, + }, + Unifi, + Tailscale { + state_path: &'static str, + }, + Btrbk { + snapshot_subvolumes: &'static [&'static str], + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HostService { + pub config: ServiceConfig, + pub ui: Option, +} + +impl HostService { + pub fn name(&self) -> &'static str { + match self.config { + ServiceConfig::Pihole { .. } => "pihole", + ServiceConfig::Syncthing { .. } => "syncthing", + ServiceConfig::Unifi => "unifi", + ServiceConfig::Tailscale { .. } => "tailscale", + ServiceConfig::Btrbk { .. } => "btrbk", + } + } + + pub fn credential_consumers(&self) -> Vec { + match self.config { + ServiceConfig::Pihole { .. } => vec![CredentialConsumer { + credential: super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + unit: "pihole.service", + }], + ServiceConfig::Tailscale { .. } => vec![CredentialConsumer { + credential: super::TAILSCALE_AUTH_KEY_CREDENTIAL, + unit: "tailscale.service", + }], + _ => Vec::new(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NetworkPolicy { + pub ipv4_subnet: &'static str, + pub ipv4_gateway: &'static str, + pub ipv6_subnet: &'static str, + pub ipv6_gateway: &'static str, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HostProfile { + pub id: HostId, + pub signed_image: Option<&'static str>, + pub masked_units: &'static [&'static str], + pub network: NetworkPolicy, + pub requires_data_mount: bool, + pub services: Vec, +} + +impl HostProfile { + pub fn supports_service(&self, name: &str) -> bool { + self.services.iter().any(|service| service.name() == name) + } + + pub fn requires_full_apply_credentials(&self) -> bool { + self.services.iter().any(|service| { + matches!( + service.config, + ServiceConfig::Pihole { .. } | ServiceConfig::Tailscale { .. } + ) + }) + } + + pub fn ui_services(&self) -> Vec { + self.services + .iter() + .filter_map(|service| service.ui.as_ref()) + .map(|ui| skillet_caddy::UiService { + name: ui.name.to_string(), + upstream: ui.upstream.to_string(), + port: ui.port, + aliases: ui + .aliases + .iter() + .map(|alias| (*alias).to_string()) + .collect(), + }) + .collect() + } + + pub fn credential_consumers(&self) -> Vec { + let mut consumers = self + .services + .iter() + .flat_map(HostService::credential_consumers) + .collect::>(); + if self.services.iter().any(|service| service.ui.is_some()) { + consumers.extend([ + CredentialConsumer { + credential: super::CADDY_SITES_CREDENTIAL, + unit: "caddy.service", + }, + CredentialConsumer { + credential: super::CLOUDFLARE_ACME_TOKEN_CREDENTIAL, + unit: "caddy.service", + }, + ]); + } + consumers + } + + pub fn service_network(&self) -> skillet_podman::PodmanNetwork { + let host = self.id.as_str(); + skillet_podman::PodmanNetwork { + unit_name: host.to_string(), + options: vec![ + "DisableDNS=false".to_string(), + "Driver=bridge".to_string(), + format!("Gateway={}", self.network.ipv4_gateway), + format!("Gateway={}", self.network.ipv6_gateway), + "IPv6=true".to_string(), + format!("NetworkName={host}"), + format!("Subnet={}", self.network.ipv4_subnet), + format!("Subnet={}", self.network.ipv6_subnet), + ], + } + } + + pub fn btrbk_config(&self) -> Option { + self.services + .iter() + .find_map(|service| match service.config { + ServiceConfig::Btrbk { + snapshot_subvolumes, + } => Some(skillet_btrbk::BtrbkConfig { + snapshot_subvolumes: snapshot_subvolumes + .iter() + .map(|subvolume| PathBuf::from(*subvolume)) + .collect(), + }), + _ => None, + }) + } +} + +const CLAMPS_DNS: &[(&str, &str)] = &[("192.168.1.100", "my.custom.domain")]; +const CLAMPS_SYNCTHING_UI: UiServiceDeclaration = UiServiceDeclaration { + name: "syncthing", + upstream: "syncthing", + port: 8384, + aliases: &["sync.{host}"], +}; +const BEEZELBOT_SYNCTHING_UI: UiServiceDeclaration = CLAMPS_SYNCTHING_UI; + +fn clamps() -> HostProfile { + HostProfile { + id: HostId::known("clamps"), + signed_image: Some("ghcr.io/gbagnoli/ucore-clamps"), + masked_units: &["systemd-resolved.service"], + network: NetworkPolicy { + ipv4_subnet: "172.26.26.0/24", + ipv4_gateway: "172.26.26.1", + ipv6_subnet: "fd59:4e23:2950:11f5::/64", + ipv6_gateway: "fd59:4e23:2950:11f5::1", + }, + requires_data_mount: true, + services: vec![ + HostService { + config: ServiceConfig::Pihole { + custom_dns: CLAMPS_DNS, + }, + ui: Some(UiServiceDeclaration { + name: "pihole", + upstream: "pihole", + port: 8088, + aliases: &[], + }), + }, + HostService { + config: ServiceConfig::Tailscale { + state_path: "/var/lib/data/tailscale", + }, + ui: None, + }, + HostService { + config: ServiceConfig::Syncthing { + data_path: "/var/lib/data/syncthing", + data_owner: "giacomo", + data_group: "giacomo", + uid: 1000, + gid: 1000, + }, + ui: Some(CLAMPS_SYNCTHING_UI), + }, + HostService { + config: ServiceConfig::Unifi, + ui: None, + }, + HostService { + config: ServiceConfig::Btrbk { + snapshot_subvolumes: &["syncthing"], + }, + ui: None, + }, + ], + } +} + +fn beezelbot() -> HostProfile { + HostProfile { + id: HostId::known("beezelbot"), + signed_image: Some("ghcr.io/gbagnoli/ucore-beezelbot"), + masked_units: &[], + network: NetworkPolicy { + ipv4_subnet: "172.26.26.0/24", + ipv4_gateway: "172.26.26.1", + ipv6_subnet: "fd59:4e23:2950:11f5::/64", + ipv6_gateway: "fd59:4e23:2950:11f5::1", + }, + requires_data_mount: true, + services: vec![HostService { + config: ServiceConfig::Syncthing { + data_path: "/var/lib/data/syncthing", + data_owner: "giacomo", + data_group: "giacomo", + uid: 1000, + gid: 1000, + }, + ui: Some(BEEZELBOT_SYNCTHING_UI), + }], + } +} + +fn agent_baseline() -> HostProfile { + HostProfile { + id: HostId::known("agent"), + signed_image: None, + masked_units: &[], + network: NetworkPolicy { + ipv4_subnet: "172.26.26.0/24", + ipv4_gateway: "172.26.26.1", + ipv6_subnet: "fd59:4e23:2950:11f5::/64", + ipv6_gateway: "fd59:4e23:2950:11f5::1", + }, + requires_data_mount: false, + services: Vec::new(), + } +} + +/// Resolve a validated host identity to its declared configuration. +pub fn profile_for_host(host: &HostId) -> Option { + match host.as_str() { + "clamps" => Some(clamps()), + "beezelbot" => Some(beezelbot()), + "agent" => Some(agent_baseline()), + _ => None, + } +} + +pub fn profile_for_name(host: &str) -> Option { + HostId::parse(host).and_then(|id| profile_for_host(&id)) +} + +pub fn declared_profiles() -> Vec { + vec![clamps(), beezelbot(), agent_baseline()] +} diff --git a/ublue/skillet/integration_tests/smoke-guest.sh b/ublue/skillet/integration_tests/smoke-guest.sh index 3e437f66..3444c84c 100755 --- a/ublue/skillet/integration_tests/smoke-guest.sh +++ b/ublue/skillet/integration_tests/smoke-guest.sh @@ -3,7 +3,8 @@ set -euo pipefail phase=${1:?phase required} binary=/var/usrlocal/bin/skillet-smoke -clamps_binary=${2:?clamps binary path required} +host_binary=${2:?host binary path required} +credentials_required=${3:?credential capability required} root=/var/lib/skillet-smoke service=skillet-smoke-fixture.service container=skillet-smoke-fixture @@ -17,7 +18,7 @@ fail() { trap 'fail "unexpected command at line $LINENO"' ERR apply() { - timeout 180s "$binary" apply --host skillet-smoke || fail "fixture apply" + timeout 180s "$binary" test fixture-apply || fail "fixture apply" } running() { @@ -76,12 +77,16 @@ case "$phase" in chmod 0600 "$root/desired/secret" timeout 180s podman pull docker.io/library/alpine:3.20 >/dev/null || fail "fixture image pull" - timeout 120s "$clamps_binary" apply --phase base || fail "base apply" - if env -u CREDENTIALS_DIRECTORY timeout 120s "$clamps_binary" apply >"$root/full-apply.stdout" 2>"$root/full-apply.stderr"; then - fail "full clamps apply accepted missing credentials" - fi - if ! grep -Eq 'CREDENTIALS_DIRECTORY|credential' "$root/full-apply.stderr"; then - fail "full clamps apply failed for an unexpected reason" + timeout 120s "$host_binary" apply --phase base || fail "host base apply" + if [[ $credentials_required == yes ]]; then + if env -u CREDENTIALS_DIRECTORY timeout 120s "$host_binary" apply >"$root/full-apply.stdout" 2>"$root/full-apply.stderr"; then + fail "full apply accepted missing declared credentials" + fi + if ! grep -Eq 'CREDENTIALS_DIRECTORY|credential' "$root/full-apply.stderr"; then + fail "full apply failed for an unexpected reason" + fi + elif ! timeout 180s "$host_binary" apply; then + fail "full apply failed without declared credentials" fi apply @@ -120,7 +125,7 @@ case "$phase" in systemctl stop "$service" || fail "stopping fixture before startup failure" rm "$root/allow-start" - if timeout 120s "$binary" apply --host skillet-smoke >"$root/startup-failure.stdout" 2>"$root/startup-failure.stderr"; then + if timeout 120s "$binary" test fixture-apply >"$root/startup-failure.stdout" 2>"$root/startup-failure.stderr"; then fail "startup failure returned success" fi systemctl show "$service" -p ActiveState -p Result > "$root/startup-failure.systemd" @@ -133,7 +138,7 @@ case "$phase" in rm "$root/allow-start" printf 'revision-three\n' > "$root/desired/config" - if timeout 120s "$binary" apply --host skillet-smoke >"$root/interrupted.stdout" 2>"$root/interrupted.stderr"; then + if timeout 120s "$binary" test fixture-apply >"$root/interrupted.stdout" 2>"$root/interrupted.stderr"; then fail "interrupted activation returned success" fi systemctl show "$service" -p ActiveState -p Result > "$root/interrupted.systemd" diff --git a/ublue/skillet/integration_tests/smoke-ssh.sh b/ublue/skillet/integration_tests/smoke-ssh.sh index 5c86abcc..8876ea21 100755 --- a/ublue/skillet/integration_tests/smoke-ssh.sh +++ b/ublue/skillet/integration_tests/smoke-ssh.sh @@ -3,7 +3,9 @@ set -euo pipefail target= binary= -clamps_binary= +host_binary= +host= +credentials_required= port=22 identity= disposable=false @@ -11,7 +13,9 @@ while (($#)); do case "$1" in --target) target=${2:?}; shift 2 ;; --binary) binary=${2:?}; shift 2 ;; - --clamps-binary) clamps_binary=${2:?}; shift 2 ;; + --host-binary) host_binary=${2:?}; shift 2 ;; + --host) host=${2:?}; shift 2 ;; + --credentials-required) credentials_required=${2:?}; shift 2 ;; --port) port=${2:?}; shift 2 ;; --identity) identity=${2:?}; shift 2 ;; --disposable-target) disposable=true; shift ;; @@ -19,20 +23,24 @@ while (($#)); do esac done -if [[ -z $target || -z $binary || -z $clamps_binary || $disposable != true ]]; then - echo 'usage: smoke-ssh.sh --target USER@DISPOSABLE_VM [--port PORT] [--identity PRIVATE_KEY] --disposable-target --binary LOCAL_SKILLET --clamps-binary REMOTE_SKILLET_CLAMPS' >&2 +if [[ -z $target || -z $binary || -z $host_binary || -z $host || -z $credentials_required || $disposable != true ]]; then + echo 'usage: smoke-ssh.sh --target USER@DISPOSABLE_VM --host HOST --credentials-required yes|no [--port PORT] [--identity PRIVATE_KEY] --disposable-target --binary LOCAL_SKILLET --host-binary REMOTE_SKILLET_HOST' >&2 exit 2 fi if [[ ! $target =~ ^[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+$ ]]; then echo 'target must be an explicit USER@HOST or USER@IPv4 address' >&2 exit 2 fi -if [[ $target == *@clamps || $target == *@clamps.* ]] || +if [[ ! $host =~ ^[a-z][a-z0-9-]*$ || ! $credentials_required =~ ^(yes|no)$ ]]; then + echo 'invalid host profile or credential capability' >&2 + exit 2 +fi +if [[ $target == *@"$host" || $target == *@"$host".* ]] || { [[ $target == *@localhost || $target == *@127.0.0.1 ]] && [[ $port == 22 ]]; }; then - echo 'refusing production clamps or a local SSH service on port 22' >&2 + echo "refusing the production host identity $host or a local SSH service on port 22" >&2 exit 2 fi -if [[ ! -f $binary || $clamps_binary != /* || ! $clamps_binary =~ ^/[A-Za-z0-9_./-]+$ ]]; then +if [[ ! -f $binary || $host_binary != /* || ! $host_binary =~ ^/[A-Za-z0-9_./-]+$ ]]; then echo 'binary paths must name a local file and an absolute remote path' >&2 exit 2 fi @@ -59,7 +67,7 @@ ssh "${ssh_opts[@]}" "$target" 'sudo install -m 0755 /var/tmp/skillet-smoke /var boot_before=$(ssh "${ssh_opts[@]}" "$target" 'cat /proc/sys/kernel/random/boot_id') # The remote path is validated above and intentionally expanded on the client. # shellcheck disable=SC2029 -ssh "${ssh_opts[@]}" "$target" "sudo bash /var/tmp/skillet-smoke-guest.sh before-reboot '$clamps_binary'" +ssh "${ssh_opts[@]}" "$target" "sudo bash /var/tmp/skillet-smoke-guest.sh before-reboot '$host_binary' '$credentials_required'" ssh "${ssh_opts[@]}" "$target" 'sudo systemctl reboot' || true deadline=$((SECONDS + 240)) @@ -67,7 +75,7 @@ while (( SECONDS < deadline )); do boot_after=$(ssh "${ssh_opts[@]}" "$target" 'cat /proc/sys/kernel/random/boot_id' 2>/dev/null || true) if [[ -n $boot_after && $boot_after != "$boot_before" ]]; then # shellcheck disable=SC2029 - ssh "${ssh_opts[@]}" "$target" "sudo bash /var/tmp/skillet-smoke-guest.sh after-reboot '$clamps_binary'" + ssh "${ssh_opts[@]}" "$target" "sudo bash /var/tmp/skillet-smoke-guest.sh after-reboot '$host_binary' '$credentials_required'" exit 0 fi sleep 5 From 4144af4ac67ea6356629c712dffc584e22d3b8c8 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 00:18:12 +0200 Subject: [PATCH 143/252] refactor(core): inject host identity and credentials --- ublue/butane/ACCEPTANCE.md | 20 ++ ublue/design/skillet-architecture.md | 7 + ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 22 +- ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/skillet/AGENTS.md | 3 + ublue/skillet/Cargo.lock | 2 - ublue/skillet/crates/cli-common/src/hosts.rs | 4 +- ublue/skillet/crates/cli-common/src/lib.rs | 65 +++++- ublue/skillet/crates/cli/src/main.rs | 17 +- ublue/skillet/crates/core/src/credentials.rs | 24 ++- ublue/skillet/crates/core/src/files.rs | 133 ++++++++++--- ublue/skillet/crates/core/src/files/tests.rs | 19 +- ublue/skillet/crates/core/src/recorder.rs | 44 ++-- ublue/skillet/crates/core/src/resource_op.rs | 11 +- ublue/skillet/crates/core/src/system.rs | 50 ++++- ublue/skillet/crates/core/src/system/tests.rs | 27 +++ ublue/skillet/crates/core/src/test_utils.rs | 188 +++++++++++++----- ublue/skillet/crates/host-profiles/Cargo.toml | 1 - .../crates/host-profiles/src/hosts_tests.rs | 40 +++- ublue/skillet/crates/host-profiles/src/lib.rs | 70 ++++--- .../crates/hosts/beezelbot/src/main.rs | 5 +- ublue/skillet/crates/hosts/clamps/src/main.rs | 5 +- ublue/skillet/crates/podman/Cargo.toml | 1 - ublue/skillet/crates/podman/src/lib.rs | 134 +++++++++---- ublue/skillet/crates/podman/src/tests.rs | 123 ++++++++++++ ublue/skillet/crates/unifi/src/lib.rs | 10 +- ublue/skillet/crates/unifi/src/tests.rs | 37 +++- 27 files changed, 850 insertions(+), 214 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 29a86ee2..f0cbfc3c 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -877,3 +877,23 @@ host tool; it still uses normal command approvals. the test container was removed. - Live base/full apply on a retained VM remains unverified because `/dev/kvm` is absent. No VM was created, contacted, or changed in this slice. + +### Injected account, credential, and ownership inputs, 2026-10-04 + +- Host composition now receives systemd credential values explicitly. The CLI + loads only the profile-declared credentials needed by the selected phase. + Account lookups use the injected system boundary. Podman reads subordinate + ID data via the injected file resource and refuses missing, malformed, + duplicate, overflowing, or undersized ranges instead of using a fixed range. +- Directory ownership now uses one value supporting account names or numeric + IDs. The numeric-ID path does not require a host account, and the fake keeps + mode/ownership state through later existence-only checks. Tests preserve a + child file across repeat apply and cover file-versus-directory errors, + injected filesystem failures, account-ID mismatch, and mapping failures. +- Passed offline workspace tests for core, Podman, UniFi, and full workspace; + pedantic Clippy with warnings denied; formatting; ShellCheck and Bash syntax + checks; and the CI container integration command (beezelbot base apply twice, + then remove the container). +- Workstream 3 remains in progress: the remaining broad file/system trait + decomposition and live ownership/mount checks are outstanding. No live VM + was changed; `/dev/kvm` is absent on this workstation. diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index f70e93ea..89c6a06e 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -28,6 +28,13 @@ no-service apply profile. Synthetic smoke fixture composition lives in a separate test command and is not dispatched as a host identity. Retained-VM runtime acceptance remains pending. +Workstream 3 is in progress. Account observations and subordinate-ID files +enter composition through injected resource interfaces; missing or invalid +subordinate ranges fail closed. Directory ownership uses one typed named or +numeric identity contract, and entry points pass required systemd credentials +to host composition. Remaining broad file/system traits and fake contracts +are still scheduled for this workstream. + Keep application crates as reusable recipes. Separate canonical host profiles and composition from guest runtime adapters, CLI parsing, workstation provisioning, and synthetic test fixtures. Effects need injectable capability diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md index 8bd4ea25..f005c682 100644 --- a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -1,7 +1,8 @@ # 3. Cohesive effect interfaces and unified ownership -Status: planned; follows workstream 2 in [the prerequisite roadmap](SKILLET-REFACTOR.md). -Further feature milestones wait for that roadmap's completion. +Status: in progress; follows workstream 2 in +[the prerequisite roadmap](SKILLET-REFACTOR.md). Further feature milestones +wait for that roadmap's completion. ## Read and locate @@ -61,3 +62,20 @@ ownership use separate APIs; mocks do not enforce important metadata contracts. ownership APIs have become one contract. - Pass roadmap checks and affected VM ownership/mount/repeat-apply checks. Update the design and generic AGENTS interface rules with the finished API. + +## Progress + +- Implemented: host-profile and Podman composition use injected account + lookups; NSS access remains in the Linux system adapter. Podman reads + subordinate-ID files through `FileResource`, validates the selected single + range, and fails closed for missing, invalid, duplicate, or undersized ranges. +- Implemented: directory ownership uses one `Ownership` value with named or + numeric UID/GID. Numeric IDs do not require matching account names. The + fake preserves mode and ownership across existence-only checks; the UniFi + repeat-apply case verifies child data survives. +- Implemented: the guest CLI loads phase-required systemd credentials and + passes `CredentialInputs` to host composition. Recipes no longer inspect + `CREDENTIALS_DIRECTORY`; required names derive from profile consumers. +- Pending: decompose the remaining broad file/system traits, extend fake + contracts for object type and failures, and run live ownership/mount + acceptance. This workstream is not complete. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 81f3cfdd..4f72ca09 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -22,7 +22,7 @@ assignments to execute concurrently against the same interfaces. | --- | --- | --- | --- | | 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | -| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Planned | +| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Planned | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Planned | diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 12f284ea..7b70776f 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -50,6 +50,9 @@ tracks existing violations and their migration, not completed implementation. interface to inspect the live filesystem, account database, process environment, or runtime. Concrete adapters own those operations; load credentials and ambient configuration at the entry point and pass them in. +- Keep account lookup, subordinate-ID reads, and account changes injectable. + Select one explicit mapping policy and report missing or invalid host + identity data instead of silently substituting IDs. - Define traits around cohesive capabilities needed by consumers. Add an operation to the capability that owns it, not an unrelated omnibus trait. Use ordinary data and functions for pure validation, rendering, and static diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 94641a1f..0ba6b69f 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2395,7 +2395,6 @@ dependencies = [ "skillet_syncthing", "skillet_unifi", "thiserror 1.0.69", - "users", ] [[package]] @@ -2421,7 +2420,6 @@ dependencies = [ "skillet_core", "thiserror 1.0.69", "tracing", - "users", ] [[package]] diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 030dbdf4..560aadbc 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -1,5 +1,6 @@ //! CLI phase parsing and compatibility forwarding to canonical host profiles. +use skillet_core::credentials::CredentialInputs; use skillet_core::{files::FileResource, system::SystemResource}; pub use skillet_hosts::{ boot_policy_for_host, declared_profiles, profile_for_host, profile_for_name, @@ -21,11 +22,12 @@ pub fn apply_host_phase( phase: ApplyPhase, system: &dyn SystemResource, files: &dyn FileResource, + credentials: &CredentialInputs, ) -> Result<(), ApplyError> { let phase = match phase { ApplyPhase::Base => skillet_hosts::HostApplyPhase::Base, ApplyPhase::Full => skillet_hosts::HostApplyPhase::Full, ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, }; - skillet_hosts::apply_host_phase(hostname, phase, system, files) + skillet_hosts::apply_host_phase(hostname, phase, system, files, credentials) } diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 1abb7ee5..507f7756 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -1,4 +1,5 @@ use clap::Parser; +use skillet_core::credentials::{CredentialInputs, CredentialManager}; use skillet_core::files::{FileResource, LocalFileResource}; use skillet_core::recorder::Recorder; use skillet_core::system::{LinuxSystemResource, SystemResource}; @@ -26,6 +27,8 @@ pub enum CliCommonError { Yaml(#[from] serde_yml::Error), #[error("Credential error: {0}")] Credential(#[from] credential::CredentialError), + #[error("Systemd credential error: {0}")] + SystemdCredential(#[from] skillet_core::credentials::CredentialError), } #[derive(Parser, Debug)] @@ -72,7 +75,12 @@ pub enum CredentialCommands { pub fn run_host(hostname: &str, apply_fn: F) -> Result<(), CliCommonError> where - F: Fn(ApplyPhase, &dyn SystemResource, &dyn FileResource) -> Result<(), String>, + F: Fn( + ApplyPhase, + &dyn SystemResource, + &dyn FileResource, + &CredentialInputs, + ) -> Result<(), String>, { let args = HostArgs::parse(); @@ -87,9 +95,11 @@ where tracing::subscriber::set_global_default(subscriber)?; match args.command { - HostCommands::Apply { record, phase } => handle_apply(hostname, record, |system, files| { - apply_fn(phase, system, files) - }), + HostCommands::Apply { record, phase } => { + handle_host_apply(hostname, phase, record, |system, files, credentials| { + apply_fn(phase, system, files, credentials) + }) + } HostCommands::Credential { command } => { match command { CredentialCommands::State { name } => { @@ -113,6 +123,49 @@ pub fn handle_apply( ) -> Result<(), CliCommonError> where F: Fn(&dyn SystemResource, &dyn FileResource) -> Result<(), String>, +{ + handle_apply_with_credentials( + hostname, + record_path, + &CredentialInputs::default(), + |system, files, _| apply_fn(system, files), + ) +} + +pub fn handle_host_apply( + hostname: &str, + phase: ApplyPhase, + record_path: Option, + apply_fn: F, +) -> Result<(), CliCommonError> +where + F: Fn(&dyn SystemResource, &dyn FileResource, &CredentialInputs) -> Result<(), String>, +{ + let phase = match phase { + ApplyPhase::Base => skillet_hosts::HostApplyPhase::Base, + ApplyPhase::Full => skillet_hosts::HostApplyPhase::Full, + ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, + }; + let required = skillet_hosts::credentials_for_phase(hostname, phase) + .map_err(|error| CliCommonError::Config(error.to_string()))?; + let mut credentials = CredentialInputs::default(); + if !required.is_empty() { + let manager = CredentialManager::new()?; + for name in required { + credentials.insert(name, manager.read_secret(name)?); + } + } + handle_apply_with_credentials(hostname, record_path, &credentials, apply_fn) +} + +fn handle_apply_with_credentials( + hostname: &str, + record_path: Option, + credentials: &CredentialInputs, + apply_fn: F, +) -> Result<(), CliCommonError> +where + F: Fn(&dyn SystemResource, &dyn FileResource, &CredentialInputs) -> Result<(), String>, { use std::io::Write as _; @@ -125,7 +178,7 @@ where let recorder_system = Recorder::new(system); let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); - apply_fn(&recorder_system, &recorder_files).map_err(CliCommonError::Config)?; + apply_fn(&recorder_system, &recorder_files, credentials).map_err(CliCommonError::Config)?; let ops = recorder_system.get_ops(); let yaml = serde_yml::to_string(&ops)?; @@ -145,7 +198,7 @@ where })?; info!("Recording saved to {}", path.display()); } else { - apply_fn(&system, &files).map_err(CliCommonError::Config)?; + apply_fn(&system, &files, credentials).map_err(CliCommonError::Config)?; } info!("Configuration applied successfully."); diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index c6c462c2..4277f53e 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -261,10 +261,21 @@ fn main() -> Result<()> { .trim() .to_string(); } - skillet_cli_common::handle_apply(&hostname, record, |system, files| { - skillet_cli_common::hosts::apply_host_phase(&hostname, phase, system, files) + skillet_cli_common::handle_host_apply( + &hostname, + phase, + record, + |system, files, credentials| { + skillet_cli_common::hosts::apply_host_phase( + &hostname, + phase, + system, + files, + credentials, + ) .map_err(|error| error.to_string()) - }) + }, + ) .map_err(|error| anyhow!("Failed to apply configuration: {error}"))?; } Commands::Test { diff --git a/ublue/skillet/crates/core/src/credentials.rs b/ublue/skillet/crates/core/src/credentials.rs index 30bca01b..af0d4f0d 100644 --- a/ublue/skillet/crates/core/src/credentials.rs +++ b/ublue/skillet/crates/core/src/credentials.rs @@ -1,5 +1,5 @@ -use std::io::Read as _; use std::path::PathBuf; +use std::{collections::HashMap, io::Read as _}; use thiserror::Error; #[derive(Error, Debug)] @@ -10,6 +10,28 @@ pub enum CredentialError { InvalidName(String), #[error("Failed to read secret {0}: {1}")] ReadError(String, std::io::Error), + #[error("Required credential {0} was not supplied to host composition")] + MissingInput(String), +} + +/// Credential bytes loaded at an entry point and passed explicitly into +/// composition. Values intentionally have no Debug implementation. +#[derive(Default)] +pub struct CredentialInputs { + values: HashMap, +} + +impl CredentialInputs { + pub fn insert(&mut self, name: impl Into, value: String) { + self.values.insert(name.into(), value); + } + + pub fn require(&self, name: &str) -> Result<&str, CredentialError> { + self.values + .get(name) + .map(String::as_str) + .ok_or_else(|| CredentialError::MissingInput(name.to_string())) + } } pub struct CredentialManager { diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 6752eb12..cd008b10 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -1,4 +1,5 @@ use nix::unistd::{chown, fchown, Gid, Uid}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs::{self, File}; use std::io::{self, BufReader, Write}; @@ -46,6 +47,20 @@ pub enum FileError { Btrfs(String, String), } +/// Owner identity can be expressed by a host account name or a numeric ID. +/// Numeric identities do not require a matching NSS entry. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum OwnerIdentity { + Name(String), + Id(u32), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct Ownership { + pub uid: Option, + pub gid: Option, +} + pub trait FileResource { fn read_file(&self, path: &Path) -> Result>, FileError>; fn ensure_file( @@ -56,20 +71,28 @@ pub trait FileResource { owner: Option<&str>, group: Option<&str>, ) -> Result; - fn ensure_directory( + fn ensure_directory_with_ownership( &self, path: &Path, mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result; - fn ensure_directory_with_owner_ids( + fn ensure_directory( &self, path: &Path, mode: Option, - uid: u32, - gid: u32, - ) -> Result; + owner: Option<&str>, + group: Option<&str>, + ) -> Result { + self.ensure_directory_with_ownership( + path, + mode, + &Ownership { + uid: owner.map(|name| OwnerIdentity::Name(name.to_string())), + gid: group.map(|name| OwnerIdentity::Name(name.to_string())), + }, + ) + } fn delete_file(&self, path: &Path) -> Result; fn require_btrfs_subvolume_mount( &self, @@ -192,6 +215,73 @@ impl LocalFileResource { Ok(()) } + fn identity_uid(identity: &OwnerIdentity) -> Result { + match identity { + OwnerIdentity::Name(name) => get_user_by_name(name) + .map(|user| user.uid()) + .ok_or_else(|| FileError::UserNotFound(name.clone())), + OwnerIdentity::Id(uid) => Ok(*uid), + } + } + + fn identity_gid(identity: &OwnerIdentity) -> Result { + match identity { + OwnerIdentity::Name(name) => get_group_by_name(name) + .map(|group| group.gid()) + .ok_or_else(|| FileError::GroupNotFound(name.clone())), + OwnerIdentity::Id(gid) => Ok(*gid), + } + } + + fn check_directory_metadata( + path: &Path, + mode: Option, + ownership: &Ownership, + ) -> Result { + let metadata = fs::metadata(path) + .map_err(|error| FileError::Read(path.display().to_string(), error))?; + let uid = ownership.uid.as_ref().map(Self::identity_uid).transpose()?; + let gid = ownership.gid.as_ref().map(Self::identity_gid).transpose()?; + Ok( + mode.is_some_and(|desired| metadata.permissions().mode() & 0o7777 != desired) + || uid.is_some_and(|desired| metadata.uid() != desired) + || gid.is_some_and(|desired| metadata.gid() != desired), + ) + } + + fn apply_directory_metadata( + path: &Path, + mode: Option, + ownership: &Ownership, + ) -> Result<(), FileError> { + if let Some(mode) = mode { + let mut permissions = fs::metadata(path) + .map_err(|error| FileError::Read(path.display().to_string(), error))? + .permissions(); + permissions.set_mode(mode); + fs::set_permissions(path, permissions) + .map_err(|error| FileError::SetPermissions(path.display().to_string(), error))?; + } + if ownership.uid.is_some() || ownership.gid.is_some() { + let uid = ownership + .uid + .as_ref() + .map(Self::identity_uid) + .transpose()? + .map(Uid::from_raw); + let gid = ownership + .gid + .as_ref() + .map(Self::identity_gid) + .transpose()? + .map(Gid::from_raw); + chown(path, uid, gid).map_err(|error| { + FileError::SetOwnership(path.display().to_string(), error.to_string()) + })?; + } + Ok(()) + } + fn get_file_hash(path: &Path) -> Result, FileError> { let file = File::open(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; let mut reader = BufReader::new(file); @@ -338,12 +428,11 @@ impl FileResource for LocalFileResource { Ok(changed) } - fn ensure_directory( + fn ensure_directory_with_ownership( &self, path: &Path, mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result { let mut changed = false; @@ -381,8 +470,8 @@ impl FileResource for LocalFileResource { } } - if path.exists() && Self::check_metadata(path, mode, owner, group)? { - Self::apply_metadata(path, mode, owner, group)?; + if path.exists() && Self::check_directory_metadata(path, mode, ownership)? { + Self::apply_directory_metadata(path, mode, ownership)?; changed = true; info!("Updated directory metadata for {}", path.display()); } @@ -390,26 +479,6 @@ impl FileResource for LocalFileResource { Ok(changed) } - fn ensure_directory_with_owner_ids( - &self, - path: &Path, - mode: Option, - uid: u32, - gid: u32, - ) -> Result { - let mut changed = self.ensure_directory(path, mode, None, None)?; - let metadata = fs::metadata(path) - .map_err(|error| FileError::Read(path.display().to_string(), error))?; - if metadata.uid() != uid || metadata.gid() != gid { - chown(path, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid))).map_err(|error| { - FileError::SetOwnership(path.display().to_string(), error.to_string()) - })?; - changed = true; - info!("Updated directory numeric ownership for {}", path.display()); - } - Ok(changed) - } - fn delete_file(&self, path: &Path) -> Result { if path.exists() { let metadata = fs::symlink_metadata(path) diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs index 22f26134..0df318cb 100644 --- a/ublue/skillet/crates/core/src/files/tests.rs +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -155,6 +155,7 @@ fn test_ensure_directory_creates_dir() { #[test] fn directory_numeric_owner_is_idempotent() { + use super::{OwnerIdentity, Ownership}; use std::os::unix::fs::MetadataExt; let dir = tempdir().unwrap(); @@ -163,10 +164,24 @@ fn directory_numeric_owner_is_idempotent() { let resource = LocalFileResource::new(); assert!(resource - .ensure_directory_with_owner_ids(&path, Some(0o750), parent.uid(), parent.gid()) + .ensure_directory_with_ownership( + &path, + Some(0o750), + &Ownership { + uid: Some(OwnerIdentity::Id(parent.uid())), + gid: Some(OwnerIdentity::Id(parent.gid())), + }, + ) .unwrap()); assert!(!resource - .ensure_directory_with_owner_ids(&path, Some(0o750), parent.uid(), parent.gid()) + .ensure_directory_with_ownership( + &path, + Some(0o750), + &Ownership { + uid: Some(OwnerIdentity::Id(parent.uid())), + gid: Some(OwnerIdentity::Id(parent.gid())), + }, + ) .unwrap()); let metadata = fs::metadata(path).unwrap(); diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index 3a180c77..bb56c227 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -1,6 +1,6 @@ -use crate::files::{FileError, FileResource}; +use crate::files::{FileError, FileResource, Ownership}; use crate::resource_op::ResourceOp; -use crate::system::{SystemError, SystemResource}; +use crate::system::{AccountResource, GroupIdentity, SystemError, SystemResource, UserIdentity}; use sha2::{Digest, Sha256}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -89,38 +89,20 @@ impl FileResource for Recorder { self.inner.ensure_file(path, content, mode, owner, group) } - fn ensure_directory( + fn ensure_directory_with_ownership( &self, path: &Path, mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result { self.record(ResourceOp::EnsureDirectory { path: path.display().to_string(), mode: mode.map(|m| format!("0o{m:o}")), - owner: owner.map(ToString::to_string), - group: group.map(ToString::to_string), + ownership: ownership.clone(), }); - self.inner.ensure_directory(path, mode, owner, group) - } - - fn ensure_directory_with_owner_ids( - &self, - path: &Path, - mode: Option, - uid: u32, - gid: u32, - ) -> Result { - self.record(ResourceOp::EnsureDirectoryWithOwnerIds { - path: path.display().to_string(), - mode: mode.map(|m| format!("0o{m:o}")), - uid, - gid, - }); self.inner - .ensure_directory_with_owner_ids(path, mode, uid, gid) + .ensure_directory_with_ownership(path, mode, ownership) } fn delete_file(&self, path: &Path) -> Result { @@ -212,3 +194,17 @@ impl SystemResource for Recorder { self.inner.daemon_reload() } } + +impl AccountResource for Recorder { + fn user_by_name(&self, name: &str) -> Result, SystemError> { + self.inner.user_by_name(name) + } + + fn user_by_uid(&self, uid: u32) -> Result, SystemError> { + self.inner.user_by_uid(uid) + } + + fn group_by_name(&self, name: &str) -> Result, SystemError> { + self.inner.group_by_name(name) + } +} diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index 4a277fc3..b13b7255 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -1,3 +1,4 @@ +use crate::files::Ownership; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] @@ -15,14 +16,8 @@ pub enum ResourceOp { EnsureDirectory { path: String, mode: Option, - owner: Option, - group: Option, - }, - EnsureDirectoryWithOwnerIds { - path: String, - mode: Option, - uid: u32, - gid: u32, + #[serde(default)] + ownership: Ownership, }, EnsureBtrfsSubvolume { path: String, diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 44b0c0e0..94e1b021 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -3,7 +3,7 @@ use std::process::{Command, Stdio}; use std::sync::LazyLock; use thiserror::Error; use tracing::{debug, info, warn}; -use users::{get_group_by_name, get_user_by_name}; +use users::{get_group_by_name, get_user_by_name, get_user_by_uid}; use zbus::proxy; static SYSTEMD_UNIT_SUFFIXES: LazyLock> = LazyLock::new(|| { @@ -59,7 +59,28 @@ pub enum SystemError { Io(#[from] std::io::Error), } -pub trait SystemResource { +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserIdentity { + pub name: String, + pub uid: u32, + pub primary_gid: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GroupIdentity { + pub name: String, + pub gid: u32, +} + +/// Account lookups needed by host and container identity configuration. +/// Implementations are the only layer that consults NSS. +pub trait AccountResource { + fn user_by_name(&self, name: &str) -> Result, SystemError>; + fn user_by_uid(&self, uid: u32) -> Result, SystemError>; + fn group_by_name(&self, name: &str) -> Result, SystemError>; +} + +pub trait SystemResource: AccountResource { fn ensure_group(&self, name: &str, gid: Option) -> Result; fn ensure_user( &self, @@ -183,6 +204,31 @@ impl Default for LinuxSystemResource { } } +impl AccountResource for LinuxSystemResource { + fn user_by_name(&self, name: &str) -> Result, SystemError> { + Ok(get_user_by_name(name).map(|user| UserIdentity { + name: user.name().to_string_lossy().into_owned(), + uid: user.uid(), + primary_gid: user.primary_group_id(), + })) + } + + fn user_by_uid(&self, uid: u32) -> Result, SystemError> { + Ok(get_user_by_uid(uid).map(|user| UserIdentity { + name: user.name().to_string_lossy().into_owned(), + uid: user.uid(), + primary_gid: user.primary_group_id(), + })) + } + + fn group_by_name(&self, name: &str) -> Result, SystemError> { + Ok(get_group_by_name(name).map(|group| GroupIdentity { + name: group.name().to_string_lossy().into_owned(), + gid: group.gid(), + })) + } +} + const EXIT_CODE_GROUP_EXISTS: i32 = 9; const EXIT_CODE_USER_EXISTS: i32 = 9; diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 1dd4c6b8..0378075c 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -45,3 +45,30 @@ fn test_mock_system_services() { "restarted" ); } + +#[test] +#[cfg(feature = "test-utils")] +fn mock_account_boundary_preserves_and_checks_numeric_identity() { + use super::AccountResource; + + let system = MockSystem::new(); + assert!(system + .ensure_user("service", Some(1042), Some(2042)) + .unwrap()); + assert_eq!( + system.user_by_name("service").unwrap(), + Some(super::UserIdentity { + name: "service".to_string(), + uid: 1042, + primary_gid: 2042, + }) + ); + assert_eq!(system.user_by_uid(1042).unwrap().unwrap().name, "service"); + assert!(!system + .ensure_user("service", Some(1042), Some(2042)) + .unwrap()); + assert!(system + .ensure_user("service", Some(1043), Some(2042)) + .is_err()); + assert!(system.group_by_name("service").unwrap().is_some()); +} diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 1702ac21..67091609 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -1,5 +1,5 @@ -use crate::files::{FileError, FileResource}; -use crate::system::{SystemError, SystemResource}; +use crate::files::{FileError, FileResource, Ownership}; +use crate::system::{AccountResource, GroupIdentity, SystemError, SystemResource, UserIdentity}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -9,6 +9,8 @@ use std::sync::{Arc, Mutex}; pub struct MockSystem { pub groups: Arc>>, pub users: Arc>>, + pub user_identities: Arc>>, + pub group_identities: Arc>>, pub podman_secrets: Arc>>, pub secret_ids: Arc>>, pub fail_restart_once: Arc, @@ -23,6 +25,8 @@ impl MockSystem { Self { groups: Arc::new(Mutex::new(HashSet::new())), users: Arc::new(Mutex::new(HashSet::new())), + user_identities: Arc::new(Mutex::new(HashMap::new())), + group_identities: Arc::new(Mutex::new(HashMap::new())), podman_secrets: Arc::new(Mutex::new(HashSet::new())), secret_ids: Arc::new(Mutex::new(HashMap::new())), fail_restart_once: Arc::new(AtomicBool::new(false)), @@ -40,6 +44,36 @@ impl Default for MockSystem { } } +impl AccountResource for MockSystem { + fn user_by_name(&self, name: &str) -> Result, SystemError> { + Ok(self + .user_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .cloned()) + } + + fn user_by_uid(&self, uid: u32) -> Result, SystemError> { + Ok(self + .user_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .find(|user| user.uid == uid) + .cloned()) + } + + fn group_by_name(&self, name: &str) -> Result, SystemError> { + Ok(self + .group_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .cloned()) + } +} + impl SystemResource for MockSystem { fn podman_secret_id(&self, name: &str) -> Result { self.secret_ids @@ -60,35 +94,75 @@ impl SystemResource for MockSystem { Some("started" | "restarted") )) } - fn ensure_group(&self, name: &str, _gid: Option) -> Result { + fn ensure_group(&self, name: &str, gid: Option) -> Result { let mut groups = self .groups .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if groups.contains(name) { - Ok(false) - } else { - groups.insert(name.to_string()); - Ok(true) + let mut identities = self + .group_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = identities.get(name) { + if gid.is_some_and(|desired| desired != existing.gid) { + return Err(SystemError::GroupCheck(format!( + "Group {name} exists with GID {}, requested {gid:?}", + existing.gid + ))); + } + return Ok(false); } + groups.insert(name.to_string()); + identities.insert( + name.to_string(), + GroupIdentity { + name: name.to_string(), + gid: gid.unwrap_or(2000), + }, + ); + Ok(true) } fn ensure_user( &self, name: &str, - _uid: Option, - _gid: Option, + uid: Option, + gid: Option, ) -> Result { + if let Some(gid) = gid { + self.ensure_group(name, Some(gid))?; + } let mut users = self .users .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if users.contains(name) { - Ok(false) - } else { - users.insert(name.to_string()); - Ok(true) + let mut identities = self + .user_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = identities.get(name) { + if uid.is_some_and(|desired| desired != existing.uid) + || gid.is_some_and(|desired| desired != existing.primary_gid) + { + return Err(SystemError::Command(format!( + "User {name} exists with UID:GID {}:{}, requested {uid:?}:{gid:?}", + existing.uid, existing.primary_gid + ))); + } + return Ok(false); } + let uid = uid.unwrap_or(2000); + let primary_gid = gid.unwrap_or(2000); + users.insert(name.to_string()); + identities.insert( + name.to_string(), + UserIdentity { + name: name.to_string(), + uid, + primary_gid, + }, + ); + Ok(true) } fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { @@ -171,13 +245,17 @@ impl SystemResource for MockSystem { } pub type FileMetadata = (Option, Option, Option); +pub type DirectoryMetadata = (Option, Ownership); +pub type DirectoryMetadataMap = Arc>>; pub struct MockFiles { pub files: Arc>>>, pub metadata: Arc>>, pub directories: Arc>>, - pub directory_owner_ids: Arc>>, + pub directory_metadata: DirectoryMetadataMap, pub fail_btrfs_mount_check: Arc, + pub fail_file_write_once: Arc, + pub fail_directory_once: Arc, } impl MockFiles { @@ -186,8 +264,10 @@ impl MockFiles { files: Arc::new(Mutex::new(HashMap::new())), metadata: Arc::new(Mutex::new(HashMap::new())), directories: Arc::new(Mutex::new(HashSet::new())), - directory_owner_ids: Arc::new(Mutex::new(HashMap::new())), + directory_metadata: Arc::new(Mutex::new(HashMap::new())), fail_btrfs_mount_check: Arc::new(AtomicBool::new(false)), + fail_file_write_once: Arc::new(AtomicBool::new(false)), + fail_directory_once: Arc::new(AtomicBool::new(false)), } } } @@ -237,6 +317,19 @@ impl FileResource for MockFiles { group: Option<&str>, ) -> Result { let path_str = path.display().to_string(); + if self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&path_str) + { + return Err(FileError::NotAFile(path_str)); + } + if self.fail_file_write_once.swap(false, Ordering::SeqCst) { + return Err(FileError::Io(std::io::Error::other( + "injected file write failure", + ))); + } let mut files = self .files .lock() @@ -276,45 +369,48 @@ impl FileResource for MockFiles { Ok(changed) } - fn ensure_directory( + fn ensure_directory_with_ownership( &self, path: &Path, - _mode: Option, - _owner: Option<&str>, - _group: Option<&str>, + mode: Option, + ownership: &Ownership, ) -> Result { let path_str = path.display().to_string(); + if self + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&path_str) + { + return Err(FileError::NotADirectory(path_str)); + } + if self.fail_directory_once.swap(false, Ordering::SeqCst) { + return Err(FileError::Io(std::io::Error::other( + "injected directory failure", + ))); + } let mut directories = self .directories .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if directories.contains(&path_str) { - Ok(false) - } else { - directories.insert(path_str); - Ok(true) - } - } - - fn ensure_directory_with_owner_ids( - &self, - path: &Path, - mode: Option, - uid: u32, - gid: u32, - ) -> Result { - let changed = self.ensure_directory(path, mode, None, None)?; - let path_str = path.display().to_string(); - let mut owners = self - .directory_owner_ids + let was_present = directories.contains(&path_str); + directories.insert(path_str.clone()); + drop(directories); + let mut metadata = self + .directory_metadata .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if owners.get(&path_str) == Some(&(uid, gid)) { - Ok(changed) - } else { - owners.insert(path_str, (uid, gid)); - Ok(true) - } + let current = metadata.get(&path_str).cloned().unwrap_or_default(); + let desired = ( + mode.or(current.0), + Ownership { + uid: ownership.uid.clone().or(current.1.uid), + gid: ownership.gid.clone().or(current.1.gid), + }, + ); + let unchanged = was_present && metadata.get(&path_str) == Some(&desired); + metadata.insert(path_str, desired); + Ok(!unchanged) } fn delete_file(&self, path: &Path) -> Result { diff --git a/ublue/skillet/crates/host-profiles/Cargo.toml b/ublue/skillet/crates/host-profiles/Cargo.toml index 6aa86e58..853bfe0e 100644 --- a/ublue/skillet/crates/host-profiles/Cargo.toml +++ b/ublue/skillet/crates/host-profiles/Cargo.toml @@ -15,7 +15,6 @@ skillet_syncthing.workspace = true skillet_btrbk.workspace = true skillet_caddy.workspace = true skillet_podman.workspace = true -users.workspace = true thiserror.workspace = true [dev-dependencies] diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 305397f1..f7d3284c 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -4,7 +4,9 @@ use super::{ }, tailscale_config, ui_config_for_host, TAILSCALE_AUTH_KEY_CREDENTIAL, }; -use skillet_core::{files::LocalFileResource, test_utils::MockSystem}; +use skillet_core::{ + credentials::CredentialInputs, files::LocalFileResource, test_utils::MockSystem, +}; use skillet_podman::SecretTarget; #[test] @@ -123,12 +125,46 @@ fn agent_baseline_declares_no_ui_or_credentials_and_unknown_full_profiles_fail() let system = MockSystem::new(); let files = LocalFileResource::new(); assert!(matches!( - super::apply_host("unknown-host", &system, &files), + super::apply_host( + "unknown-host", + &system, + &files, + &CredentialInputs::default() + ), Err(super::ApplyError::UnknownHost(_)) )); assert!(system.podman_secrets.lock().unwrap().is_empty()); } +#[test] +fn credential_values_are_selected_by_host_and_apply_phase() { + assert!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Base) + .unwrap() + .is_empty() + ); + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Full).unwrap(), + [ + super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + super::TAILSCALE_AUTH_KEY_CREDENTIAL + ] + ); + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Caddy).unwrap(), + [ + super::CADDY_SITES_CREDENTIAL, + super::CLOUDFLARE_ACME_TOKEN_CREDENTIAL + ] + ); + assert!( + super::credentials_for_phase("beezelbot", super::HostApplyPhase::Full) + .unwrap() + .is_empty() + ); + assert!(super::credentials_for_phase("unknown", super::HostApplyPhase::Base).is_err()); +} + #[test] fn host_ui_declarations_include_only_the_services_each_host_runs() { let clamps = ui_config_for_host("clamps").expect("clamps UI declaration"); diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index abec08e7..96dfde2d 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -1,7 +1,7 @@ //! Canonical host profiles and their shared composition. use skillet_core::{ - credentials::{CredentialError, CredentialManager}, + credentials::{CredentialError, CredentialInputs}, files::{FileError, FileResource}, system::{SystemError, SystemResource}, }; @@ -24,6 +24,27 @@ pub enum HostApplyPhase { Caddy, } +pub fn credentials_for_phase( + hostname: &str, + phase: HostApplyPhase, +) -> Result, ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; + if phase == HostApplyPhase::Base { + return Ok(Vec::new()); + } + Ok(profile + .credential_consumers() + .into_iter() + .filter(|consumer| match phase { + HostApplyPhase::Full => consumer.unit != "caddy.service", + HostApplyPhase::Caddy => consumer.unit == "caddy.service", + HostApplyPhase::Base => false, + }) + .map(|consumer| consumer.credential) + .collect()) +} + /// Explicit guest boot expectations. Consolidated with the canonical capability /// declaration in refactoring workstream 2; the VM orchestrator has no host cases. pub struct HostBootPolicy { @@ -68,20 +89,6 @@ pub enum ApplyError { UnknownHost(String), } -mod user_lookup { - use users::{get_group_by_name, get_user_by_name}; - - /// Look up UID for a username, returns None if user doesn't exist - pub fn lookup_uid(username: &str) -> Option { - get_user_by_name(username).map(|user| user.uid()) - } - - /// Look up GID for a group name, returns None if group doesn't exist - pub fn lookup_gid(groupname: &str) -> Option { - get_group_by_name(groupname).map(|group| group.gid()) - } -} - /// Shared host baseline used by both CLI entry points. pub fn apply_base(system: &dyn SystemResource, files: &dyn FileResource) -> Result<(), ApplyError> { skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) @@ -204,10 +211,11 @@ pub fn apply_host( hostname: &str, system: &dyn SystemResource, files: &dyn FileResource, + credentials: &CredentialInputs, ) -> Result<(), ApplyError> { let profile = profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; - apply_profile(&profile, system, files) + apply_profile(&profile, system, files, credentials) } #[allow(clippy::similar_names)] @@ -215,6 +223,7 @@ fn apply_profile( profile: &HostProfile, system: &dyn SystemResource, files: &dyn FileResource, + credentials: &CredentialInputs, ) -> Result<(), ApplyError> { apply_base(system, files)?; if profile.requires_data_mount { @@ -227,15 +236,18 @@ fn apply_profile( for service in &profile.services { match &service.config { ServiceConfig::Pihole { custom_dns } => { - let password = - CredentialManager::new()?.read_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL)?; + let password = credentials + .require(PIHOLE_WEB_PASSWORD_CREDENTIAL)? + .to_string(); system.ensure_podman_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL, &password)?; + let user = system.user_by_name("pihole")?; + let group = system.group_by_name("pihole")?; skillet_pihole::apply( system, files, &skillet_pihole::PiholeUser { - uid: user_lookup::lookup_uid("pihole"), - gid: user_lookup::lookup_gid("pihole"), + uid: user.map(|identity| identity.uid), + gid: group.map(|identity| identity.gid), name: "pihole".to_string(), group_name: "pihole".to_string(), }, @@ -258,8 +270,9 @@ fn apply_profile( ServiceConfig::Syncthing { .. } => apply_syncthing(system, files, profile, service)?, ServiceConfig::Unifi => skillet_unifi::apply(system, files)?, ServiceConfig::Tailscale { state_path } => { - let auth_key = - CredentialManager::new()?.read_secret(TAILSCALE_AUTH_KEY_CREDENTIAL)?; + let auth_key = credentials + .require(TAILSCALE_AUTH_KEY_CREDENTIAL)? + .to_string(); system.ensure_podman_secret(TAILSCALE_AUTH_KEY_CREDENTIAL, &auth_key)?; let hostname = runtime_hostname(files, profile.id.as_str())?; skillet_podman::container( @@ -310,11 +323,12 @@ pub fn apply_host_phase( phase: HostApplyPhase, system: &dyn SystemResource, files: &dyn FileResource, + credentials: &CredentialInputs, ) -> Result<(), ApplyError> { match phase { HostApplyPhase::Base => apply_base(system, files), - HostApplyPhase::Full => apply_host(hostname, system, files), - HostApplyPhase::Caddy => apply_caddy_host(hostname, system, files), + HostApplyPhase::Full => apply_host(hostname, system, files, credentials), + HostApplyPhase::Caddy => apply_caddy_host(hostname, system, files, credentials), } } @@ -322,6 +336,7 @@ fn apply_caddy_host( hostname: &str, system: &dyn SystemResource, files: &dyn FileResource, + credentials: &CredentialInputs, ) -> Result<(), ApplyError> { let profile = profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; @@ -342,13 +357,14 @@ fn apply_caddy_host( "/data", )?; } - let credentials = CredentialManager::new()?; let sites = skillet_caddy::CaddySites::parse( - &credentials.read_secret(CADDY_SITES_CREDENTIAL)?, + credentials.require(CADDY_SITES_CREDENTIAL)?, hostname, &ui_config.services, )?; - let token = credentials.read_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL)?; + let token = credentials + .require(CLOUDFLARE_ACME_TOKEN_CREDENTIAL)? + .to_string(); system.ensure_podman_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL, &token)?; skillet_caddy::apply(system, files, &sites, ui_config.network)?; Ok(()) diff --git a/ublue/skillet/crates/hosts/beezelbot/src/main.rs b/ublue/skillet/crates/hosts/beezelbot/src/main.rs index 19d54a03..7b788874 100644 --- a/ublue/skillet/crates/hosts/beezelbot/src/main.rs +++ b/ublue/skillet/crates/hosts/beezelbot/src/main.rs @@ -2,8 +2,9 @@ use anyhow::Result; use skillet_cli_common::{hosts, run_host}; fn main() -> Result<()> { - run_host("beezelbot", |phase, system, files| { - hosts::apply_host_phase("beezelbot", phase, system, files).map_err(|e| e.to_string()) + run_host("beezelbot", |phase, system, files, credentials| { + hosts::apply_host_phase("beezelbot", phase, system, files, credentials) + .map_err(|e| e.to_string()) })?; Ok(()) } diff --git a/ublue/skillet/crates/hosts/clamps/src/main.rs b/ublue/skillet/crates/hosts/clamps/src/main.rs index 14c41a56..37c1a565 100644 --- a/ublue/skillet/crates/hosts/clamps/src/main.rs +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -2,8 +2,9 @@ use anyhow::Result; use skillet_cli_common::{hosts, run_host}; fn main() -> Result<()> { - run_host("clamps", |phase, system, files| { - hosts::apply_host_phase("clamps", phase, system, files).map_err(|e| e.to_string()) + run_host("clamps", |phase, system, files, credentials| { + hosts::apply_host_phase("clamps", phase, system, files, credentials) + .map_err(|e| e.to_string()) })?; Ok(()) } diff --git a/ublue/skillet/crates/podman/Cargo.toml b/ublue/skillet/crates/podman/Cargo.toml index 19105646..37087199 100644 --- a/ublue/skillet/crates/podman/Cargo.toml +++ b/ublue/skillet/crates/podman/Cargo.toml @@ -12,7 +12,6 @@ thiserror.workspace = true tracing.workspace = true serde.workspace = true askama.workspace = true -users.workspace = true sha2.workspace = true hex.workspace = true diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index ad67c0a3..4e53297d 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -4,10 +4,9 @@ use skillet_core::files::{FileError, FileResource}; use skillet_core::system::{SystemError, SystemResource}; use std::collections::BTreeMap; use std::fmt::Write as _; -use std::path::Path; +use std::{path::Path, str::FromStr}; use thiserror::Error; use tracing::info; -use users::{get_user_by_name, get_user_by_uid}; #[derive(Error, Debug)] pub enum PodmanError { @@ -21,6 +20,14 @@ pub enum PodmanError { InvalidNetworkName(String), #[error("Network configuration changed for {0}; stop its consumers, remove the Podman network and its applied marker, then apply again")] NetworkConfigChanged(String), + #[error("No valid {kind} subordinate-ID range for account {account}")] + MissingSubordinateRange { kind: &'static str, account: String }, + #[error("Invalid subordinate-ID range in {path} for account {account}")] + InvalidSubordinateRange { path: &'static str, account: String }, + #[error( + "Subordinate-ID range for account {account} is too small for container ID {container_id}" + )] + SubordinateRangeTooSmall { account: String, container_id: u32 }, } #[derive(Template)] @@ -116,6 +123,7 @@ pub struct PodmanNetwork { pub options: Vec, } +#[allow(clippy::similar_names)] pub fn container(system: &S, files: &F, config: PodmanConfig) -> Result where S: SystemResource + ?Sized, @@ -180,13 +188,17 @@ where // 2. Calculate mappings if let Some((uid_host, gid_host, username)) = &host_info { + let sub_uid = discover_subid_range(files, "/etc/subuid", username, "UID")?; + let sub_gid = discover_subid_range(files, "/etc/subgid", username, "GID")?; calculate_user_mappings( &config.user, *uid_host, *gid_host, username, + sub_uid, + sub_gid, &mut extra_config, - ); + )?; } // 3. Ensure volumes and secrets @@ -282,20 +294,16 @@ fn resolve_host_user( if create { system.ensure_user(n, None, None)?; } - let u = get_user_by_name(n).ok_or_else(|| { + let u = system.user_by_name(n)?.ok_or_else(|| { PodmanError::UserMapping(format!("User {n} not found on host")) })?; - (n.clone(), u.uid(), u.primary_group_id()) + (n.clone(), u.uid, u.primary_gid) } HostUser::Uid(u) => { - let u_info = get_user_by_uid(*u).ok_or_else(|| { + let u_info = system.user_by_uid(*u)?.ok_or_else(|| { PodmanError::UserMapping(format!("UID {u} not found on host")) })?; - ( - u_info.name().to_string_lossy().to_string(), - *u, - u_info.primary_group_id(), - ) + (u_info.name, *u, u_info.primary_gid) } }; Ok(Some((uid, gid, username))) @@ -312,68 +320,114 @@ fn calculate_user_mappings( uid_host: u32, gid_host: u32, username: &str, + sub_uid: SubordinateRange, + sub_gid: SubordinateRange, extra_config: &mut BTreeMap>, -) { +) -> Result<(), PodmanError> { let uid_container = user.container_uid; let gid_container = user.container_gid; - - let (sub_uid_base, sub_uid_size) = - discover_subid_range("/etc/subuid", username).unwrap_or((100_000, 65_536)); - let (sub_gid_base, sub_gid_size) = - discover_subid_range("/etc/subgid", username).unwrap_or((100_000, 65_536)); + validate_subordinate_range(sub_uid, username, uid_container)?; + validate_subordinate_range(sub_gid, username, gid_container)?; let container_section = extra_config.entry("Container".to_string()).or_default(); container_section.push(format!("User={uid_container}:{gid_container}")); // UIDMap if uid_container > 0 { - container_section.push(format!("UIDMap=0:{sub_uid_base}:{uid_container}")); + container_section.push(format!("UIDMap=0:{}:{uid_container}", sub_uid.start)); } container_section.push(format!("UIDMap={uid_container}:{uid_host}:1")); - // Remaining subordinate UIDs after the container UID; saturating so a - // too-small subuid range degrades to no remainder mapping instead of - // panicking on underflow. - let rem_u = sub_uid_size.saturating_sub(uid_container).saturating_sub(1); + let rem_u = sub_uid.size - uid_container - 1; if rem_u > 0 { container_section.push(format!( "UIDMap={}:{}:{rem_u}", uid_container + 1, - sub_uid_base + uid_container + 1 + sub_uid.start + uid_container + 1 )); } // GIDMap if gid_container > 0 { - container_section.push(format!("GIDMap=0:{sub_gid_base}:{gid_container}")); + container_section.push(format!("GIDMap=0:{}:{gid_container}", sub_gid.start)); } container_section.push(format!("GIDMap={gid_container}:{gid_host}:1")); - // Same underflow protection as the UID remainder above. - let rem_g = sub_gid_size.saturating_sub(gid_container).saturating_sub(1); + let rem_g = sub_gid.size - gid_container - 1; if rem_g > 0 { container_section.push(format!( "GIDMap={}:{}:{rem_g}", gid_container + 1, - sub_gid_base + gid_container + 1 + sub_gid.start + gid_container + 1 )); } + Ok(()) } -fn discover_subid_range(path: &str, username: &str) -> Option<(u32, u32)> { - use std::fs::File; - use std::io::{BufRead, BufReader}; - - let file = File::open(path).ok()?; - let reader = BufReader::new(file); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SubordinateRange { + start: u32, + size: u32, +} - for line in reader.lines().map_while(Result::ok) { - let parts: Vec<&str> = line.split(':').collect(); - if parts.len() == 3 && parts[0] == username { - let start = parts[1].parse().ok()?; - let size = parts[2].parse().ok()?; - return Some((start, size)); +fn discover_subid_range( + files: &F, + path: &'static str, + username: &str, + kind: &'static str, +) -> Result { + let contents = + files + .read_file(Path::new(path))? + .ok_or_else(|| PodmanError::MissingSubordinateRange { + kind, + account: username.to_string(), + })?; + let contents = + std::str::from_utf8(&contents).map_err(|_| PodmanError::InvalidSubordinateRange { + path, + account: username.to_string(), + })?; + let mut matching = contents + .lines() + .filter(|line| line.split(':').next() == Some(username)); + let line = matching + .next() + .ok_or_else(|| PodmanError::MissingSubordinateRange { + kind, + account: username.to_string(), + })?; + if matching.next().is_some() { + return Err(PodmanError::InvalidSubordinateRange { + path, + account: username.to_string(), + }); + } + let mut fields = line.split(':'); + let _name = fields.next(); + let start = fields.next().and_then(|value| u32::from_str(value).ok()); + let size = fields.next().and_then(|value| u32::from_str(value).ok()); + match (start, size, fields.next()) { + (Some(start), Some(size), None) if size > 0 && start.checked_add(size).is_some() => { + Ok(SubordinateRange { start, size }) } + _ => Err(PodmanError::InvalidSubordinateRange { + path, + account: username.to_string(), + }), + } +} + +fn validate_subordinate_range( + range: SubordinateRange, + account: &str, + container_id: u32, +) -> Result<(), PodmanError> { + if range.size <= container_id { + return Err(PodmanError::SubordinateRangeTooSmall { + account: account.to_string(), + container_id, + }); } - None + Ok(()) } fn render_and_ensure_quadlet( diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index 21dd9581..e98d4385 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -174,3 +174,126 @@ fn consumed_file_change_restarts_even_when_quadlet_is_unchanged() { container(&system, &files, repeat).unwrap(); assert_eq!(system.restart_count.load(Ordering::SeqCst), 2); } + +#[test] +fn subordinate_ranges_are_read_from_injected_files_and_render_user_maps() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "payload").unwrap(); + system.user_identities.lock().unwrap().insert( + "service".to_string(), + skillet_core::system::UserIdentity { + name: "service".to_string(), + uid: 1234, + primary_gid: 2345, + }, + ); + let files = MockFiles::new(); + files.files.lock().unwrap().insert( + "/etc/subuid".to_string(), + b"other:300000:65536\nservice:200000:65536\n".to_vec(), + ); + files.files.lock().unwrap().insert( + "/etc/subgid".to_string(), + b"service:400000:65536\n".to_vec(), + ); + let mut config = fixture(); + config.user = ContainerUser { + container_uid: 999, + container_gid: 999, + host_user: Some(HostUser::Name("service".to_string())), + }; + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("User=999:999")); + assert!(quadlet.contains("UIDMap=0:200000:999")); + assert!(quadlet.contains("UIDMap=999:1234:1")); + assert!(quadlet.contains("GIDMap=0:400000:999")); + assert!(quadlet.contains("GIDMap=999:2345:1")); +} + +#[test] +fn missing_or_invalid_subordinate_ranges_fail_closed() { + let missing = MockFiles::new(); + assert!(matches!( + discover_subid_range(&missing, "/etc/subuid", "service", "UID"), + Err(PodmanError::MissingSubordinateRange { kind: "UID", .. }) + )); + + let invalid = MockFiles::new(); + invalid.files.lock().unwrap().insert( + "/etc/subuid".to_string(), + b"service:not-a-number:5\n".to_vec(), + ); + assert!(matches!( + discover_subid_range(&invalid, "/etc/subuid", "service", "UID"), + Err(PodmanError::InvalidSubordinateRange { .. }) + )); + + let duplicate = MockFiles::new(); + duplicate.files.lock().unwrap().insert( + "/etc/subuid".to_string(), + b"service:100000:65536\nservice:200000:65536\n".to_vec(), + ); + assert!(matches!( + discover_subid_range(&duplicate, "/etc/subuid", "service", "UID"), + Err(PodmanError::InvalidSubordinateRange { .. }) + )); +} + +#[test] +fn numeric_container_identity_must_fit_the_subordinate_range() { + assert!(matches!( + validate_subordinate_range( + SubordinateRange { + start: 100_000, + size: 999 + }, + "service", + 999 + ), + Err(PodmanError::SubordinateRangeTooSmall { .. }) + )); +} + +#[test] +fn filesystem_fake_enforces_object_types_and_injected_failures() { + let files = MockFiles::new(); + let file_path = std::path::Path::new("/tmp/mock-file"); + files + .ensure_file(file_path, b"data", None, None, None) + .unwrap(); + assert!(matches!( + files.ensure_directory(file_path, None, None, None), + Err(FileError::NotADirectory(_)) + )); + + let directory = std::path::Path::new("/tmp/mock-directory"); + files.ensure_directory(directory, None, None, None).unwrap(); + assert!(matches!( + files.ensure_file(directory, b"data", None, None, None), + Err(FileError::NotAFile(_)) + )); + + files.fail_file_write_once.store(true, Ordering::SeqCst); + assert!(files + .ensure_file( + std::path::Path::new("/tmp/failing-file"), + b"data", + None, + None, + None + ) + .is_err()); + files.fail_directory_once.store(true, Ordering::SeqCst); + assert!(files + .ensure_directory( + std::path::Path::new("/tmp/failing-directory"), + None, + None, + None + ) + .is_err()); +} diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs index 6b791e04..2a6bc49f 100644 --- a/ublue/skillet/crates/unifi/src/lib.rs +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -1,4 +1,4 @@ -use skillet_core::files::{FileError, FileResource}; +use skillet_core::files::{FileError, FileResource, OwnerIdentity, Ownership}; use skillet_core::system::{SystemError, SystemResource}; use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, Volume}; use std::{collections::BTreeMap, path::Path}; @@ -33,11 +33,13 @@ where info!("Applying UniFi Network container..."); files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; files.ensure_btrfs_subvolume(Path::new(DATA_PATH))?; - files.ensure_directory_with_owner_ids( + files.ensure_directory_with_ownership( Path::new(DATA_PATH), Some(0o750), - CONTAINER_UID, - CONTAINER_GID, + &Ownership { + uid: Some(OwnerIdentity::Id(CONTAINER_UID)), + gid: Some(OwnerIdentity::Id(CONTAINER_GID)), + }, )?; let mut extra_config = BTreeMap::new(); diff --git a/ublue/skillet/crates/unifi/src/tests.rs b/ublue/skillet/crates/unifi/src/tests.rs index 95fb729d..3b0f4593 100644 --- a/ublue/skillet/crates/unifi/src/tests.rs +++ b/ublue/skillet/crates/unifi/src/tests.rs @@ -1,5 +1,8 @@ use super::{apply, DATA_PATH}; -use skillet_core::test_utils::{MockFiles, MockSystem}; +use skillet_core::{ + files::{OwnerIdentity, Ownership}, + test_utils::{MockFiles, MockSystem}, +}; use std::sync::atomic::Ordering; #[test] @@ -26,8 +29,14 @@ fn unifi_uses_host_network_and_persistent_numeric_owned_data() { drop(quadlets); assert_eq!( - files.directory_owner_ids.lock().unwrap().get(DATA_PATH), - Some(&(999, 999)) + files.directory_metadata.lock().unwrap().get(DATA_PATH), + Some(&( + Some(0o750), + Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + } + )) ); } @@ -49,11 +58,29 @@ fn repeated_unifi_apply_does_not_restart_an_unchanged_container() { apply(&system, &files).unwrap(); let restart_count = system.restart_count.load(Ordering::SeqCst); + files.files.lock().unwrap().insert( + format!("{DATA_PATH}/existing.db"), + b"preserve existing application data".to_vec(), + ); apply(&system, &files).unwrap(); assert_eq!(system.restart_count.load(Ordering::SeqCst), restart_count); assert_eq!( - files.directory_owner_ids.lock().unwrap().get(DATA_PATH), - Some(&(999, 999)) + files.directory_metadata.lock().unwrap().get(DATA_PATH), + Some(&( + Some(0o750), + Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + } + )) + ); + assert_eq!( + files + .files + .lock() + .unwrap() + .get(&format!("{DATA_PATH}/existing.db")), + Some(&b"preserve existing application data".to_vec()) ); } From fe181f2792a8dff8903671d0445e41cf586f6eaa Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 00:30:08 +0200 Subject: [PATCH 144/252] refactor(core): split file effect capabilities --- ublue/butane/ACCEPTANCE.md | 16 ++++++++++++++ ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 13 +++++++++--- ublue/skillet/crates/btrbk/src/lib.rs | 4 ++-- ublue/skillet/crates/btrbk/src/tests.rs | 2 +- ublue/skillet/crates/caddy/src/lib.rs | 4 ++-- ublue/skillet/crates/core/src/files.rs | 21 +++++++++++++++++-- ublue/skillet/crates/core/src/recorder.rs | 9 ++++++-- ublue/skillet/crates/core/src/test_utils.rs | 11 +++++++--- ublue/skillet/crates/hardening/src/lib.rs | 12 +++++------ ublue/skillet/crates/pihole/src/lib.rs | 4 ++-- ublue/skillet/crates/podman/src/lib.rs | 8 +++---- ublue/skillet/crates/syncthing/src/lib.rs | 4 ++-- ublue/skillet/crates/unifi/src/lib.rs | 6 ++++-- 13 files changed, 83 insertions(+), 31 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index f0cbfc3c..b6e42799 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -897,3 +897,19 @@ host tool; it still uses normal command approvals. - Workstream 3 remains in progress: the remaining broad file/system trait decomposition and live ownership/mount checks are outstanding. No live VM was changed; `/dev/kvm` is absent on this workstation. + +### Cohesive file effect capabilities, 2026-10-04 + +- Split file access into read, mutation, and Btrfs storage traits. Recorder, + local adapter, and fake implement those contracts individually. Updated + service and Podman recipe bounds to declare the operations their composition + uses; retained the aggregate for host composition during migration. +- Passed `cargo fmt --manifest-path ublue/skillet/Cargo.toml --all`, offline + workspace tests, and offline workspace all-target Clippy with warnings + denied. Passed `git diff --check`. +- Passed the CI container integration command: + `ublue/skillet/target/debug/skillet test run beezelbot --phase base --image + fedora:latest`. Both applies succeeded, the second caused no service restart, + and the test container was removed. +- System capability splitting and live VM ownership/mount acceptance remain + pending. This workstation has no `/dev/kvm`; no VM was changed. diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md index f005c682..b33233f6 100644 --- a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -76,6 +76,13 @@ ownership use separate APIs; mocks do not enforce important metadata contracts. - Implemented: the guest CLI loads phase-required systemd credentials and passes `CredentialInputs` to host composition. Recipes no longer inspect `CREDENTIALS_DIRECTORY`; required names derive from profile consumers. -- Pending: decompose the remaining broad file/system traits, extend fake - contracts for object type and failures, and run live ownership/mount - acceptance. This workstream is not complete. +- Implemented: file effects now expose separate read, mutation, and storage + capabilities. Recipe signatures use those capabilities where they do not + need the entire file interface. The aggregate remains for host composition + and migration paths. Recorder and mock adapters implement the capabilities + separately. +- Pending: split the broad system interface into account, service, and + Podman-secret capabilities and narrow consumers. The fakes already model + object types and injected failures; shared adapter contract tests still need + review. Live ownership/mount acceptance is also outstanding. This workstream + is not complete. diff --git a/ublue/skillet/crates/btrbk/src/lib.rs b/ublue/skillet/crates/btrbk/src/lib.rs index f75d3198..641b3461 100644 --- a/ublue/skillet/crates/btrbk/src/lib.rs +++ b/ublue/skillet/crates/btrbk/src/lib.rs @@ -1,5 +1,5 @@ use skillet_core::{ - files::{FileError, FileResource}, + files::{FileError, FileMutationResource, StorageResource}, system::{SystemError, SystemResource}, }; use std::path::{Component, Path, PathBuf}; @@ -29,7 +29,7 @@ pub struct BtrbkConfig { pub fn apply(system: &S, files: &F, config: &BtrbkConfig) -> Result<(), BtrbkError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + StorageResource + ?Sized, { if config.snapshot_subvolumes.is_empty() { return Ok(()); diff --git a/ublue/skillet/crates/btrbk/src/tests.rs b/ublue/skillet/crates/btrbk/src/tests.rs index e63d516d..6d7409e8 100644 --- a/ublue/skillet/crates/btrbk/src/tests.rs +++ b/ublue/skillet/crates/btrbk/src/tests.rs @@ -1,5 +1,5 @@ use super::{apply, BtrbkConfig, BtrbkError, CONFIG_PATH, SERVICE_PATH, TIMER_PATH}; -use skillet_core::files::FileResource; +use skillet_core::files::FileReadResource; use skillet_core::test_utils::{MockFiles, MockSystem}; use std::path::PathBuf; diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index 20ace32f..1a44b21a 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -1,7 +1,7 @@ //! Caddy reverse proxy for administrative interfaces. use serde::{Deserialize, Serialize}; -use skillet_core::files::{FileError, FileResource}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; use skillet_core::system::{SystemError, SystemResource}; use skillet_podman::{ self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, QuadletSecret, SecretTarget, @@ -272,7 +272,7 @@ pub fn apply( ) -> Result<(), CaddyError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, { let caddyfile = sites.render(); let config_dir = Path::new("/etc/skillet/caddy"); diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index cd008b10..70c32b2e 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -61,8 +61,11 @@ pub struct Ownership { pub gid: Option, } -pub trait FileResource { +pub trait FileReadResource { fn read_file(&self, path: &Path) -> Result>, FileError>; +} + +pub trait FileMutationResource { fn ensure_file( &self, path: &Path, @@ -94,6 +97,9 @@ pub trait FileResource { ) } fn delete_file(&self, path: &Path) -> Result; +} + +pub trait StorageResource { fn require_btrfs_subvolume_mount( &self, path: &Path, @@ -104,6 +110,12 @@ pub trait FileResource { fn ensure_btrfs_subvolume(&self, path: &Path) -> Result; } +/// Transitional aggregate for recipes that still consume more than one file +/// capability. New consumers should request the narrowest trait they need. +pub trait FileResource: FileReadResource + FileMutationResource + StorageResource {} + +impl FileResource for T where T: FileReadResource + FileMutationResource + StorageResource {} + pub struct LocalFileResource; impl LocalFileResource { @@ -298,7 +310,7 @@ impl Default for LocalFileResource { } } -impl FileResource for LocalFileResource { +impl StorageResource for LocalFileResource { fn require_btrfs_subvolume_mount( &self, path: &Path, @@ -353,7 +365,9 @@ impl FileResource for LocalFileResource { } Ok(true) } +} +impl FileReadResource for LocalFileResource { fn read_file(&self, path: &Path) -> Result>, FileError> { match fs::read(path) { Ok(bytes) => Ok(Some(bytes)), @@ -361,6 +375,9 @@ impl FileResource for LocalFileResource { Err(error) => Err(FileError::Read(path.display().to_string(), error)), } } +} + +impl FileMutationResource for LocalFileResource { fn ensure_file( &self, path: &Path, diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index bb56c227..f804c62b 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -1,4 +1,4 @@ -use crate::files::{FileError, FileResource, Ownership}; +use crate::files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}; use crate::resource_op::ResourceOp; use crate::system::{AccountResource, GroupIdentity, SystemError, SystemResource, UserIdentity}; use sha2::{Digest, Sha256}; @@ -41,7 +41,7 @@ impl Recorder { } } -impl FileResource for Recorder { +impl StorageResource for Recorder { fn require_btrfs_subvolume_mount( &self, path: &Path, @@ -62,10 +62,15 @@ impl FileResource for Recorder { }); self.inner.ensure_btrfs_subvolume(path) } +} +impl FileReadResource for Recorder { fn read_file(&self, path: &Path) -> Result>, FileError> { self.inner.read_file(path) } +} + +impl FileMutationResource for Recorder { fn ensure_file( &self, path: &Path, diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 67091609..5b2b2466 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -1,4 +1,4 @@ -use crate::files::{FileError, FileResource, Ownership}; +use crate::files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}; use crate::system::{AccountResource, GroupIdentity, SystemError, SystemResource, UserIdentity}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; @@ -278,7 +278,7 @@ impl Default for MockFiles { } } -impl FileResource for MockFiles { +impl StorageResource for MockFiles { fn require_btrfs_subvolume_mount( &self, path: &Path, @@ -297,9 +297,11 @@ impl FileResource for MockFiles { } fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { - self.ensure_directory(path, None, None, None) + FileMutationResource::ensure_directory(self, path, None, None, None) } +} +impl FileReadResource for MockFiles { fn read_file(&self, path: &Path) -> Result>, FileError> { Ok(self .files @@ -308,6 +310,9 @@ impl FileResource for MockFiles { .get(&path.display().to_string()) .cloned()) } +} + +impl FileMutationResource for MockFiles { fn ensure_file( &self, path: &Path, diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index ac5240bd..d1c69502 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -1,5 +1,5 @@ use sha2::{Digest, Sha256}; -use skillet_core::files::{FileError, FileResource}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; use skillet_core::system::{SystemError, SystemResource}; use std::path::Path; use thiserror::Error; @@ -16,7 +16,7 @@ pub enum HardeningError { pub fn apply(system: &S, files: &F) -> Result<(), HardeningError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, { info!("Applying hardening..."); @@ -50,7 +50,7 @@ fn converge_service_file( ) -> Result<(), HardeningError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, { let state_dir = Path::new("/var/lib/skillet/hardening"); files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; @@ -76,7 +76,7 @@ where fn apply_sysctl_hardening(system: &S, files: &F) -> Result<(), HardeningError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, { info!("Applying sysctl hardening..."); let sysctl_dir = Path::new("/etc/sysctl.d"); @@ -105,7 +105,7 @@ fn apply_os_hardening(_system: &S) { fn apply_ssh_hardening_server(system: &S, files: &F) -> Result<(), HardeningError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, { info!("Applying ssh-hardening::server"); let content = include_bytes!("../files/sshd_config"); @@ -119,7 +119,7 @@ where fn apply_ssh_hardening_client(_system: &S, files: &F) -> Result<(), HardeningError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, { info!("Applying ssh-hardening::client"); let content = include_bytes!("../files/ssh_config"); diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 93ebb4b3..50eb7388 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -1,5 +1,5 @@ use askama::Template; -use skillet_core::files::{FileError, FileResource}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource, StorageResource}; use skillet_core::system::{SystemError, SystemResource}; use skillet_podman::{ self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, QuadletSecret, Volume, @@ -45,7 +45,7 @@ pub fn apply( ) -> Result<(), PiholeError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, { info!("Applying pihole configuration..."); let root = "/var/lib/data/pihole"; diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 4e53297d..3cac7a08 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -1,6 +1,6 @@ use askama::Template; use sha2::{Digest, Sha256}; -use skillet_core::files::{FileError, FileResource}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; use skillet_core::system::{SystemError, SystemResource}; use std::collections::BTreeMap; use std::fmt::Write as _; @@ -127,7 +127,7 @@ pub struct PodmanNetwork { pub fn container(system: &S, files: &F, config: PodmanConfig) -> Result where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, { let name = &config.name; info!("Ensuring podman container: {name}"); @@ -368,7 +368,7 @@ struct SubordinateRange { size: u32, } -fn discover_subid_range( +fn discover_subid_range( files: &F, path: &'static str, username: &str, @@ -440,7 +440,7 @@ fn render_and_ensure_quadlet( ) -> Result where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, { let template = QuadletTemplate { sections }; let content = template.render().map_err(|e| { diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index 6122cbaf..0bc5bdaa 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -1,4 +1,4 @@ -use skillet_core::files::{FileError, FileResource}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource, StorageResource}; use skillet_core::system::{SystemError, SystemResource}; use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, Volume}; use std::collections::BTreeMap; @@ -28,7 +28,7 @@ pub struct SyncthingConfig { pub fn apply(system: &S, files: &F, config: SyncthingConfig) -> Result<(), SyncthingError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, { info!("Applying Syncthing configuration..."); let data_path = Path::new(&config.data_path); diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs index 2a6bc49f..e51b07b8 100644 --- a/ublue/skillet/crates/unifi/src/lib.rs +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -1,4 +1,6 @@ -use skillet_core::files::{FileError, FileResource, OwnerIdentity, Ownership}; +use skillet_core::files::{ + FileError, FileMutationResource, FileReadResource, OwnerIdentity, Ownership, StorageResource, +}; use skillet_core::system::{SystemError, SystemResource}; use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, Volume}; use std::{collections::BTreeMap, path::Path}; @@ -28,7 +30,7 @@ pub enum UnifiError { pub fn apply(system: &S, files: &F) -> Result<(), UnifiError> where S: SystemResource + ?Sized, - F: FileResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, { info!("Applying UniFi Network container..."); files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; From 0e6d7ab4b04c69a9ea9bda8628bcb8863e21a0a4 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 00:37:58 +0200 Subject: [PATCH 145/252] refactor(core): split system effect capabilities --- ublue/butane/ACCEPTANCE.md | 14 +++++ ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 12 ++-- ublue/skillet/crates/btrbk/src/lib.rs | 4 +- ublue/skillet/crates/caddy/src/lib.rs | 6 +- ublue/skillet/crates/caddy/src/tests.rs | 2 +- ublue/skillet/crates/core/src/recorder.rs | 62 +++++++++++-------- ublue/skillet/crates/core/src/system.rs | 30 +++++++-- ublue/skillet/crates/core/src/system/tests.rs | 2 +- ublue/skillet/crates/core/src/test_utils.rs | 52 +++++++++------- ublue/skillet/crates/hardening/src/lib.rs | 14 ++--- ublue/skillet/crates/pihole/src/lib.rs | 6 +- ublue/skillet/crates/pihole/src/tests.rs | 2 +- ublue/skillet/crates/podman/src/lib.rs | 10 +-- ublue/skillet/crates/syncthing/src/lib.rs | 6 +- ublue/skillet/crates/unifi/src/lib.rs | 6 +- 15 files changed, 146 insertions(+), 82 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index b6e42799..01bbc969 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -913,3 +913,17 @@ host tool; it still uses normal command approvals. and the test container was removed. - System capability splitting and live VM ownership/mount acceptance remain pending. This workstation has no `/dev/kvm`; no VM was changed. + +### Cohesive system effect capabilities, 2026-10-04 + +- Split system effects into account lookup, account mutation, Podman-secret, + and service capabilities. Narrowed Podman, hardening, service, and backup + crate bounds; host composition keeps the aggregate trait. Linux, recorder, + and mock adapters implement each capability. +- Passed workspace formatting, offline workspace tests, and offline workspace + all-target Clippy with warnings denied. The full CI command + `ublue/skillet/target/debug/skillet test run beezelbot --phase base --image + fedora:latest` exited 0: both applies succeeded, repeat apply issued no + restart, and the disposable test container was removed. +- Live ownership and Btrfs mount acceptance remains unverified because this + workstation has no `/dev/kvm`; no VM was changed. diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md index b33233f6..c150ac29 100644 --- a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -81,8 +81,10 @@ ownership use separate APIs; mocks do not enforce important metadata contracts. need the entire file interface. The aggregate remains for host composition and migration paths. Recorder and mock adapters implement the capabilities separately. -- Pending: split the broad system interface into account, service, and - Podman-secret capabilities and narrow consumers. The fakes already model - object types and injected failures; shared adapter contract tests still need - review. Live ownership/mount acceptance is also outstanding. This workstream - is not complete. +- Implemented: system effects expose separate account lookup, account + mutation, Podman-secret, and service capabilities. The Podman, hardening, + service, and backup crates request their required capabilities; host + composition retains the aggregate during migration. Recorder and mock + adapters implement the narrow contracts. +- Pending: shared adapter contract tests still need review, and live + ownership/mount acceptance is outstanding. This workstream is not complete. diff --git a/ublue/skillet/crates/btrbk/src/lib.rs b/ublue/skillet/crates/btrbk/src/lib.rs index 641b3461..bfbc2f45 100644 --- a/ublue/skillet/crates/btrbk/src/lib.rs +++ b/ublue/skillet/crates/btrbk/src/lib.rs @@ -1,6 +1,6 @@ use skillet_core::{ files::{FileError, FileMutationResource, StorageResource}, - system::{SystemError, SystemResource}, + system::{ServiceResource, SystemError}, }; use std::path::{Component, Path, PathBuf}; use thiserror::Error; @@ -28,7 +28,7 @@ pub struct BtrbkConfig { pub fn apply(system: &S, files: &F, config: &BtrbkConfig) -> Result<(), BtrbkError> where - S: SystemResource + ?Sized, + S: ServiceResource + ?Sized, F: FileMutationResource + StorageResource + ?Sized, { if config.snapshot_subvolumes.is_empty() { diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index 1a44b21a..ae94b091 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; -use skillet_core::system::{SystemError, SystemResource}; +use skillet_core::system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, +}; use skillet_podman::{ self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, QuadletSecret, SecretTarget, Volume, @@ -271,7 +273,7 @@ pub fn apply( network: PodmanNetwork, ) -> Result<(), CaddyError> where - S: SystemResource + ?Sized, + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { let caddyfile = sites.render(); diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index d2fa878a..b9a1bdaf 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -1,6 +1,6 @@ use super::{CaddySites, UiEnvironment, UiService}; use skillet_core::{ - system::SystemResource, + system::PodmanSecretResource, test_utils::{MockFiles, MockSystem}, }; use skillet_podman::PodmanNetwork; diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index f804c62b..e2c06708 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -1,6 +1,9 @@ use crate::files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}; use crate::resource_op::ResourceOp; -use crate::system::{AccountResource, GroupIdentity, SystemError, SystemResource, UserIdentity}; +use crate::system::{ + AccountLookupResource, AccountResource, GroupIdentity, PodmanSecretResource, ServiceResource, + SystemError, UserIdentity, +}; use sha2::{Digest, Sha256}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -118,35 +121,11 @@ impl FileMutationResource for Recorder { } } -impl SystemResource for Recorder { +impl PodmanSecretResource for Recorder { fn podman_secret_id(&self, name: &str) -> Result { self.inner.podman_secret_id(name) } - fn service_is_active(&self, name: &str) -> Result { - self.inner.service_is_active(name) - } - fn ensure_group(&self, name: &str, gid: Option) -> Result { - self.record(ResourceOp::EnsureGroup { - name: name.to_string(), - }); - self.inner.ensure_group(name, gid) - } - - fn ensure_user( - &self, - name: &str, - uid: Option, - gid: Option, - ) -> Result { - self.record(ResourceOp::EnsureUser { - name: name.to_string(), - uid, - gid, - }); - self.inner.ensure_user(name, uid, gid) - } - fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { let mut hasher = Sha256::new(); hasher.update(payload.as_bytes()); @@ -158,6 +137,12 @@ impl SystemResource for Recorder { }); self.inner.ensure_podman_secret(name, payload) } +} + +impl ServiceResource for Recorder { + fn service_is_active(&self, name: &str) -> Result { + self.inner.service_is_active(name) + } fn service_start(&self, name: &str) -> Result<(), SystemError> { self.record(ResourceOp::ServiceStart { @@ -200,7 +185,7 @@ impl SystemResource for Recorder { } } -impl AccountResource for Recorder { +impl AccountLookupResource for Recorder { fn user_by_name(&self, name: &str) -> Result, SystemError> { self.inner.user_by_name(name) } @@ -213,3 +198,26 @@ impl AccountResource for Recorder { self.inner.group_by_name(name) } } + +impl AccountResource for Recorder { + fn ensure_group(&self, name: &str, gid: Option) -> Result { + self.record(ResourceOp::EnsureGroup { + name: name.to_string(), + }); + self.inner.ensure_group(name, gid) + } + + fn ensure_user( + &self, + name: &str, + uid: Option, + gid: Option, + ) -> Result { + self.record(ResourceOp::EnsureUser { + name: name.to_string(), + uid, + gid, + }); + self.inner.ensure_user(name, uid, gid) + } +} diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 94e1b021..2f771780 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -74,13 +74,13 @@ pub struct GroupIdentity { /// Account lookups needed by host and container identity configuration. /// Implementations are the only layer that consults NSS. -pub trait AccountResource { +pub trait AccountLookupResource { fn user_by_name(&self, name: &str) -> Result, SystemError>; fn user_by_uid(&self, uid: u32) -> Result, SystemError>; fn group_by_name(&self, name: &str) -> Result, SystemError>; } -pub trait SystemResource: AccountResource { +pub trait AccountResource { fn ensure_group(&self, name: &str, gid: Option) -> Result; fn ensure_user( &self, @@ -88,8 +88,14 @@ pub trait SystemResource: AccountResource { uid: Option, gid: Option, ) -> Result; +} + +pub trait PodmanSecretResource { fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result; fn podman_secret_id(&self, name: &str) -> Result; +} + +pub trait ServiceResource { fn service_is_active(&self, name: &str) -> Result; fn service_start(&self, name: &str) -> Result<(), SystemError>; fn service_stop(&self, name: &str) -> Result<(), SystemError>; @@ -99,6 +105,18 @@ pub trait SystemResource: AccountResource { fn daemon_reload(&self) -> Result<(), SystemError>; } +/// Aggregate retained for host composition and adapters while consumers move +/// to narrower account, service, and Podman-secret capabilities. +pub trait SystemResource: + AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource +{ +} + +impl SystemResource for T where + T: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource +{ +} + pub struct LinuxSystemResource { conn: Option, } @@ -204,7 +222,7 @@ impl Default for LinuxSystemResource { } } -impl AccountResource for LinuxSystemResource { +impl AccountLookupResource for LinuxSystemResource { fn user_by_name(&self, name: &str) -> Result, SystemError> { Ok(get_user_by_name(name).map(|user| UserIdentity { name: user.name().to_string_lossy().into_owned(), @@ -232,7 +250,7 @@ impl AccountResource for LinuxSystemResource { const EXIT_CODE_GROUP_EXISTS: i32 = 9; const EXIT_CODE_USER_EXISTS: i32 = 9; -impl SystemResource for LinuxSystemResource { +impl AccountResource for LinuxSystemResource { fn ensure_group(&self, name: &str, gid: Option) -> Result { if let Some(grp) = get_group_by_name(name) { debug!("Group {name} already exists"); @@ -327,7 +345,9 @@ impl SystemResource for LinuxSystemResource { info!("Created user {name}"); Ok(true) } +} +impl PodmanSecretResource for LinuxSystemResource { fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { let mut hasher = Sha256::new(); hasher.update(payload.as_bytes()); @@ -418,7 +438,9 @@ impl SystemResource for LinuxSystemResource { } Ok(id) } +} +impl ServiceResource for LinuxSystemResource { fn service_is_active(&self, name: &str) -> Result { let name = ensure_systemd_suffix(name); let output = Command::new("systemctl") diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 0378075c..1eb32a5b 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -1,5 +1,5 @@ #[cfg(feature = "test-utils")] -use super::SystemResource; +use super::{AccountLookupResource, AccountResource, ServiceResource}; #[cfg(feature = "test-utils")] use crate::test_utils::MockSystem; diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 5b2b2466..450b44bf 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -1,5 +1,8 @@ use crate::files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}; -use crate::system::{AccountResource, GroupIdentity, SystemError, SystemResource, UserIdentity}; +use crate::system::{ + AccountLookupResource, AccountResource, GroupIdentity, PodmanSecretResource, ServiceResource, + SystemError, UserIdentity, +}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -44,7 +47,7 @@ impl Default for MockSystem { } } -impl AccountResource for MockSystem { +impl AccountLookupResource for MockSystem { fn user_by_name(&self, name: &str) -> Result, SystemError> { Ok(self .user_identities @@ -74,26 +77,7 @@ impl AccountResource for MockSystem { } } -impl SystemResource for MockSystem { - fn podman_secret_id(&self, name: &str) -> Result { - self.secret_ids - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get(name) - .cloned() - .ok_or_else(|| SystemError::Command(format!("secret {name} missing"))) - } - - fn service_is_active(&self, name: &str) -> Result { - Ok(matches!( - self.services - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get(name) - .map(String::as_str), - Some("started" | "restarted") - )) - } +impl AccountResource for MockSystem { fn ensure_group(&self, name: &str, gid: Option) -> Result { let mut groups = self .groups @@ -164,6 +148,17 @@ impl SystemResource for MockSystem { ); Ok(true) } +} + +impl PodmanSecretResource for MockSystem { + fn podman_secret_id(&self, name: &str) -> Result { + self.secret_ids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .cloned() + .ok_or_else(|| SystemError::Command(format!("secret {name} missing"))) + } fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { let mut secrets = self @@ -183,6 +178,19 @@ impl SystemResource for MockSystem { Ok(true) } } +} + +impl ServiceResource for MockSystem { + fn service_is_active(&self, name: &str) -> Result { + Ok(matches!( + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .map(String::as_str), + Some("started" | "restarted") + )) + } fn service_start(&self, name: &str) -> Result<(), SystemError> { self.start_count.fetch_add(1, Ordering::SeqCst); diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index d1c69502..c3d4d8a6 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -1,6 +1,6 @@ use sha2::{Digest, Sha256}; use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; -use skillet_core::system::{SystemError, SystemResource}; +use skillet_core::system::{ServiceResource, SystemError}; use std::path::Path; use thiserror::Error; use tracing::info; @@ -15,7 +15,7 @@ pub enum HardeningError { pub fn apply(system: &S, files: &F) -> Result<(), HardeningError> where - S: SystemResource + ?Sized, + S: ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { info!("Applying hardening..."); @@ -49,7 +49,7 @@ fn converge_service_file( ensure_active: bool, ) -> Result<(), HardeningError> where - S: SystemResource + ?Sized, + S: ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { let state_dir = Path::new("/var/lib/skillet/hardening"); @@ -75,7 +75,7 @@ where fn apply_sysctl_hardening(system: &S, files: &F) -> Result<(), HardeningError> where - S: SystemResource + ?Sized, + S: ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { info!("Applying sysctl hardening..."); @@ -98,13 +98,13 @@ where Ok(()) } -fn apply_os_hardening(_system: &S) { +fn apply_os_hardening(_system: &S) { info!("(Placeholder) Applying os-hardening"); } fn apply_ssh_hardening_server(system: &S, files: &F) -> Result<(), HardeningError> where - S: SystemResource + ?Sized, + S: ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { info!("Applying ssh-hardening::server"); @@ -118,7 +118,7 @@ where fn apply_ssh_hardening_client(_system: &S, files: &F) -> Result<(), HardeningError> where - S: SystemResource + ?Sized, + S: ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { info!("Applying ssh-hardening::client"); diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 50eb7388..9c189b49 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -1,6 +1,8 @@ use askama::Template; use skillet_core::files::{FileError, FileMutationResource, FileReadResource, StorageResource}; -use skillet_core::system::{SystemError, SystemResource}; +use skillet_core::system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, +}; use skillet_podman::{ self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, QuadletSecret, Volume, }; @@ -44,7 +46,7 @@ pub fn apply( network: PodmanNetwork, ) -> Result<(), PiholeError> where - S: SystemResource + ?Sized, + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + StorageResource + ?Sized, { info!("Applying pihole configuration..."); diff --git a/ublue/skillet/crates/pihole/src/tests.rs b/ublue/skillet/crates/pihole/src/tests.rs index 9b2c3242..2e78fa5a 100644 --- a/ublue/skillet/crates/pihole/src/tests.rs +++ b/ublue/skillet/crates/pihole/src/tests.rs @@ -1,6 +1,6 @@ use crate::{apply, PiholeUser}; use skillet_core::{ - system::SystemResource, + system::PodmanSecretResource, test_utils::{MockFiles, MockSystem}, }; use skillet_podman::{PodmanNetwork, QuadletSecret, SecretTarget}; diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 3cac7a08..0796e285 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -1,7 +1,9 @@ use askama::Template; use sha2::{Digest, Sha256}; use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; -use skillet_core::system::{SystemError, SystemResource}; +use skillet_core::system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, +}; use std::collections::BTreeMap; use std::fmt::Write as _; use std::{path::Path, str::FromStr}; @@ -126,7 +128,7 @@ pub struct PodmanNetwork { #[allow(clippy::similar_names)] pub fn container(system: &S, files: &F, config: PodmanConfig) -> Result where - S: SystemResource + ?Sized, + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { let name = &config.name; @@ -283,7 +285,7 @@ fn render_network(network: &PodmanNetwork) -> Result { Ok(content) } -fn resolve_host_user( +fn resolve_host_user( system: &S, user: &ContainerUser, create: bool, @@ -439,7 +441,7 @@ fn render_and_ensure_quadlet( config_revisions: &[Vec], ) -> Result where - S: SystemResource + ?Sized, + S: ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { let template = QuadletTemplate { sections }; diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index 0bc5bdaa..7f108070 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -1,5 +1,7 @@ use skillet_core::files::{FileError, FileMutationResource, FileReadResource, StorageResource}; -use skillet_core::system::{SystemError, SystemResource}; +use skillet_core::system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, +}; use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, Volume}; use std::collections::BTreeMap; use std::path::Path; @@ -27,7 +29,7 @@ pub struct SyncthingConfig { pub fn apply(system: &S, files: &F, config: SyncthingConfig) -> Result<(), SyncthingError> where - S: SystemResource + ?Sized, + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + StorageResource + ?Sized, { info!("Applying Syncthing configuration..."); diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs index e51b07b8..ec5d732c 100644 --- a/ublue/skillet/crates/unifi/src/lib.rs +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -1,7 +1,9 @@ use skillet_core::files::{ FileError, FileMutationResource, FileReadResource, OwnerIdentity, Ownership, StorageResource, }; -use skillet_core::system::{SystemError, SystemResource}; +use skillet_core::system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, +}; use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, Volume}; use std::{collections::BTreeMap, path::Path}; use thiserror::Error; @@ -29,7 +31,7 @@ pub enum UnifiError { /// changing existing application data or requiring matching host accounts. pub fn apply(system: &S, files: &F) -> Result<(), UnifiError> where - S: SystemResource + ?Sized, + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + StorageResource + ?Sized, { info!("Applying UniFi Network container..."); From 6cf9d54cfb2ab719e1b2b272a59dddd05072f401 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 00:44:25 +0200 Subject: [PATCH 146/252] refactor(vm): share credential delivery transport --- ublue/butane/ACCEPTANCE.md | 14 ++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 14 +- ublue/plan/SKILLET-REFACTOR.md | 4 +- .../skillet/crates/cli/src/secret_delivery.rs | 196 +++++++----------- ublue/skillet/crates/vm/src/credential.rs | 72 +++++++ .../skillet/crates/vm/src/credential/tests.rs | 103 +++++++++ ublue/skillet/crates/vm/src/lib.rs | 1 + 7 files changed, 279 insertions(+), 125 deletions(-) create mode 100644 ublue/skillet/crates/vm/src/credential.rs create mode 100644 ublue/skillet/crates/vm/src/credential/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 01bbc969..295c058d 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -927,3 +927,17 @@ host tool; it still uses normal command approvals. restart, and the disposable test container was removed. - Live ownership and Btrfs mount acceptance remains unverified because this workstation has no `/dev/kvm`; no VM was changed. + +### Shared guest credential delivery, 2026-10-04 + +- Production host secret delivery and disposable VM provisioning now call the + same `skillet_vm::credential::install` API over `GuestTransport`. The helper + validates host/credential/unit inputs, supports immediate or deferred unit + activation, delivers bytes on stdin, and returns sanitized guest errors. +- Tests cover stdin-only delivery, deferred activation arguments, invalid + inputs before transport, and remote failure diagnostics. Passed offline + workspace tests, workspace formatting, and strict all-target Clippy. +- The CI container integration command exited 0. Both base applies succeeded, + repeat apply did not restart a service, and the disposable test container + was removed. VM credential rotation/recovery acceptance remains pending; no + `/dev/kvm` is available here. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index a9840062..2c43d5f4 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -1,6 +1,6 @@ # 4. Thin CLIs and shared workstation provisioning -Status: planned; follows workstreams 1–3 in +Status: in progress; follows workstreams 1–3 in [the prerequisite roadmap](SKILLET-REFACTOR.md). Further feature milestones wait for that roadmap's completion. @@ -79,3 +79,15 @@ and VM delivery have duplicate implementations with different validation. evidence and mark unavailable live checks honestly. - Pass roadmap checks; no duplicated production/VM credential workflow remains, and runtime/CLI/library boundaries are reflected in designs and AGENTS. + +## Progress + +- Implemented: production and disposable-VM credential installations now use + one `skillet_vm::credential::install` operation over `GuestTransport`. It + accepts host, credential, consumer unit, activation policy, and payload as + typed values; validates identifiers; sends secret bytes only on stdin; and + reports sanitized guest errors. Both verify recorded SSH host keys. +- Pending: workstation/vault/provider logic still lives in CLI modules; Tailscale + still uses its current curl adapter; Cloudflare/token lifecycle orchestration + and VM state persistence remain in place pending extraction/consolidation. + Finish remaining sequence items and run named-VM acceptance. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 4f72ca09..cda1a277 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -22,8 +22,8 @@ assignments to execute concurrently against the same interfaces. | --- | --- | --- | --- | | 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | -| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress | -| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Planned | +| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; live acceptance pending | +| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Planned | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Planned | | 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Planned | diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 426e1f22..0a619efb 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -50,8 +50,16 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { save_vault(&mut vault, args.key_file.as_deref(), &path, &secret)?; secret }; - let mut command = ssh_command(args); - install(&mut command, &args.hostname, "pihole_web_password", &secret) + let transport = credential_transport(args)?; + skillet_vm::credential::install( + &transport, + &args.hostname, + "pihole_web_password", + "skillet-full-apply.service", + skillet_vm::credential::ActivationPolicy::StartConsumer, + secret.as_bytes(), + )?; + Ok(()) } "tailscale" => { let credentials = tailscale_credentials(&vault)?; @@ -60,13 +68,16 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { tailscale::SERVER_TAG, &format!("Skillet {0} production host", args.hostname), )?; - let mut command = ssh_command(args); - install( - &mut command, + let transport = credential_transport(args)?; + skillet_vm::credential::install( + &transport, &args.hostname, "tailscale_auth_key", - &auth_key.key, - ) + "skillet-full-apply.service", + skillet_vm::credential::ActivationPolicy::StartConsumer, + auth_key.key.as_bytes(), + )?; + Ok(()) } "caddy" => deliver_caddy_from_vault(args, &mut vault, ui_config.as_ref()), _ => Err(anyhow!("unsupported secret service {}", args.service)), @@ -138,12 +149,13 @@ fn deliver_caddy_from_vault( ("caddy_sites", sites.as_str()), ("cloudflare_acme_token", token.as_str()), ] { - install_deferred_for_unit( - &mut ssh_command(args), + skillet_vm::credential::install( + &credential_transport(args)?, &args.hostname, credential, "skillet-caddy-apply.service", - value, + skillet_vm::credential::ActivationPolicy::DeferConsumer, + value.as_bytes(), )?; } let status = ssh_command(args) @@ -261,6 +273,25 @@ fn ssh_command(args: &SecretDeliverArgs) -> Command { command } +fn credential_transport(args: &SecretDeliverArgs) -> Result { + let (user, address) = args + .target + .split_once('@') + .filter(|(user, address)| !user.is_empty() && !address.is_empty()) + .ok_or_else(|| anyhow!("SSH target must be USER@HOST"))?; + skillet_vm::transport::SshTransport::new( + skillet_vm::SshTarget { + user: user.to_string(), + address: address.to_string(), + port: args.port, + identity: args.identity.clone(), + known_hosts: args.known_hosts.clone(), + }, + skillet_vm::transport::HostKeyPolicy::Verify, + ) + .context("validating credential delivery SSH target") +} + #[derive(PartialEq, Eq)] enum RemoteCredentialState { Present, @@ -268,13 +299,17 @@ enum RemoteCredentialState { } fn remote_credential_state(args: &SecretDeliverArgs) -> Result { - let output = ssh_command(args) - .arg(format!( - "sudo -n /var/usrlocal/bin/skillet-{} credential state pihole_web_password", - args.hostname - )) - .output() - .context("checking host state before generating a production credential")?; + let transport = credential_transport(args)?; + let program = format!("/var/usrlocal/bin/skillet-{}", args.hostname); + let output = skillet_vm::transport::GuestTransport::execute( + &transport, + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", &program, "credential", "state", "pihole_web_password"], + }, + None, + ) + .context("checking host state before generating a production credential")?; if !output.status.success() { return Err(anyhow!( "SSH host credential state check failed; no production credential was generated" @@ -728,39 +763,28 @@ impl VmSsh<'_> { secret: &str, defer_start: bool, ) -> Result<()> { - if secret.is_empty() { - return Err(anyhow!("refusing to deliver an empty credential")); - } - if !unit - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'@')) - { - return Err(anyhow!("invalid credential consumer unit")); - } - let mut remote = format!( - "sudo -n /var/usrlocal/bin/skillet-{} credential install {name} {unit}", - self.host - ); - if defer_start { - remote.push_str(" --no-start"); - } - let mut child = self - .command(&remote) - .stdin(Stdio::piped()) - .spawn() - .context("opening VM credential delivery")?; - child - .stdin - .take() - .ok_or_else(|| anyhow!("VM SSH stdin unavailable"))? - .write_all(secret.as_bytes()) - .context("sending credential to VM over SSH")?; - let status = child.wait().context("waiting for VM credential delivery")?; - if !status.success() { - return Err(anyhow!( - "VM credential delivery or full apply failed: {status}" - )); - } + let transport = skillet_vm::transport::SshTransport::new( + skillet_vm::SshTarget { + user: "giacomo".to_string(), + address: "127.0.0.1".to_string(), + port: self.port, + identity: self.identity.to_path_buf(), + known_hosts: self.known_hosts.to_path_buf(), + }, + skillet_vm::transport::HostKeyPolicy::Verify, + )?; + skillet_vm::credential::install( + &transport, + self.host, + name, + unit, + if defer_start { + skillet_vm::credential::ActivationPolicy::DeferConsumer + } else { + skillet_vm::credential::ActivationPolicy::StartConsumer + }, + secret.as_bytes(), + )?; Ok(()) } } @@ -1130,78 +1154,6 @@ fn save_vault( Ok(()) } -fn install(command: &mut Command, host: &str, credential: &str, secret: &str) -> Result<()> { - install_for_unit( - command, - host, - credential, - "skillet-full-apply.service", - secret, - ) -} - -fn install_for_unit( - command: &mut Command, - host: &str, - credential: &str, - unit: &str, - secret: &str, -) -> Result<()> { - install_for_unit_inner(command, host, credential, unit, secret, false) -} - -fn install_deferred_for_unit( - command: &mut Command, - host: &str, - credential: &str, - unit: &str, - secret: &str, -) -> Result<()> { - install_for_unit_inner(command, host, credential, unit, secret, true) -} - -fn install_for_unit_inner( - command: &mut Command, - host: &str, - credential: &str, - unit: &str, - secret: &str, - defer_start: bool, -) -> Result<()> { - if secret.is_empty() { - return Err(anyhow!("refusing to deliver an empty credential")); - } - if !host - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - { - return Err(anyhow!("invalid host identifier")); - } - let binary = format!("/var/usrlocal/bin/skillet-{host}"); - let mut remote = format!("sudo -n {binary} credential install {credential} {unit}"); - if defer_start { - remote.push_str(" --no-start"); - } - let mut child = command - .arg(remote) - .stdin(Stdio::piped()) - .spawn() - .context("opening SSH credential delivery")?; - child - .stdin - .take() - .ok_or_else(|| anyhow!("SSH stdin unavailable"))? - .write_all(secret.as_bytes()) - .context("sending credential to SSH")?; - let status = child.wait().context("waiting for credential delivery")?; - if !status.success() { - return Err(anyhow!( - "credential delivery or full apply failed: {status}" - )); - } - Ok(()) -} - #[cfg(test)] #[path = "secret_delivery/tests.rs"] mod tests; diff --git a/ublue/skillet/crates/vm/src/credential.rs b/ublue/skillet/crates/vm/src/credential.rs new file mode 100644 index 00000000..8f97d07c --- /dev/null +++ b/ublue/skillet/crates/vm/src/credential.rs @@ -0,0 +1,72 @@ +//! Shared, stdin-only installation of encrypted guest credentials. + +use crate::{ + transport::{GuestCommand, GuestTransport}, + Error, Result, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ActivationPolicy { + StartConsumer, + DeferConsumer, +} + +pub fn install( + transport: &impl GuestTransport, + host: &str, + credential: &str, + consumer_unit: &str, + activation: ActivationPolicy, + payload: &[u8], +) -> Result<()> { + if !valid_component(host) + || !valid_component(credential) + || !valid_unit(consumer_unit) + || payload.is_empty() + { + return Err(Error::Invalid( + "invalid guest credential delivery input".into(), + )); + } + + let binary = format!("/var/usrlocal/bin/skillet-{host}"); + let mut arguments = vec!["-n", binary.as_str(), "credential", "install"]; + arguments.push(credential); + arguments.push(consumer_unit); + if activation == ActivationPolicy::DeferConsumer { + arguments.push("--no-start"); + } + let output = transport.execute( + &GuestCommand { + program: "/usr/bin/sudo", + arguments: &arguments, + }, + Some(payload), + )?; + if !output.status.success() { + return Err(Error::Guest { + operation: "credential installation".into(), + code: output.status.code(), + }); + } + Ok(()) +} + +fn valid_component(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn valid_unit(value: &str) -> bool { + value.ends_with(".service") + && !value.starts_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'@')) +} + +#[cfg(test)] +#[path = "credential/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/credential/tests.rs b/ublue/skillet/crates/vm/src/credential/tests.rs new file mode 100644 index 00000000..031c8c05 --- /dev/null +++ b/ublue/skillet/crates/vm/src/credential/tests.rs @@ -0,0 +1,103 @@ +use super::{install, ActivationPolicy}; +use crate::{ + transport::{GuestCommand, GuestTransport}, + Error, Result, +}; +use std::{ + path::Path, + process::{Command, Output}, +}; + +type CapturedCall = (String, Vec, Option>); + +#[derive(Default)] +struct FakeTransport { + calls: std::sync::Mutex>, + fail: bool, +} + +impl GuestTransport for FakeTransport { + fn execute(&self, command: &GuestCommand<'_>, input: Option<&[u8]>) -> Result { + self.calls.lock().unwrap().push(( + command.program.to_string(), + command + .arguments + .iter() + .map(|arg| (*arg).to_string()) + .collect(), + input.map(<[u8]>::to_vec), + )); + Ok(Output { + status: Command::new(if self.fail { "false" } else { "true" }) + .status() + .map_err(Error::Io)?, + stdout: Vec::new(), + stderr: Vec::new(), + }) + } + + fn upload(&self, _source: &Path, _destination: &str) -> Result<()> { + Ok(()) + } +} + +#[test] +fn sends_payload_only_as_stdin_and_defers_activation_when_requested() { + let transport = FakeTransport::default(); + install( + &transport, + "clamps", + "cloudflare_acme_token", + "skillet-caddy-apply.service", + ActivationPolicy::DeferConsumer, + b"secret bytes", + ) + .unwrap(); + let calls = transport.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "/usr/bin/sudo"); + assert_eq!(calls[0].1.last().map(String::as_str), Some("--no-start")); + assert_eq!(calls[0].2.as_deref(), Some(b"secret bytes".as_slice())); + assert!(!calls[0].1.iter().any(|argument| argument == "secret bytes")); +} + +#[test] +fn rejects_invalid_names_and_empty_payload_before_transport() { + let transport = FakeTransport::default(); + for (host, credential, unit, payload) in [ + ("clamps;id", "token", "unit.service", b"secret".as_slice()), + ("clamps", "token;id", "unit.service", b"secret".as_slice()), + ("clamps", "token", "unit", b"secret".as_slice()), + ("clamps", "token", "unit.service", b"".as_slice()), + ] { + assert!(install( + &transport, + host, + credential, + unit, + ActivationPolicy::StartConsumer, + payload, + ) + .is_err()); + } + assert!(transport.calls.lock().unwrap().is_empty()); +} + +#[test] +fn reports_guest_failure_without_exposing_payload() { + let transport = FakeTransport { + fail: true, + ..FakeTransport::default() + }; + let error = install( + &transport, + "clamps", + "pihole_web_password", + "skillet-full-apply.service", + ActivationPolicy::StartConsumer, + b"private value", + ) + .unwrap_err(); + assert!(error.to_string().contains("credential installation")); + assert!(!error.to_string().contains("private value")); +} diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index b38bf94f..5ec421a8 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -4,6 +4,7 @@ pub mod artifacts; pub mod backend; pub mod catalog; pub mod creation; +pub mod credential; pub mod delivery; pub mod domain_xml; pub mod lifecycle; From d97b45484bed1e3bdd3ae6e57475e190140a0296 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 00:51:52 +0200 Subject: [PATCH 147/252] refactor(tailscale): replace curl with Rust HTTP client --- ublue/butane/ACCEPTANCE.md | 14 ++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 7 +- ublue/skillet/Cargo.lock | 1 + ublue/skillet/Cargo.toml | 1 + ublue/skillet/crates/cli/Cargo.toml | 1 + ublue/skillet/crates/cli/src/tailscale.rs | 216 ++++++++---------- .../skillet/crates/cli/src/tailscale_tests.rs | 125 +++++++++- 7 files changed, 241 insertions(+), 124 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 295c058d..cefacb0b 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -941,3 +941,17 @@ host tool; it still uses normal command approvals. repeat apply did not restart a service, and the disposable test container was removed. VM credential rotation/recovery acceptance remains pending; no `/dev/kvm` is available here. + +### Tailscale Rust HTTP client, 2026-10-04 + +- Replaced the curl-based Tailscale API adapter with the workspace `reqwest` + blocking client. The endpoint is injectable through a test-only constructor; + production uses the HTTPS endpoint with bounded connect/request timeouts and + Rustls. +- Local HTTP fixture tests cover OAuth auth-key creation, bearer auth, tagged + device lookup/deletion, and a forbidden response. Full workspace + formatting/tests/strict Clippy passed. The static-musl CLI built with + `PATH=/opt/x86_64-linux-musl-cross/bin:$PATH`. +- CI container integration exited 0: both applies succeeded, repeat apply + issued no service restart, and the container was removed. No live Tailscale + API request was made. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 2c43d5f4..4897bc52 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -87,7 +87,10 @@ and VM delivery have duplicate implementations with different validation. accepts host, credential, consumer unit, activation policy, and payload as typed values; validates identifiers; sends secret bytes only on stdin; and reports sanitized guest errors. Both verify recorded SSH host keys. -- Pending: workstation/vault/provider logic still lives in CLI modules; Tailscale - still uses its current curl adapter; Cloudflare/token lifecycle orchestration +- Implemented: Tailscale API calls use a bounded `reqwest` blocking client with + an injectable base URL. Local HTTP tests cover OAuth auth-key creation, + tagged device lookup/removal, and API failures. Tailscale no longer shells + out to curl. +- Pending: workstation/vault/provider logic still lives in CLI modules; Cloudflare/token lifecycle orchestration and VM state persistence remain in place pending extraction/consolidation. Finish remaining sequence items and run named-VM acceptance. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 0ba6b69f..60ad64de 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2283,6 +2283,7 @@ dependencies = [ "hex", "keepass", "keyutils", + "reqwest", "rpassword", "serde", "serde_json", diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index a340fb17..d05fd49c 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -46,6 +46,7 @@ askama = "0.12" keepass = { version = "0.13.25", features = ["save_kdbx4"] } rpassword = "7.5" cloudflare = { version = "0.14.0", default-features = false, features = ["blocking", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } keyutils = "0.4" uuid = { version = "1.26", features = ["serde", "v4"] } quick-xml = { version = "0.41", features = ["serialize"] } diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 3ceb8969..dfa996ec 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -20,6 +20,7 @@ anyhow = "1.0" keepass.workspace = true rpassword.workspace = true cloudflare.workspace = true +reqwest.workspace = true keyutils.workspace = true hex.workspace = true sha2.workspace = true diff --git a/ublue/skillet/crates/cli/src/tailscale.rs b/ublue/skillet/crates/cli/src/tailscale.rs index 1d7b63e2..1f1442f5 100644 --- a/ublue/skillet/crates/cli/src/tailscale.rs +++ b/ublue/skillet/crates/cli/src/tailscale.rs @@ -1,12 +1,8 @@ use anyhow::{anyhow, Context, Result}; +use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::{ - collections::BTreeSet, - fmt::Write as _, - io::Write as _, - process::{Command, Stdio}, -}; +use std::{collections::BTreeSet, fmt::Write as _, time::Duration}; const API_BASE: &str = "https://api.tailscale.com/api/v2"; pub(crate) const PROVISIONER_TAG: &str = "tag:skillet-provisioner"; @@ -16,6 +12,73 @@ pub(crate) const SMOKE_TAG: &str = "tag:skillet-smoke"; pub(crate) struct OAuthCredentials { client_id: String, client_secret: String, + api: ApiClient, +} + +struct ApiClient { + base_url: String, + http: Client, +} + +impl ApiClient { + fn new(base_url: &str) -> Result { + let base_url = base_url.trim_end_matches('/'); + if !base_url.starts_with("http://") && !base_url.starts_with("https://") { + return Err(anyhow!("invalid Tailscale API base URL")); + } + let http = Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .context("building Tailscale HTTP client")?; + Ok(Self { + base_url: base_url.to_string(), + http, + }) + } + + fn request( + &self, + method: reqwest::Method, + path: &str, + bearer: Option<&str>, + body: Option<(&str, &str)>, + ) -> Result { + let mut request = self + .http + .request(method, format!("{}{path}", self.base_url)); + if let Some(token) = bearer { + request = request.bearer_auth(token); + } + if let Some((content_type, payload)) = body { + request = request + .header(reqwest::header::CONTENT_TYPE, content_type) + .body(payload.to_string()); + } + let response = request.send().context("sending Tailscale API request")?; + let status = response.status(); + let payload = response.text().context("reading Tailscale API response")?; + if !status.is_success() { + let message = serde_json::from_str::(&payload) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(Value::as_str) + .map(str::to_owned) + }); + return Err(match message { + Some(message) => { + anyhow!("Tailscale API returned HTTP {}: {message}", status.as_u16()) + } + None => anyhow!("Tailscale API request failed with HTTP {}", status.as_u16()), + }); + } + if payload.is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(&payload).context("decoding Tailscale API response") + } } impl OAuthCredentials { @@ -26,6 +89,19 @@ impl OAuthCredentials { Ok(Self { client_id, client_secret, + api: ApiClient::new(API_BASE)?, + }) + } + + #[cfg(test)] + fn with_api_base(client_id: String, client_secret: String, base_url: &str) -> Result { + if client_id.trim().is_empty() || client_secret.trim().is_empty() { + return Err(anyhow!("Tailscale OAuth credentials are empty")); + } + Ok(Self { + client_id, + client_secret, + api: ApiClient::new(base_url)?, }) } @@ -41,9 +117,9 @@ impl OAuthCredentials { .map(|(key, value)| format!("{}={}", form_encode(key), form_encode(value))) .collect::>() .join("&"); - let response = request( - "POST", - &format!("{API_BASE}/oauth/token"), + let response = self.api.request( + reqwest::Method::POST, + "/oauth/token", None, Some(("application/x-www-form-urlencoded", &form)), )?; @@ -87,9 +163,9 @@ pub(crate) fn create_auth_key( "description": description }); let body = serde_json::to_string(&payload).context("encoding Tailscale auth key request")?; - let response = request( - "POST", - &format!("{API_BASE}/tailnet/-/keys"), + let response = credentials.api.request( + reqwest::Method::POST, + "/tailnet/-/keys", Some(&token), Some(("application/json", &body)), )?; @@ -112,9 +188,9 @@ pub(crate) fn find_device( return Err(anyhow!("invalid Tailscale device lookup constraints")); } let token = credentials.access_token("devices:core")?; - let response = request( - "GET", - &format!("{API_BASE}/tailnet/-/devices"), + let response = credentials.api.request( + reqwest::Method::GET, + "/tailnet/-/devices", Some(&token), None, )?; @@ -153,9 +229,9 @@ pub(crate) fn find_device_by_hostname( return Err(anyhow!("invalid Tailscale device lookup constraints")); } let token = credentials.access_token("devices:core")?; - let response = request( - "GET", - &format!("{API_BASE}/tailnet/-/devices"), + let response = credentials.api.request( + reqwest::Method::GET, + "/tailnet/-/devices", Some(&token), None, )?; @@ -190,9 +266,9 @@ pub(crate) fn remove_device_for_hostname( return Err(anyhow!("invalid Tailscale device removal constraints")); } let token = credentials.access_token("devices:core")?; - let response = request( - "GET", - &format!("{API_BASE}/tailnet/-/devices"), + let response = credentials.api.request( + reqwest::Method::GET, + "/tailnet/-/devices", Some(&token), None, )?; @@ -226,9 +302,9 @@ pub(crate) fn remove_device_for_hostname( )); } } - request( - "DELETE", - &format!("{API_BASE}/device/{}", path_segment(&actual.id)?), + credentials.api.request( + reqwest::Method::DELETE, + &format!("/device/{}", path_segment(&actual.id)?), Some(&token), None, )?; @@ -256,88 +332,6 @@ fn device_record(value: &Value, expected_tag: &str) -> Option { }) } -fn request( - method: &str, - url: &str, - bearer: Option<&str>, - body: Option<(&str, &str)>, -) -> Result { - let mut config = format!( - "url = {}\nrequest = {}\n", - curl_quote(url), - curl_quote(method) - ); - if let Some(token) = bearer { - let _ = writeln!( - config, - "header = {}", - curl_quote(&format!("Authorization: Bearer {token}")) - ); - } - if let Some((content_type, payload)) = body { - let _ = write!( - config, - "header = {}\ndata = {}\n", - curl_quote(&format!("Content-Type: {content_type}")), - curl_quote(payload) - ); - } - let mut child = Command::new("curl") - .args([ - "--config", - "-", - "--silent", - "--show-error", - "--fail-with-body", - "--connect-timeout", - "10", - "--max-time", - "30", - "--write-out", - "\n%{http_code}", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("starting curl for Tailscale API; install curl on the workstation")?; - child - .stdin - .take() - .ok_or_else(|| anyhow!("curl stdin unavailable"))? - .write_all(config.as_bytes()) - .context("sending private Tailscale API request to curl")?; - let output = child - .wait_with_output() - .context("waiting for Tailscale API response")?; - let response_text = - String::from_utf8(output.stdout).context("Tailscale API response was not UTF-8")?; - let (payload, status) = response_text - .rsplit_once('\n') - .ok_or_else(|| anyhow!("Tailscale API response omitted HTTP status"))?; - let status: u16 = status - .parse() - .context("Tailscale API returned an invalid HTTP status")?; - if !output.status.success() || !(200..300).contains(&status) { - let message = serde_json::from_str::(payload) - .ok() - .and_then(|value| { - value - .get("message") - .and_then(Value::as_str) - .map(str::to_owned) - }); - return Err(match message { - Some(message) => anyhow!("Tailscale API returned HTTP {status}: {message}"), - None => anyhow!("Tailscale API request failed with HTTP {status}"), - }); - } - if payload.is_empty() { - return Ok(Value::Null); - } - serde_json::from_str(payload).context("decoding Tailscale API response") -} - fn form_encode(value: &str) -> String { let mut encoded = String::new(); for byte in value.bytes() { @@ -350,16 +344,6 @@ fn form_encode(value: &str) -> String { encoded } -fn curl_quote(value: &str) -> String { - let escaped = value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\r', "\\r") - .replace('\n', "\\n") - .replace('\t', "\\t"); - format!("\"{escaped}\"") -} - fn path_segment(value: &str) -> Result { if value.is_empty() || !value diff --git a/ublue/skillet/crates/cli/src/tailscale_tests.rs b/ublue/skillet/crates/cli/src/tailscale_tests.rs index 30804680..59635cd2 100644 --- a/ublue/skillet/crates/cli/src/tailscale_tests.rs +++ b/ublue/skillet/crates/cli/src/tailscale_tests.rs @@ -1,16 +1,19 @@ -use super::{curl_quote, device_record, form_encode, SMOKE_TAG}; +use super::{ + create_auth_key, device_record, find_device_by_hostname, form_encode, + remove_device_for_hostname, OAuthCredentials, SMOKE_TAG, +}; use serde_json::json; +use std::{ + io::{Read, Write}, + net::TcpListener, + thread, +}; #[test] fn form_encoding_escapes_oauth_credentials() { assert_eq!(form_encode("a b+c/%"), "a%20b%2Bc%2F%25"); } -#[test] -fn curl_config_quotes_newlines_and_quotes() { - assert_eq!(curl_quote("one\ntwo\"\\"), "\"one\\ntwo\\\"\\\\\""); -} - #[test] fn device_record_requires_expected_tag_and_identity_fields() { let value = json!({ @@ -25,3 +28,113 @@ fn device_record_requires_expected_tag_and_identity_fields() { assert_eq!(record.addresses.len(), 2); assert!(device_record(&value, "tag:skillet-server").is_none()); } + +fn fixture(responses: Vec<(u16, &'static str)>) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); + let address = listener.local_addr().expect("fixture address"); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in responses { + let (mut stream, _) = listener.accept().expect("accept HTTP request"); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .expect("set read timeout"); + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let read = stream.read(&mut buffer).expect("read request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + let text = String::from_utf8_lossy(&request); + let Some((headers, payload)) = text.split_once("\r\n\r\n") else { + continue; + }; + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(0); + if payload.len() >= content_length { + break; + } + } + requests.push(String::from_utf8_lossy(&request).into_owned()); + let reason = if status == 200 { "OK" } else { "Forbidden" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + } + requests + }); + (format!("http://{address}/api/v2"), handle) +} + +#[test] +fn auth_key_requests_use_the_injected_http_endpoint() { + let (base, server) = fixture(vec![ + (200, r#"{"access_token":"oauth-access"}"#), + (200, r#"{"key":"tskey-test"}"#), + ]); + let credentials = + OAuthCredentials::with_api_base("client-id".into(), "client-secret".into(), &base) + .expect("test credentials"); + let issued = create_auth_key(&credentials, SMOKE_TAG, "local fixture").expect("auth key"); + assert_eq!(issued.key, "tskey-test"); + let requests = server.join().expect("fixture thread"); + assert!(requests[0].starts_with("POST /api/v2/oauth/token ")); + assert!(requests[0].contains("client_secret=client-secret")); + assert!(requests[1].starts_with("POST /api/v2/tailnet/-/keys ")); + assert!(requests[1] + .to_ascii_lowercase() + .contains("authorization: bearer oauth-access")); +} + +#[test] +fn device_lookup_and_removal_use_the_same_http_client() { + let (base, server) = fixture(vec![ + (200, r#"{"access_token":"oauth-access"}"#), + ( + 200, + r#"{"devices":[{"id":"device-1","hostname":"clamps-test-smoke","addresses":["100.64.0.5"],"tags":["tag:skillet-smoke"]}]}"#, + ), + (200, r#"{"access_token":"oauth-access"}"#), + ( + 200, + r#"{"devices":[{"id":"device-1","hostname":"clamps-test-smoke","addresses":["100.64.0.5"],"tags":["tag:skillet-smoke"]}]}"#, + ), + (200, "{}"), + ]); + let credentials = + OAuthCredentials::with_api_base("client-id".into(), "client-secret".into(), &base) + .expect("test credentials"); + let found = find_device_by_hostname(&credentials, "clamps-test-smoke", SMOKE_TAG) + .expect("device lookup"); + assert_eq!(found.id, "device-1"); + let removed = remove_device_for_hostname(&credentials, "clamps-test-smoke", SMOKE_TAG, None) + .expect("device removal"); + assert_eq!(removed.expect("removed record").id, "device-1"); + let requests = server.join().expect("fixture thread"); + assert!(requests[1].starts_with("GET /api/v2/tailnet/-/devices ")); + assert!(requests[4].starts_with("DELETE /api/v2/device/device-1 ")); +} + +#[test] +fn api_errors_keep_status_context() { + let (base, server) = fixture(vec![(403, r#"{"message":"denied"}"#)]); + let credentials = + OAuthCredentials::with_api_base("client-id".into(), "client-secret".into(), &base) + .expect("test credentials"); + let error = credentials.access_token("auth_keys").unwrap_err(); + assert!(error.to_string().contains("HTTP 403")); + assert!(error.to_string().contains("denied")); + server.join().expect("fixture thread"); +} From e77c001c2888b0e4396cc20b3d598338bb5e195b Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 01:06:22 +0200 Subject: [PATCH 148/252] refactor(workstation): extract vault and guest credentials --- ublue/butane/ACCEPTANCE.md | 16 + ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 13 +- ublue/skillet/AGENTS.md | 4 + ublue/skillet/Cargo.lock | 14 + ublue/skillet/Cargo.toml | 2 + ublue/skillet/crates/cli-common/src/lib.rs | 9 +- ublue/skillet/crates/cli/Cargo.toml | 1 + .../skillet/crates/cli/src/secret_delivery.rs | 345 +++----------- .../crates/cli/src/secret_delivery/tests.rs | 130 +----- .../cli/src/secret_delivery/vault_cache.rs | 118 ----- .../src/credential_install.rs} | 47 +- .../core/src/credential_install/tests.rs | 32 ++ ublue/skillet/crates/core/src/lib.rs | 1 + ublue/skillet/crates/workstation/Cargo.toml | 16 + ublue/skillet/crates/workstation/src/lib.rs | 3 + ublue/skillet/crates/workstation/src/vault.rs | 427 ++++++++++++++++++ .../crates/workstation/src/vault/tests.rs | 173 +++++++ 17 files changed, 787 insertions(+), 564 deletions(-) delete mode 100644 ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs rename ublue/skillet/crates/{cli-common/src/credential.rs => core/src/credential_install.rs} (73%) create mode 100644 ublue/skillet/crates/core/src/credential_install/tests.rs create mode 100644 ublue/skillet/crates/workstation/Cargo.toml create mode 100644 ublue/skillet/crates/workstation/src/lib.rs create mode 100644 ublue/skillet/crates/workstation/src/vault.rs create mode 100644 ublue/skillet/crates/workstation/src/vault/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index cefacb0b..b577ecc5 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -955,3 +955,19 @@ host tool; it still uses normal command approvals. - CI container integration exited 0: both applies succeeded, repeat apply issued no service restart, and the container was removed. No live Tailscale API request was made. + +### Workstation vault library, 2026-10-04 + +- Added `skillet_workstation` and moved KeePassXC opening, exact secret lookup, + atomic encrypted save, backup/conflict checks, XDG location, lock operation, + and named-session kernel cache out of CLI code. The API returns `thiserror` + errors and keeps the unlocked database/password private. +- Workstation tests passed for exact entries, XDG fallback, correct/wrong + KDBX passwords, symlink-target replacement and backup, concurrent-change + refusal, absent-vault error before prompting, and three-hour cache expiry and + clearing. +- Passed workspace formatting, tests, strict all-target Clippy, and static + musl build. CI container integration exited 0; both applies succeeded, + repeat apply did not restart, and the container was removed. Live credential + delivery and rotation recovery on a disposable VM remain unverified because + `/dev/kvm` is unavailable. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 4897bc52..fbcc70cc 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -91,6 +91,13 @@ and VM delivery have duplicate implementations with different validation. an injectable base URL. Local HTTP tests cover OAuth auth-key creation, tagged device lookup/removal, and API failures. Tailscale no longer shells out to curl. -- Pending: workstation/vault/provider logic still lives in CLI modules; Cloudflare/token lifecycle orchestration - and VM state persistence remain in place pending extraction/consolidation. - Finish remaining sequence items and run named-VM acceptance. +- Implemented: KeePassXC access and its named-session kernel password cache + live in `skillet_workstation::vault`, outside Clap and guest apply code. The + API encapsulates exact lookup, XDG path choice, symlink-safe atomic save, + conflict detection, verified encrypted replacement, recovery backup, and + three-hour cache/lock operations. It uses `thiserror` and does not implement + `Debug` for the unlocked vault value. +- Pending: Cloudflare/Tailscale provider and lifecycle orchestration still + lives in CLI modules, and VM resource ownership persistence has not been + consolidated with the lifecycle coordinator. Finish the remaining sequence + items and run named-VM acceptance. diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 7b70776f..e4eccbe3 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -66,6 +66,10 @@ tracks existing violations and their migration, not completed implementation. workstation provisioning, and test fixtures have distinct responsibilities. Extract modules first and crates where dependency or deployment boundaries justify them; production dispatch must not include synthetic test profiles. +- Keep workstation vault, provider-client, and host-provisioning dependencies + behind workstation libraries. Guest apply and credential commands must not + require a workstation vault, interactive unlock flow, or provisioning-only + API client. - Share delivery and lifecycle workflows across environments. Supply explicit policy and target inputs rather than duplicating algorithms or branching on particular deployment names. Keep secret values off command lines and out of diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 60ad64de..6e28b6a3 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2294,6 +2294,7 @@ dependencies = [ "skillet_hosts", "skillet_podman", "skillet_vm", + "skillet_workstation", "tempfile", "thiserror 1.0.69", "tracing", @@ -2461,6 +2462,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "skillet_workstation" +version = "0.1.0" +dependencies = [ + "hex", + "keepass", + "keyutils", + "rpassword", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index d05fd49c..c4751303 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/cli-common", "crates/vm", "crates/host-profiles", + "crates/workstation", ] [workspace.dependencies] @@ -29,6 +30,7 @@ skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } skillet_vm = { path = "crates/vm" } skillet_hosts = { path = "crates/host-profiles" } +skillet_workstation = { path = "crates/workstation" } thiserror = "1.0" sha2 = "0.10" users = "0.11" diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 507f7756..b541da19 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -9,7 +9,6 @@ use thiserror::Error; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; -mod credential; pub mod hosts; use hosts::ApplyPhase; @@ -25,8 +24,8 @@ pub enum CliCommonError { Io(#[from] std::io::Error), #[error("Serialization error: {0}")] Yaml(#[from] serde_yml::Error), - #[error("Credential error: {0}")] - Credential(#[from] credential::CredentialError), + #[error("Credential installation error: {0}")] + CredentialInstall(#[from] skillet_core::credential_install::CredentialInstallError), #[error("Systemd credential error: {0}")] SystemdCredential(#[from] skillet_core::credentials::CredentialError), } @@ -103,13 +102,13 @@ where HostCommands::Credential { command } => { match command { CredentialCommands::State { name } => { - println!("{}", credential::state(&name)?); + println!("{}", skillet_core::credential_install::state(&name)?); } CredentialCommands::Install { name, unit, no_start, - } => credential::install(&name, &unit, !no_start)?, + } => skillet_core::credential_install::install(&name, &unit, !no_start)?, } Ok(()) } diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index dfa996ec..0905cb32 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -13,6 +13,7 @@ skillet_caddy.workspace = true skillet_podman.workspace = true skillet_core.workspace = true skillet_vm.workspace = true +skillet_workstation.workspace = true clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 0a619efb..72816fe5 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -3,19 +3,16 @@ use super::{ VmProvisionArgs, }; use anyhow::{anyhow, Context, Result}; -use keepass::{Database, DatabaseKey}; use serde::{Deserialize, Serialize}; +use skillet_workstation::vault::Vault; use std::{ fs, io::{Read as _, Write as _}, - os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}, - path::{Path, PathBuf}, + os::unix::fs::PermissionsExt as _, + path::Path, process::{Command, Stdio}, - time::{SystemTime, UNIX_EPOCH}, }; -mod vault_cache; - pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { validate_delivery_service(&args.hostname, &args.service)?; let ui_config = if args.service == "caddy" { @@ -32,11 +29,11 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { "SSH identity and recorded known-hosts file must exist" )); } - let mut vault = open_vault(&database, args.key_file.as_deref())?; + let mut vault = Vault::open(&database, args.key_file.as_deref())?; match args.service.as_str() { "pihole" => { let path = format!("skillet/hosts/{}/pihole/web-password", args.hostname); - let secret = if let Some(secret) = lookup(&vault.database, &path)? { + let secret = if let Some(secret) = vault.get(&path)? { ensure_vault_unchanged(&vault)?; secret } else { @@ -46,8 +43,8 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { )); } let secret = random_password()?; - create_entry(&mut vault.database, &path, &secret)?; - save_vault(&mut vault, args.key_file.as_deref(), &path, &secret)?; + vault.insert(&path, &secret)?; + vault.save_verified(args.key_file.as_deref(), &path, &secret)?; secret }; let transport = credential_transport(args)?; @@ -101,20 +98,22 @@ fn validate_delivery_service(hostname: &str, service: &str) -> Result, ) -> Result<()> { let ui_config = ui_config.ok_or_else(|| anyhow!("missing host UI declaration"))?; let environment = args.environment.as_str(); let domain_path = format!("skillet/environments/{environment}/dns/ui-domain"); - let domain_prefix = lookup(&vault.database, &domain_path)?; + let domain_prefix = vault.get(&domain_path)?; let zone_path = format!("skillet/environments/{environment}/dns/cloudflare-zone-id"); - let zone_id = lookup(&vault.database, &zone_path)? + let zone_id = vault + .get(&zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; let zone_id = zone_id.trim(); crate::cloudflare::validate_zone_id(zone_id)?; - let creator = - lookup(&vault.database, "skillet/cloudflare/token-creator")?.ok_or_else(|| { + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| { anyhow!( "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" ) @@ -170,7 +169,7 @@ fn deliver_caddy_from_vault( fn host_acme_token( args: &SecretDeliverArgs, - vault: &mut OpenVault, + vault: &mut Vault, api: &crate::cloudflare::Cloudflare, creator: &str, zone_id: &str, @@ -181,33 +180,33 @@ fn host_acme_token( "skillet/environments/{environment}/hosts/{}/cloudflare/acme-token", args.hostname ); - if let Some(token) = lookup(&vault.database, &token_path)? { + if let Some(token) = vault.get(&token_path)? { return Ok(token); } let legacy = if args.environment == UiEnvironmentName::Production { - lookup( - &vault.database, - &format!("skillet/hosts/{}/cloudflare/acme-token", args.hostname), - )? + vault.get(&format!( + "skillet/hosts/{}/cloudflare/acme-token", + args.hostname + ))? } else { None }; if let Some(token) = legacy { - create_entry(&mut vault.database, &token_path, &token)?; - save_vault(vault, args.key_file.as_deref(), &token_path, &token)?; + vault.insert(&token_path, &token)?; + vault.save_verified(args.key_file.as_deref(), &token_path, &token)?; return Ok(token); } let token_name = format!("skillet:{environment}:{}", args.hostname); let issued = api.replace_named_zone_token(creator, zone_id, account_id, &token_name, None)?; - if let Err(error) = create_entry(&mut vault.database, &token_path, &issued.value) - .and_then(|()| save_vault(vault, args.key_file.as_deref(), &token_path, &issued.value)) + if let Err(error) = vault + .insert(&token_path, &issued.value) + .and_then(|()| vault.save_verified(args.key_file.as_deref(), &token_path, &issued.value)) { if let Err(revoke_error) = api.revoke_token(creator, account_id, &issued.id) { return Err(anyhow!("saving issued Cloudflare credential failed ({error}); revoking token {} also failed ({revoke_error})", issued.id)); } - return Err( - error.context("saving new Cloudflare token into KeePassXC; issued token was revoked") - ); + return Err(anyhow::Error::new(error) + .context("saving new Cloudflare token into KeePassXC; issued token was revoked")); } Ok(issued.value) } @@ -219,33 +218,27 @@ pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { }; // A symlink at the default XDG location points to the same cache entry as // an explicit path to its target. - let canonical = canonical_database(&path)?; - vault_cache::clear(&canonical)?; + skillet_workstation::vault::lock(Some(&path))?; println!("Vault unlock removed from the kernel keyring"); Ok(()) } -fn ensure_vault_unchanged(vault: &OpenVault) -> Result<()> { - if fs::read(&vault.path).context("rechecking vault before delivery")? != vault.original { - return Err(anyhow!( - "KeePassXC database changed while Skillet was running; reopen it and retry" - )); - } +fn ensure_vault_unchanged(vault: &Vault) -> Result<()> { + vault.ensure_unchanged()?; Ok(()) } -fn tailscale_credentials(vault: &OpenVault) -> Result { - let client_id = lookup(&vault.database, "skillet/tailscale/provisioner-client-id")? +fn tailscale_credentials(vault: &Vault) -> Result { + let client_id = vault + .get("skillet/tailscale/provisioner-client-id")? .ok_or_else(|| { anyhow!("KeePassXC entry skillet/tailscale/provisioner-client-id is missing") })?; - let client_secret = lookup( - &vault.database, - "skillet/tailscale/provisioner-client-secret", - )? - .ok_or_else(|| { - anyhow!("KeePassXC entry skillet/tailscale/provisioner-client-secret is missing") - })?; + let client_secret = vault + .get("skillet/tailscale/provisioner-client-secret")? + .ok_or_else(|| { + anyhow!("KeePassXC entry skillet/tailscale/provisioner-client-secret is missing") + })?; tailscale::OAuthCredentials::new(client_id, client_secret) } @@ -323,24 +316,7 @@ fn remote_credential_state(args: &SecretDeliverArgs) -> Result Result { - let xdg = std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from); - let home = std::env::var_os("HOME").map(std::path::PathBuf::from); - database_path_from(xdg.as_deref(), home.as_deref()) -} - -fn database_path_from( - xdg_data_home: Option<&Path>, - home: Option<&Path>, -) -> Result { - let data_home = if let Some(path) = xdg_data_home.filter(|path| path.is_absolute()) { - path.to_path_buf() - } else { - let home = home - .filter(|path| path.is_absolute()) - .ok_or_else(|| anyhow!("set an absolute XDG_DATA_HOME or HOME, or pass --database"))?; - home.join(".local/share") - }; - Ok(data_home.join("skillet/secrets.kdbx")) + Ok(Vault::default_path()?) } pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { @@ -373,7 +349,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { Some(path) => path.clone(), None => default_database_path()?, }; - let mut vault = open_vault(&vault_path, args.key_file.as_deref())?; + let mut vault = Vault::open(&vault_path, args.key_file.as_deref())?; let credentials = tailscale_credentials(&vault)?; let expected_hostname = name.as_str(); @@ -438,7 +414,7 @@ pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { Some(path) => path.clone(), None => default_database_path()?, }; - let vault = open_vault(&vault_path, args.key_file.as_deref())?; + let vault = Vault::open(&vault_path, args.key_file.as_deref())?; let credentials = tailscale_credentials(&vault)?; if tailscale::remove_device_for_hostname( &credentials, @@ -473,17 +449,19 @@ fn provision_vm_ui( args: &VmProvisionArgs, run_dir: &Path, ssh: &mut VmSsh<'_>, - vault: &mut OpenVault, + vault: &mut Vault, device: &tailscale::DeviceRecord, ) -> Result<()> { let environment = UiEnvironmentName::Test.as_str(); let domain_path = format!("skillet/environments/{environment}/dns/ui-domain"); let zone_path = format!("skillet/environments/{environment}/dns/cloudflare-zone-id"); - let domain_prefix = lookup(&vault.database, &domain_path)?; - let zone_id = lookup(&vault.database, &zone_path)? + let domain_prefix = vault.get(&domain_path)?; + let zone_id = vault + .get(&zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; - let creator = - lookup(&vault.database, "skillet/cloudflare/token-creator")?.ok_or_else(|| { + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| { anyhow!( "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" ) @@ -619,19 +597,17 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( .database .clone() .map_or_else(default_database_path, Ok)?; - let vault = open_vault(&vault_path, args.key_file.as_deref())?; - let creator = - lookup(&vault.database, "skillet/cloudflare/token-creator")?.ok_or_else(|| { + let vault = Vault::open(&vault_path, args.key_file.as_deref())?; + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| { anyhow!( "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" ) })?; - let configured_zone = lookup( - &vault.database, - "skillet/environments/test/dns/cloudflare-zone-id", - )? + let configured_zone = vault.get("skillet/environments/test/dns/cloudflare-zone-id")? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: skillet/environments/test/dns/cloudflare-zone-id"))?; - let configured_prefix = lookup(&vault.database, "skillet/environments/test/dns/ui-domain")?; + let configured_prefix = vault.get("skillet/environments/test/dns/ui-domain")?; if configured_zone.trim() != ownership.zone_id { return Err(anyhow!("test Cloudflare configuration differs from the recorded VM owner; restore the original vault values before cleanup")); } @@ -935,225 +911,12 @@ fn validate_tailscale_unit_config(contents: &str) -> Result<()> { Ok(()) } -struct OpenVault { - path: PathBuf, - original: Vec, - database: Database, - password: String, -} - -fn canonical_database(path: &Path) -> Result { - fs::canonicalize(path).with_context(|| format!( - "KeePassXC database is missing or unreadable at {}; place the synced vault there or pass --database", - path.display() - )) -} - -fn database_key(password: &str, key_file: Option<&Path>) -> Result { - let mut key = DatabaseKey::new().with_password(password); - if let Some(path) = key_file { - let mut file = - fs::File::open(path).context("KeePassXC key file is missing or unreadable")?; - key = key.with_keyfile(&mut file)?; - } - Ok(key) -} - -fn open_with_password(bytes: &[u8], password: &str, key_file: Option<&Path>) -> Result { - let mut reader = bytes; - Database::open(&mut reader, database_key(password, key_file)?) - .context("opening KeePassXC database; check password and key file") -} - -fn open_vault(path: &Path, key_file: Option<&Path>) -> Result { - // Resolve the symlink before any eventual atomic replacement, so the - // Syncthing-managed target is changed instead of replacing the symlink. - let path = canonical_database(path)?; - let original = fs::read(&path).context("reading KeePassXC database")?; - if let Some(cached) = vault_cache::read(&path)? { - if let Ok(database) = open_with_password(&original, &cached, key_file) { - return Ok(OpenVault { - path, - original, - database, - password: cached, - }); - } - vault_cache::clear(&path)?; - } - let password = rpassword::prompt_password("KeePassXC database password: ") - .context("reading database password from terminal")?; - let database = open_with_password(&original, &password, key_file)?; - vault_cache::store(&path, &password)?; - Ok(OpenVault { - path, - original, - database, - password, - }) -} - -fn lookup(database: &Database, path: &str) -> Result> { - let parts: Vec<_> = path.split('/').collect(); - if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { - return Err(anyhow!("invalid KeePassXC entry path")); - } - let mut groups = vec![database.root().id()]; - for part in &parts[..parts.len() - 1] { - groups = groups - .into_iter() - .filter_map(|id| database.group(id)) - .flat_map(|group| group.group_ids().collect::>()) - .filter(|id| database.group(*id).is_some_and(|group| group.name == *part)) - .collect(); - } - let mut matches = groups - .iter() - .filter_map(|id| database.group(*id)) - .flat_map(|group| group.entry_ids().collect::>()) - .filter_map(|id| database.entry(id)) - .filter(|entry| entry.get_title() == parts.last().copied()) - .collect::>(); - if matches.len() > 1 { - return Err(anyhow!("KeePassXC entry {path} is ambiguous")); - } - let Some(entry) = matches.pop() else { - return Ok(None); - }; - let secret = entry - .get_password() - .ok_or_else(|| anyhow!("KeePassXC entry {path} has no Password field"))?; - if secret.is_empty() { - return Err(anyhow!( - "KeePassXC entry {path} has an empty Password field" - )); - } - Ok(Some(secret.to_owned())) -} - fn random_password() -> Result { let mut bytes = [0_u8; 32]; fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; Ok(hex::encode(bytes)) } -fn create_entry(database: &mut Database, path: &str, password: &str) -> Result<()> { - let parts: Vec<_> = path.split('/').collect(); - if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { - return Err(anyhow!("invalid KeePassXC entry path")); - } - let mut parent_id = database.root().id(); - for name in &parts[..parts.len() - 1] { - let parent = database - .group(parent_id) - .ok_or_else(|| anyhow!("vault group disappeared"))?; - let matching = parent - .group_ids() - .filter(|id| database.group(*id).is_some_and(|group| group.name == *name)) - .collect::>(); - parent_id = match matching.as_slice() { - [id] => *id, - [] => { - let mut parent = database - .group_mut(parent_id) - .ok_or_else(|| anyhow!("vault group disappeared"))?; - let mut group = parent.add_group(); - (*name).clone_into(&mut group.name); - group.id() - } - _ => return Err(anyhow!("KeePassXC group {name} is ambiguous")), - }; - } - if lookup(database, path)?.is_some() { - return Err(anyhow!("KeePassXC entry {path} already exists")); - } - let mut parent = database - .group_mut(parent_id) - .ok_or_else(|| anyhow!("vault group disappeared"))?; - let mut entry = parent.add_entry(); - entry.set_unprotected("Title", parts[parts.len() - 1]); - entry.set_protected("Password", password); - Ok(()) -} - -fn save_vault( - vault: &mut OpenVault, - key_file: Option<&Path>, - entry_path: &str, - secret: &str, -) -> Result<()> { - let parent = vault - .path - .parent() - .ok_or_else(|| anyhow!("vault has no parent directory"))?; - let lock_path = parent.join(".skillet-vault.lock"); - let lock = fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .mode(0o600) - .open(lock_path) - .context("opening vault write lock")?; - lock.lock().context("locking vault for update")?; - if fs::read(&vault.path).context("rechecking vault before write")? != vault.original { - return Err(anyhow!( - "KeePassXC database changed while Skillet was running; reopen it and retry" - )); - } - let mut candidate = - tempfile::NamedTempFile::new_in(parent).context("creating encrypted vault update")?; - vault - .database - .save( - candidate.as_file_mut(), - database_key(&vault.password, key_file)?, - ) - .context("saving KeePassXC database")?; - candidate.as_file_mut().flush()?; - candidate - .as_file() - .set_permissions(fs::metadata(&vault.path)?.permissions())?; - candidate.as_file().sync_all()?; - let candidate_bytes = fs::read(candidate.path())?; - let reopened = open_with_password(&candidate_bytes, &vault.password, key_file)?; - if lookup(&reopened, entry_path)?.as_deref() != Some(secret) { - return Err(anyhow!( - "saved vault did not retain the generated credential" - )); - } - // Retain a separate encrypted copy of the previous valid vault before - // replacing it. KeePassXC/Syncthing can then recover from a bad write. - let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - let backup_name = format!( - "{}.skillet-{stamp}.bak", - vault - .path - .file_name() - .ok_or_else(|| anyhow!("vault has no filename"))? - .to_string_lossy() - ); - let mut backup = tempfile::NamedTempFile::new_in(parent).context("creating vault backup")?; - backup.write_all(&vault.original)?; - backup - .as_file() - .set_permissions(fs::metadata(&vault.path)?.permissions())?; - backup.as_file().sync_all()?; - backup - .persist_noclobber(parent.join(backup_name)) - .context("preserving previous encrypted vault")?; - if fs::read(&vault.path).context("rechecking vault before replacement")? != vault.original { - return Err(anyhow!( - "KeePassXC database changed during save; generated value was not installed" - )); - } - candidate - .persist(&vault.path) - .context("atomically replacing KeePassXC database")?; - fs::File::open(parent)?.sync_all()?; - vault.original = fs::read(&vault.path).context("refreshing vault snapshot after save")?; - Ok(()) -} - #[cfg(test)] #[path = "secret_delivery/tests.rs"] mod tests; diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 99917557..5cb8a5ca 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,38 +1,7 @@ use super::{ - create_entry, database_path_from, lookup, open_vault, parse_tailscale_addresses, read_vm_port, - save_vault, validate_delivery_service, validate_tailscale_unit_config, OpenVault, + parse_tailscale_addresses, read_vm_port, validate_delivery_service, + validate_tailscale_unit_config, }; -use keepass::Database; -use keepass::DatabaseKey; - -#[test] -fn exact_vault_path_preserves_whitespace() { - let mut db = Database::new(); - let mut root = db.root_mut(); - let mut skillet = root.add_group(); - skillet.name = "skillet".to_string(); - let mut hosts = skillet.add_group(); - hosts.name = "hosts".to_string(); - let mut clamps = hosts.add_group(); - clamps.name = "clamps".to_string(); - let mut pihole = clamps.add_group(); - pihole.name = "pihole".to_string(); - let mut entry = pihole.add_entry(); - entry.set_unprotected("Title", "web-password"); - entry.set_protected("Password", " value\n"); - assert_eq!( - lookup(&db, "skillet/hosts/clamps/pihole/web-password").unwrap(), - Some(" value\n".to_string()) - ); - assert_eq!( - lookup(&db, "skillet/hosts/clamps/pihole/other").unwrap(), - None - ); - assert_eq!( - lookup(&db, "skillet/hosts/other/pihole/web-password").unwrap(), - None - ); -} #[test] fn vm_port_requires_manifest_range() { @@ -71,98 +40,3 @@ fn delivery_eligibility_uses_declared_service_capabilities() { assert!(validate_delivery_service("missing-host", "caddy").is_err()); assert!(validate_delivery_service("clamps", "unknown").is_err()); } - -#[test] -fn disposable_database_opens_with_correct_password_only() { - let mut db = Database::new(); - let mut root = db.root_mut(); - let mut skillet = root.add_group(); - skillet.name = "skillet".to_string(); - let mut entry = skillet.add_entry(); - entry.set_unprotected("Title", "fixture"); - entry.set_protected("Password", "dummy only"); - let mut encrypted = Vec::new(); - db.save( - &mut encrypted, - DatabaseKey::new().with_password("fixture unlock"), - ) - .unwrap(); - assert!(Database::open( - &mut encrypted.as_slice(), - DatabaseKey::new().with_password("wrong") - ) - .is_err()); - let reopened = Database::open( - &mut encrypted.as_slice(), - DatabaseKey::new().with_password("fixture unlock"), - ) - .unwrap(); - assert_eq!( - lookup(&reopened, "skillet/fixture").unwrap(), - Some("dummy only".to_string()) - ); -} - -#[test] -fn vault_path_uses_xdg_data_home_with_home_fallback() { - let xdg = std::path::Path::new("/tmp/xdg-data"); - let home = std::path::Path::new("/tmp/user-home"); - assert_eq!( - database_path_from(Some(xdg), Some(home)).unwrap(), - xdg.join("skillet/secrets.kdbx") - ); - assert_eq!( - database_path_from(Some(std::path::Path::new("relative")), Some(home)).unwrap(), - home.join(".local/share/skillet/secrets.kdbx") - ); - assert!(database_path_from(None, None).is_err()); -} - -#[test] -fn missing_vault_fails_before_password_prompt() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("secrets.kdbx"); - let error = open_vault(&path, None).err().unwrap(); - assert!(error.to_string().contains("missing or unreadable")); - assert!(error.to_string().contains("secrets.kdbx")); -} - -#[test] -fn creates_and_reopens_encrypted_entry_through_symlink_target() { - let dir = tempfile::tempdir().unwrap(); - let database_path = dir.path().join("synced.kdbx"); - let link_path = dir.path().join("secrets.kdbx"); - let db = Database::new(); - let password = "fixture unlock"; - let mut original = Vec::new(); - db.save(&mut original, DatabaseKey::new().with_password(password)) - .unwrap(); - std::fs::write(&database_path, &original).unwrap(); - std::os::unix::fs::symlink(&database_path, &link_path).unwrap(); - let path = std::fs::canonicalize(&link_path).unwrap(); - let mut vault = OpenVault { - path, - original, - database: db, - password: password.to_string(), - }; - let entry_path = "skillet/hosts/clamps/pihole/web-password"; - create_entry(&mut vault.database, entry_path, "generated test value").unwrap(); - save_vault(&mut vault, None, entry_path, "generated test value").unwrap(); - assert!(link_path.is_symlink()); - let changed = std::fs::read(&database_path).unwrap(); - let reopened = Database::open( - &mut changed.as_slice(), - DatabaseKey::new().with_password(password), - ) - .unwrap(); - assert_eq!( - lookup(&reopened, entry_path).unwrap(), - Some("generated test value".to_string()) - ); - assert!(std::fs::read_dir(dir.path()).unwrap().any(|entry| entry - .unwrap() - .file_name() - .to_string_lossy() - .ends_with(".bak"))); -} diff --git a/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs b/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs deleted file mode 100644 index 024d1aa6..00000000 --- a/ublue/skillet/crates/cli/src/secret_delivery/vault_cache.rs +++ /dev/null @@ -1,118 +0,0 @@ -use anyhow::{Context, Result}; -use keyutils::{keytypes::User, Keyring, Permission, SpecialKeyring}; -use sha2::{Digest, Sha256}; -use std::{path::Path, sync::Mutex, time::Duration}; - -const CACHE_LIFETIME: Duration = Duration::from_hours(3); -const SESSION_KEYRING_NAME: &str = "skillet-vault-cache"; - -static SESSION_KEYRING: Mutex> = Mutex::new(None); - -fn description(database: &Path) -> String { - let digest = Sha256::digest(database.as_os_str().as_encoded_bytes()); - format!("skillet:vault:{}", hex::encode(digest)) -} - -fn keyring() -> Result { - // Keys in the user keyring are not possessed by this process on every - // workstation, so setting their expiry can be denied. A named session - // keyring gives the process possession permissions. Link it from the user - // keyring so it remains available to later Skillet invocations after this - // process exits. - let mut cached = SESSION_KEYRING - .lock() - .map_err(|_| anyhow::anyhow!("vault session keyring lock was poisoned"))?; - if let Some(ring) = cached.as_ref() { - return Ok(ring.clone()); - } - - let current = Keyring::attach_or_create(SpecialKeyring::Session) - .ok() - .and_then(|ring| { - ring.description() - .ok() - .filter(|description| description.description == SESSION_KEYRING_NAME) - .map(|_| ring) - }); - let mut ring = match current { - Some(ring) => ring, - None => Keyring::join_session(SESSION_KEYRING_NAME) - .context("opening the named Linux session keyring for the vault cache")?, - }; - // Joining an existing named session keyring requires owner search access. - // The user keyring link below keeps the named ring alive between CLI runs. - ring.set_permissions(Permission::POSSESSOR_ALL | Permission::USER_SEARCH) - .context("setting access on the named vault session keyring")?; - let mut user = Keyring::attach_or_create(SpecialKeyring::User) - .context("opening the Linux user keyring for the vault cache")?; - user.link_keyring(&ring) - .context("keeping the vault session keyring available across invocations")?; - *cached = Some(ring.clone()); - Ok(ring) -} - -pub(super) fn read(database: &Path) -> Result> { - let Ok(ring) = keyring() else { - return Ok(None); - }; - let Ok(key) = ring.search_for_key::(description(database), None) else { - return Ok(None); - }; - let payload = key.read().context("reading the cached vault password")?; - String::from_utf8(payload) - .context("cached vault password is not UTF-8") - .map(Some) -} - -pub(super) fn store(database: &Path, password: &str) -> Result<()> { - let mut ring = match keyring() { - Ok(ring) => ring, - Err(error) => { - eprintln!( - "Vault password was not cached: Linux session keyring unavailable: {error:#}" - ); - return Ok(()); - } - }; - let Ok(mut key) = ring.add_key::(description(database), password.as_bytes()) else { - eprintln!("Vault password was not cached: could not add a kernel key"); - return Ok(()); - }; - if key.set_timeout(CACHE_LIFETIME).is_err() { - // An unbounded cache entry would retain the master password beyond the - // requested lifetime. Drop its only link before proceeding uncached. - ring.unlink_key(&key) - .context("removing a vault key whose expiry could not be set")?; - eprintln!("Vault password was not cached: kernel denied the three-hour expiry"); - } - Ok(()) -} - -pub(super) fn clear(database: &Path) -> Result<()> { - let mut ring = keyring()?; - if let Ok(key) = ring.search_for_key::(description(database), None) { - ring.unlink_key(&key) - .context("removing the cached vault password")?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{clear, read, store}; - - #[test] - fn named_session_cache_expires_and_clears() { - let database = tempfile::tempdir().expect("temporary database path"); - let database = database.path().join("secrets.kdbx"); - - store(&database, "dummy-vault-password").expect("cache password"); - assert_eq!( - read(&database).expect("read cache").as_deref(), - Some("dummy-vault-password") - ); - - clear(&database).expect("clear cache"); - assert_eq!(read(&database).expect("read cleared cache"), None); - } -} diff --git a/ublue/skillet/crates/cli-common/src/credential.rs b/ublue/skillet/crates/core/src/credential_install.rs similarity index 73% rename from ublue/skillet/crates/cli-common/src/credential.rs rename to ublue/skillet/crates/core/src/credential_install.rs index bd5c0d8e..949ad51d 100644 --- a/ublue/skillet/crates/cli-common/src/credential.rs +++ b/ublue/skillet/crates/core/src/credential_install.rs @@ -1,3 +1,5 @@ +//! Guest-side inspection and atomic installation of encrypted credentials. + use std::{ fs, io, os::unix::fs::PermissionsExt as _, @@ -9,7 +11,7 @@ use thiserror::Error; const CREDENTIAL_DIRECTORY: &str = "/etc/credstore.encrypted/skillet"; #[derive(Debug, Error)] -pub enum CredentialError { +pub enum CredentialInstallError { #[error("invalid credential name or service unit")] InvalidName, #[error("I/O error: {0}")] @@ -18,18 +20,18 @@ pub enum CredentialError { Command(&'static str), } -fn credential_path(name: &str) -> Result { +fn credential_path(name: &str) -> Result { if name.is_empty() || !name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') { - return Err(CredentialError::InvalidName); + return Err(CredentialInstallError::InvalidName); } Ok(PathBuf::from(CREDENTIAL_DIRECTORY).join(format!("{name}.cred"))) } -pub fn state(name: &str) -> Result<&'static str, CredentialError> { +pub fn state(name: &str) -> Result<&'static str, CredentialInstallError> { let path = credential_path(name)?; match fs::symlink_metadata(path) { Ok(_) => return Ok("present"), @@ -42,22 +44,14 @@ pub fn state(name: &str) -> Result<&'static str, CredentialError> { match status.code() { Some(0) => Ok("present"), Some(1) => Ok("absent"), - _ => Err(CredentialError::Command("podman secret exists")), + _ => Err(CredentialInstallError::Command("podman secret exists")), } } -pub fn install(name: &str, unit: &str, start_unit: bool) -> Result<(), CredentialError> { +pub fn install(name: &str, unit: &str, start_unit: bool) -> Result<(), CredentialInstallError> { let path = credential_path(name)?; - if !unit.ends_with(".service") - || !unit - .as_bytes() - .first() - .is_some_and(u8::is_ascii_alphanumeric) - || !unit - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'@' | b'.')) - { - return Err(CredentialError::InvalidName); + if !valid_unit(unit) { + return Err(CredentialInstallError::InvalidName); } let directory = PathBuf::from(CREDENTIAL_DIRECTORY); fs::create_dir_all(&directory)?; @@ -71,7 +65,7 @@ pub fn install(name: &str, unit: &str, start_unit: bool) -> Result<(), Credentia .stdout(Stdio::from(encrypted.reopen()?)) .status()?; if !status.success() { - return Err(CredentialError::Command("systemd-creds encrypt")); + return Err(CredentialInstallError::Command("systemd-creds encrypt")); } encrypted.as_file().sync_all()?; let status = Command::new("systemd-creds") @@ -82,15 +76,30 @@ pub fn install(name: &str, unit: &str, start_unit: bool) -> Result<(), Credentia .stdout(Stdio::null()) .status()?; if !status.success() { - return Err(CredentialError::Command("systemd-creds decrypt")); + return Err(CredentialInstallError::Command("systemd-creds decrypt")); } encrypted.persist(path).map_err(|error| error.error)?; fs::File::open(&directory)?.sync_all()?; if start_unit { let status = Command::new("systemctl").args(["start", unit]).status()?; if !status.success() { - return Err(CredentialError::Command("systemctl start")); + return Err(CredentialInstallError::Command("systemctl start")); } } Ok(()) } + +fn valid_unit(unit: &str) -> bool { + unit.ends_with(".service") + && unit + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && unit + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'@' | b'.')) +} + +#[cfg(test)] +#[path = "credential_install/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/credential_install/tests.rs b/ublue/skillet/crates/core/src/credential_install/tests.rs new file mode 100644 index 00000000..04c435d6 --- /dev/null +++ b/ublue/skillet/crates/core/src/credential_install/tests.rs @@ -0,0 +1,32 @@ +use super::{credential_path, valid_unit, CredentialInstallError}; +use std::path::Path; + +#[test] +fn credential_names_are_single_literal_components() { + assert_eq!( + credential_path("pihole_web_password").unwrap(), + Path::new("/etc/credstore.encrypted/skillet/pihole_web_password.cred") + ); + for name in ["", "../secret", "name.service", "name-with-dash", "a/b"] { + assert!(matches!( + credential_path(name), + Err(CredentialInstallError::InvalidName) + )); + } +} + +#[test] +fn consumer_units_are_service_unit_names() { + for unit in ["skillet-full-apply.service", "skillet-caddy-apply.service"] { + assert!(valid_unit(unit)); + } + for unit in [ + "", + "service", + "-service.service", + "../service.service", + "service.timer", + ] { + assert!(!valid_unit(unit)); + } +} diff --git a/ublue/skillet/crates/core/src/lib.rs b/ublue/skillet/crates/core/src/lib.rs index 9b606425..ece22a43 100644 --- a/ublue/skillet/crates/core/src/lib.rs +++ b/ublue/skillet/crates/core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod credential_install; pub mod credentials; pub mod files; pub mod recorder; diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml new file mode 100644 index 00000000..d4d562af --- /dev/null +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "skillet_workstation" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +hex.workspace = true +keepass.workspace = true +keyutils.workspace = true +rpassword.workspace = true +sha2.workspace = true +tempfile.workspace = true +thiserror.workspace = true diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs new file mode 100644 index 00000000..be60e966 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -0,0 +1,3 @@ +//! Workstation-only provisioning capabilities. + +pub mod vault; diff --git a/ublue/skillet/crates/workstation/src/vault.rs b/ublue/skillet/crates/workstation/src/vault.rs new file mode 100644 index 00000000..8c33107e --- /dev/null +++ b/ublue/skillet/crates/workstation/src/vault.rs @@ -0,0 +1,427 @@ +//! `KeePassXC` database access, atomic persistence, and session password cache. + +use keepass::{Database, DatabaseKey}; +use keyutils::{keytypes::User, Keyring, Permission, SpecialKeyring}; +use sha2::{Digest, Sha256}; +use std::{ + error::Error as StdError, + fs, + io::Write as _, + os::unix::fs::OpenOptionsExt as _, + path::{Path, PathBuf}, + sync::Mutex, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; + +const CACHE_LIFETIME: Duration = Duration::from_hours(3); +const SESSION_KEYRING_NAME: &str = "skillet-vault-cache"; +static SESSION_KEYRING: Mutex> = Mutex::new(None); + +#[derive(Debug, Error)] +pub enum VaultError { + #[error("{context}: {source}")] + Source { + context: String, + #[source] + source: Box, + }, + #[error("{0}")] + Invalid(String), +} + +fn source(context: impl Into, error: E) -> VaultError +where + E: StdError + Send + Sync + 'static, +{ + VaultError::Source { + context: context.into(), + source: Box::new(error), + } +} + +pub struct Vault { + path: PathBuf, + original: Vec, + database: Database, + password: String, +} + +impl Vault { + pub fn default_path() -> Result { + let xdg = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); + let home = std::env::var_os("HOME").map(PathBuf::from); + database_path_from(xdg.as_deref(), home.as_deref()) + } + + pub fn open(path: &Path, key_file: Option<&Path>) -> Result { + // Resolve the symlink before any atomic replacement so the synced target + // is updated rather than replacing the symlink itself. + let path = fs::canonicalize(path).map_err(|error| { + source( + format!("KeePassXC database is missing or unreadable at {}; place the synced vault there or pass --database", path.display()), + error, + ) + })?; + let original = + fs::read(&path).map_err(|error| source("reading KeePassXC database", error))?; + if let Some(cached) = cache_read(&path)? { + if let Ok(database) = open_with_password(&original, &cached, key_file) { + return Ok(Self { + path, + original, + database, + password: cached, + }); + } + cache_clear(&path)?; + } + let password = rpassword::prompt_password("KeePassXC database password: ") + .map_err(|error| source("reading database password from terminal", error))?; + let database = open_with_password(&original, &password, key_file)?; + cache_store(&path, &password)?; + Ok(Self { + path, + original, + database, + password, + }) + } + + pub fn get(&self, path: &str) -> Result, VaultError> { + lookup(&self.database, path) + } + + pub fn insert(&mut self, path: &str, secret: &str) -> Result<(), VaultError> { + create_entry(&mut self.database, path, secret) + } + + pub fn ensure_unchanged(&self) -> Result<(), VaultError> { + if fs::read(&self.path).map_err(|error| source("rechecking vault before write", error))? + != self.original + { + return Err(VaultError::Invalid( + "KeePassXC database changed while Skillet was running; reopen it and retry".into(), + )); + } + Ok(()) + } + + pub fn save_verified( + &mut self, + key_file: Option<&Path>, + entry_path: &str, + secret: &str, + ) -> Result<(), VaultError> { + let parent = self + .path + .parent() + .ok_or_else(|| VaultError::Invalid("vault has no parent directory".into()))?; + let lock_path = parent.join(".skillet-vault.lock"); + let lock = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(lock_path) + .map_err(|error| source("opening vault write lock", error))?; + lock.lock() + .map_err(|error| source("locking vault for update", error))?; + self.ensure_unchanged()?; + + let mut candidate = tempfile::NamedTempFile::new_in(parent) + .map_err(|error| source("creating encrypted vault update", error))?; + self.database + .save( + candidate.as_file_mut(), + database_key(&self.password, key_file)?, + ) + .map_err(|error| source("saving KeePassXC database", error))?; + candidate + .as_file_mut() + .flush() + .map_err(|error| source("flushing encrypted vault update", error))?; + candidate + .as_file() + .set_permissions( + fs::metadata(&self.path) + .map_err(|error| source("reading vault metadata", error))? + .permissions(), + ) + .map_err(|error| source("preserving vault permissions", error))?; + candidate + .as_file() + .sync_all() + .map_err(|error| source("syncing encrypted vault update", error))?; + let candidate_bytes = fs::read(candidate.path()) + .map_err(|error| source("reading encrypted vault update", error))?; + let reopened = open_with_password(&candidate_bytes, &self.password, key_file)?; + if lookup(&reopened, entry_path)?.as_deref() != Some(secret) { + return Err(VaultError::Invalid( + "saved vault did not retain the generated credential".into(), + )); + } + + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| source("reading system time", error))? + .as_nanos(); + let backup_name = format!( + "{}.skillet-{stamp}.bak", + self.path + .file_name() + .ok_or_else(|| VaultError::Invalid("vault has no filename".into()))? + .to_string_lossy() + ); + let mut backup = tempfile::NamedTempFile::new_in(parent) + .map_err(|error| source("creating vault backup", error))?; + backup + .write_all(&self.original) + .map_err(|error| source("writing encrypted vault backup", error))?; + backup + .as_file() + .set_permissions( + fs::metadata(&self.path) + .map_err(|error| source("reading vault metadata", error))? + .permissions(), + ) + .map_err(|error| source("preserving backup permissions", error))?; + backup + .as_file() + .sync_all() + .map_err(|error| source("syncing encrypted vault backup", error))?; + backup + .persist_noclobber(parent.join(backup_name)) + .map_err(|error| source("preserving previous encrypted vault", error.error))?; + self.ensure_unchanged()?; + candidate + .persist(&self.path) + .map_err(|error| source("atomically replacing KeePassXC database", error.error))?; + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| source("syncing vault directory", error))?; + self.original = fs::read(&self.path) + .map_err(|error| source("refreshing vault snapshot after save", error))?; + Ok(()) + } +} + +pub fn lock(path: Option<&Path>) -> Result<(), VaultError> { + let path = match path { + Some(path) => path.to_path_buf(), + None => Vault::default_path()?, + }; + let canonical = + fs::canonicalize(path).map_err(|error| source("locating KeePassXC database", error))?; + cache_clear(&canonical) +} + +pub fn database_path_from( + xdg_data_home: Option<&Path>, + home: Option<&Path>, +) -> Result { + let data_home = if let Some(path) = xdg_data_home.filter(|path| path.is_absolute()) { + path.to_path_buf() + } else { + let home = home.filter(|path| path.is_absolute()).ok_or_else(|| { + VaultError::Invalid("set an absolute XDG_DATA_HOME or HOME, or pass --database".into()) + })?; + home.join(".local/share") + }; + Ok(data_home.join("skillet/secrets.kdbx")) +} + +fn database_key(password: &str, key_file: Option<&Path>) -> Result { + let mut key = DatabaseKey::new().with_password(password); + if let Some(path) = key_file { + let mut file = + fs::File::open(path).map_err(|error| source("opening KeePassXC key file", error))?; + key = key + .with_keyfile(&mut file) + .map_err(|error| source("reading KeePassXC key file", error))?; + } + Ok(key) +} + +fn open_with_password( + bytes: &[u8], + password: &str, + key_file: Option<&Path>, +) -> Result { + let mut reader = bytes; + Database::open(&mut reader, database_key(password, key_file)?).map_err(|error| { + source( + "opening KeePassXC database; check password and key file", + error, + ) + }) +} + +fn lookup(database: &Database, path: &str) -> Result, VaultError> { + let parts: Vec<_> = path.split('/').collect(); + if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { + return Err(VaultError::Invalid("invalid KeePassXC entry path".into())); + } + let mut groups = vec![database.root().id()]; + for part in &parts[..parts.len() - 1] { + groups = groups + .into_iter() + .filter_map(|id| database.group(id)) + .flat_map(|group| group.group_ids().collect::>()) + .filter(|id| database.group(*id).is_some_and(|group| group.name == *part)) + .collect(); + } + let mut matches = groups + .iter() + .filter_map(|id| database.group(*id)) + .flat_map(|group| group.entry_ids().collect::>()) + .filter_map(|id| database.entry(id)) + .filter(|entry| entry.get_title() == parts.last().copied()) + .collect::>(); + if matches.len() > 1 { + return Err(VaultError::Invalid(format!( + "KeePassXC entry {path} is ambiguous" + ))); + } + let Some(entry) = matches.pop() else { + return Ok(None); + }; + let secret = entry.get_password().ok_or_else(|| { + VaultError::Invalid(format!("KeePassXC entry {path} has no Password field")) + })?; + if secret.is_empty() { + return Err(VaultError::Invalid(format!( + "KeePassXC entry {path} has an empty Password field" + ))); + } + Ok(Some(secret.to_owned())) +} + +fn create_entry(database: &mut Database, path: &str, password: &str) -> Result<(), VaultError> { + let parts: Vec<_> = path.split('/').collect(); + if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { + return Err(VaultError::Invalid("invalid KeePassXC entry path".into())); + } + let mut parent_id = database.root().id(); + for name in &parts[..parts.len() - 1] { + let parent = database + .group(parent_id) + .ok_or_else(|| VaultError::Invalid("vault group disappeared".into()))?; + let matching = parent + .group_ids() + .filter(|id| database.group(*id).is_some_and(|group| group.name == *name)) + .collect::>(); + parent_id = match matching.as_slice() { + [id] => *id, + [] => { + let mut parent = database + .group_mut(parent_id) + .ok_or_else(|| VaultError::Invalid("vault group disappeared".into()))?; + let mut group = parent.add_group(); + (*name).clone_into(&mut group.name); + group.id() + } + _ => { + return Err(VaultError::Invalid(format!( + "KeePassXC group {name} is ambiguous" + ))) + } + }; + } + if lookup(database, path)?.is_some() { + return Err(VaultError::Invalid(format!( + "KeePassXC entry {path} already exists" + ))); + } + let mut parent = database + .group_mut(parent_id) + .ok_or_else(|| VaultError::Invalid("vault group disappeared".into()))?; + let mut entry = parent.add_entry(); + entry.set_unprotected("Title", parts[parts.len() - 1]); + entry.set_protected("Password", password); + Ok(()) +} + +fn cache_description(database: &Path) -> String { + let digest = Sha256::digest(database.as_os_str().as_encoded_bytes()); + format!("skillet:vault:{}", hex::encode(digest)) +} + +fn session_keyring() -> Result { + let mut cached = SESSION_KEYRING + .lock() + .map_err(|_| VaultError::Invalid("vault session keyring lock was poisoned".into()))?; + if let Some(ring) = cached.as_ref() { + return Ok(ring.clone()); + } + let current = Keyring::attach_or_create(SpecialKeyring::Session) + .ok() + .and_then(|ring| { + ring.description() + .ok() + .filter(|description| description.description == SESSION_KEYRING_NAME) + .map(|_| ring) + }); + let mut ring = match current { + Some(ring) => ring, + None => Keyring::join_session(SESSION_KEYRING_NAME) + .map_err(|error| source("opening the named Linux session keyring", error))?, + }; + ring.set_permissions(Permission::POSSESSOR_ALL | Permission::USER_SEARCH) + .map_err(|error| source("setting access on the named vault session keyring", error))?; + let mut user = Keyring::attach_or_create(SpecialKeyring::User) + .map_err(|error| source("opening the Linux user keyring", error))?; + user.link_keyring(&ring) + .map_err(|error| source("linking the named vault session keyring", error))?; + *cached = Some(ring.clone()); + Ok(ring) +} + +fn cache_read(database: &Path) -> Result, VaultError> { + let Ok(ring) = session_keyring() else { + return Ok(None); + }; + let Ok(key) = ring.search_for_key::(cache_description(database), None) else { + return Ok(None); + }; + String::from_utf8( + key.read() + .map_err(|error| source("reading cached vault password", error))?, + ) + .map(Some) + .map_err(|error| source("decoding cached vault password", error)) +} + +fn cache_store(database: &Path, password: &str) -> Result<(), VaultError> { + let mut ring = match session_keyring() { + Ok(ring) => ring, + Err(error) => { + eprintln!("Vault password was not cached: Linux session keyring unavailable: {error}"); + return Ok(()); + } + }; + let Ok(mut key) = ring.add_key::(cache_description(database), password.as_bytes()) + else { + eprintln!("Vault password was not cached: could not add a kernel key"); + return Ok(()); + }; + if key.set_timeout(CACHE_LIFETIME).is_err() { + ring.unlink_key(&key) + .map_err(|error| source("removing a vault key whose expiry could not be set", error))?; + eprintln!("Vault password was not cached: kernel denied the three-hour expiry"); + } + Ok(()) +} + +fn cache_clear(database: &Path) -> Result<(), VaultError> { + let mut ring = session_keyring()?; + if let Ok(key) = ring.search_for_key::(cache_description(database), None) { + ring.unlink_key(&key) + .map_err(|error| source("removing cached vault password", error))?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "vault/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/vault/tests.rs b/ublue/skillet/crates/workstation/src/vault/tests.rs new file mode 100644 index 00000000..a47782f6 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/vault/tests.rs @@ -0,0 +1,173 @@ +use super::{ + cache_clear, cache_read, cache_store, database_path_from, lookup, Vault, VaultError, + CACHE_LIFETIME, +}; +use keepass::{Database, DatabaseKey}; + +#[test] +fn exact_entry_lookup_preserves_whitespace_and_reports_absence() { + let mut database = Database::new(); + let mut root = database.root_mut(); + let mut skillet = root.add_group(); + skillet.name = "skillet".to_string(); + let mut hosts = skillet.add_group(); + hosts.name = "hosts".to_string(); + let mut clamps = hosts.add_group(); + clamps.name = "clamps".to_string(); + let mut pihole = clamps.add_group(); + pihole.name = "pihole".to_string(); + let mut entry = pihole.add_entry(); + entry.set_unprotected("Title", "web-password"); + entry.set_protected("Password", " value\n"); + assert_eq!( + lookup(&database, "skillet/hosts/clamps/pihole/web-password") + .unwrap() + .as_deref(), + Some(" value\n") + ); + assert_eq!( + lookup(&database, "skillet/hosts/other/pihole/web-password").unwrap(), + None + ); +} + +#[test] +fn xdg_vault_path_uses_data_home_and_absolute_home_fallback() { + let xdg = std::path::Path::new("/tmp/xdg-data"); + let home = std::path::Path::new("/tmp/user-home"); + assert_eq!( + database_path_from(Some(xdg), Some(home)).unwrap(), + xdg.join("skillet/secrets.kdbx") + ); + assert_eq!( + database_path_from(Some(std::path::Path::new("relative")), Some(home)).unwrap(), + home.join(".local/share/skillet/secrets.kdbx") + ); + assert!(matches!( + database_path_from(None, None), + Err(VaultError::Invalid(_)) + )); +} + +#[test] +fn kdbx_round_trip_requires_the_correct_password() { + let mut database = Database::new(); + let mut root = database.root_mut(); + let mut group = root.add_group(); + group.name = "skillet".to_string(); + let mut entry = group.add_entry(); + entry.set_unprotected("Title", "fixture"); + entry.set_protected("Password", "dummy only"); + let mut encrypted = Vec::new(); + database + .save( + &mut encrypted, + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + assert!(Database::open( + &mut encrypted.as_slice(), + DatabaseKey::new().with_password("wrong") + ) + .is_err()); + let opened = Database::open( + &mut encrypted.as_slice(), + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + assert_eq!( + lookup(&opened, "skillet/fixture").unwrap().as_deref(), + Some("dummy only") + ); +} + +#[test] +fn verified_save_updates_symlink_target_and_keeps_recovery_copy() { + let dir = tempfile::tempdir().unwrap(); + let database_path = dir.path().join("synced.kdbx"); + let link_path = dir.path().join("secrets.kdbx"); + let database = Database::new(); + let password = "fixture unlock"; + let mut original = Vec::new(); + database + .save(&mut original, DatabaseKey::new().with_password(password)) + .unwrap(); + std::fs::write(&database_path, &original).unwrap(); + std::os::unix::fs::symlink(&database_path, &link_path).unwrap(); + let mut vault = Vault { + path: std::fs::canonicalize(&link_path).unwrap(), + original, + database, + password: password.to_string(), + }; + let entry_path = "skillet/hosts/clamps/pihole/web-password"; + vault.insert(entry_path, "generated test value").unwrap(); + vault + .save_verified(None, entry_path, "generated test value") + .unwrap(); + assert!(link_path.is_symlink()); + let changed = std::fs::read(&database_path).unwrap(); + let reopened = Database::open( + &mut changed.as_slice(), + DatabaseKey::new().with_password(password), + ) + .unwrap(); + assert_eq!( + lookup(&reopened, entry_path).unwrap().as_deref(), + Some("generated test value") + ); + assert!(std::fs::read_dir(dir.path()).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".bak"))); +} + +#[test] +fn verified_save_refuses_a_concurrent_vault_change() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.kdbx"); + let database = Database::new(); + let password = "fixture unlock"; + let mut original = Vec::new(); + database + .save(&mut original, DatabaseKey::new().with_password(password)) + .unwrap(); + std::fs::write(&path, &original).unwrap(); + let mut vault = Vault { + path: path.clone(), + original, + database, + password: password.to_string(), + }; + vault.insert("skillet/new-token", "dummy").unwrap(); + std::fs::write(&path, b"external update").unwrap(); + assert!(vault + .save_verified(None, "skillet/new-token", "dummy") + .is_err()); +} + +#[test] +fn session_password_cache_uses_three_hour_expiry_and_can_be_cleared() { + assert_eq!(CACHE_LIFETIME, std::time::Duration::from_hours(3)); + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("secrets.kdbx"); + cache_store(&database, "dummy vault password").unwrap(); + assert_eq!( + cache_read(&database).unwrap().as_deref(), + Some("dummy vault password") + ); + cache_clear(&database).unwrap(); + assert_eq!(cache_read(&database).unwrap(), None); +} + +#[test] +fn missing_vault_fails_before_password_prompt() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("secrets.kdbx"); + let Err(error) = Vault::open(&path, None) else { + panic!("missing vault unexpectedly opened"); + }; + assert!(error.to_string().contains("missing or unreadable")); + assert!(error.to_string().contains("secrets.kdbx")); +} From f767151f530a8e57005c795c3768bd003be49b12 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 01:15:40 +0200 Subject: [PATCH 149/252] refactor(workstation): extract Cloudflare provider --- ublue/butane/ACCEPTANCE.md | 12 ++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 12 +- ublue/skillet/Cargo.lock | 5 +- ublue/skillet/crates/cli/Cargo.toml | 1 - ublue/skillet/crates/cli/src/main.rs | 1 - .../skillet/crates/cli/src/secret_delivery.rs | 34 ++-- ublue/skillet/crates/workstation/Cargo.toml | 4 + .../src/cloudflare/mod.rs} | 170 ++++++++++++------ .../src/cloudflare/tests.rs} | 0 ublue/skillet/crates/workstation/src/lib.rs | 1 + 10 files changed, 169 insertions(+), 71 deletions(-) rename ublue/skillet/crates/{cli/src/cloudflare.rs => workstation/src/cloudflare/mod.rs} (82%) rename ublue/skillet/crates/{cli/src/cloudflare_tests.rs => workstation/src/cloudflare/tests.rs} (100%) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index b577ecc5..a675078d 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -971,3 +971,15 @@ host tool; it still uses normal command approvals. repeat apply did not restart, and the container was removed. Live credential delivery and rotation recovery on a disposable VM remain unverified because `/dev/kvm` is unavailable. + +### Cloudflare workstation provider library, 2026-10-04 + +- Moved the Cloudflare SDK adapter, scoped account-token issuance/revocation, + DNS reconciliation, desired record validation, and the local HTTP fixtures + into `skillet_workstation::cloudflare`. The library uses a `thiserror` + boundary preserving SDK/JSON sources; the CLI calls the library API. +- The 10 Cloudflare provider tests passed, including DNS reconciliation and + ambiguous-token retry cases. Full workspace formatting/tests/strict + all-target Clippy and the static-musl build passed. CI container integration + exited 0; both applies succeeded, repeat apply issued no restart, and its + disposable container was removed. No live Cloudflare API mutation was made. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index fbcc70cc..2ac6b242 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -97,7 +97,11 @@ and VM delivery have duplicate implementations with different validation. conflict detection, verified encrypted replacement, recovery backup, and three-hour cache/lock operations. It uses `thiserror` and does not implement `Debug` for the unlocked vault value. -- Pending: Cloudflare/Tailscale provider and lifecycle orchestration still - lives in CLI modules, and VM resource ownership persistence has not been - consolidated with the lifecycle coordinator. Finish the remaining sequence - items and run named-VM acceptance. +- Implemented: the Cloudflare SDK client, token and DNS operations, domain + validation, and local HTTP fixtures now live in `skillet_workstation`. Its + library API uses `thiserror`; the CLI contains only the current orchestration + decisions calling that API. +- Pending: Tailscale provider and lifecycle orchestration still live in CLI + modules, and VM resource ownership persistence has not been consolidated + with the lifecycle coordinator. Finish the remaining sequence items and run + named-VM acceptance. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 6e28b6a3..a11a0503 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2279,7 +2279,6 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", - "cloudflare", "hex", "keepass", "keyutils", @@ -2466,11 +2465,15 @@ dependencies = [ name = "skillet_workstation" version = "0.1.0" dependencies = [ + "cloudflare", "hex", "keepass", "keyutils", "rpassword", + "serde", + "serde_json", "sha2 0.10.9", + "skillet_caddy", "tempfile", "thiserror 1.0.69", ] diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 0905cb32..4fb489f4 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -20,7 +20,6 @@ tracing-subscriber.workspace = true anyhow = "1.0" keepass.workspace = true rpassword.workspace = true -cloudflare.workspace = true reqwest.workspace = true keyutils.workspace = true hex.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 4277f53e..c22259bd 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -5,7 +5,6 @@ use std::{fs, path::PathBuf, process::Command}; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; -mod cloudflare; mod secret_delivery; mod tailscale; mod test_fixture; diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 72816fe5..e3224dc9 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -110,7 +110,7 @@ fn deliver_caddy_from_vault( .get(&zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; let zone_id = zone_id.trim(); - crate::cloudflare::validate_zone_id(zone_id)?; + skillet_workstation::cloudflare::validate_zone_id(zone_id)?; let creator = vault .get("skillet/cloudflare/token-creator")? .ok_or_else(|| { @@ -118,7 +118,7 @@ fn deliver_caddy_from_vault( "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" ) })?; - let cloudflare = crate::cloudflare::Cloudflare::new(); + let cloudflare = skillet_workstation::cloudflare::Cloudflare::new(); let zone = cloudflare.zone(&creator, zone_id)?; let domain = skillet_caddy::resolve_ui_domain(&zone.name, domain_prefix.as_deref()) .context("resolving KeePassXC relative UI domain beneath its Cloudflare zone")?; @@ -134,10 +134,13 @@ fn deliver_caddy_from_vault( let device = tailscale::find_device_by_hostname(&tailnet, &args.hostname, tailscale::SERVER_TAG)?; let dns_marker = format!("skillet:{environment}:{}", args.hostname); - let dns = - crate::cloudflare::desired_records(&sites.machine_hostname, &device.addresses, &sites)?; + let dns = skillet_workstation::cloudflare::desired_records( + &sites.machine_hostname, + &device.addresses, + &sites, + )?; ensure_vault_unchanged(vault)?; - let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?; + let account_id = skillet_workstation::cloudflare::Cloudflare::account_id(&zone)?; let token = host_acme_token(args, vault, &cloudflare, &creator, zone_id, account_id)?; cloudflare.zone(&token, zone_id)?; ensure_vault_unchanged(vault)?; @@ -170,7 +173,7 @@ fn deliver_caddy_from_vault( fn host_acme_token( args: &SecretDeliverArgs, vault: &mut Vault, - api: &crate::cloudflare::Cloudflare, + api: &skillet_workstation::cloudflare::Cloudflare, creator: &str, zone_id: &str, account_id: &str, @@ -467,8 +470,8 @@ fn provision_vm_ui( ) })?; let zone_id = zone_id.trim().to_string(); - crate::cloudflare::validate_zone_id(&zone_id)?; - let api = crate::cloudflare::Cloudflare::new(); + skillet_workstation::cloudflare::validate_zone_id(&zone_id)?; + let api = skillet_workstation::cloudflare::Cloudflare::new(); let zone = api.zone(&creator, &zone_id)?; let ui_domain = skillet_caddy::resolve_ui_domain(&zone.name, domain_prefix.as_deref()) .context("resolving test relative UI domain beneath its Cloudflare zone")?; @@ -482,8 +485,11 @@ fn provision_vm_ui( }, &host_ui.services, )?; - let dns = - crate::cloudflare::desired_records(&sites.machine_hostname, &device.addresses, &sites)?; + let dns = skillet_workstation::cloudflare::desired_records( + &sites.machine_hostname, + &device.addresses, + &sites, + )?; let marker = format!("skillet:test:{}:{}", args.hostname, args.instance); let token_name = format!("skillet:test:{}-{}", args.hostname, args.instance); let metadata_path = run_dir.join("cloudflare.json"); @@ -497,7 +503,7 @@ fn provision_vm_ui( expires_on: None, record_ids: Vec::new(), }; - let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?.to_string(); + let account_id = skillet_workstation::cloudflare::Cloudflare::account_id(&zone)?.to_string(); write_cloudflare_ownership(&metadata_path, &ownership)?; ensure_vault_unchanged(vault)?; let issued = api.create_zone_token( @@ -590,7 +596,7 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( "Cloudflare metadata does not match this VM identity; refusing cleanup" )); } - crate::cloudflare::validate_zone_id(&ownership.zone_id)?; + skillet_workstation::cloudflare::validate_zone_id(&ownership.zone_id)?; skillet_caddy::validate_domain_in_zone(&ownership.ui_domain, &ownership.ui_domain) .context("validating Cloudflare UI domain in VM metadata")?; let vault_path = args @@ -611,9 +617,9 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( if configured_zone.trim() != ownership.zone_id { return Err(anyhow!("test Cloudflare configuration differs from the recorded VM owner; restore the original vault values before cleanup")); } - let api = crate::cloudflare::Cloudflare::new(); + let api = skillet_workstation::cloudflare::Cloudflare::new(); let zone = api.zone(&creator, &ownership.zone_id)?; - let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?.to_string(); + let account_id = skillet_workstation::cloudflare::Cloudflare::account_id(&zone)?.to_string(); let configured_domain = skillet_caddy::resolve_ui_domain(&zone.name, configured_prefix.as_deref()) .context("resolving test UI namespace before VM cleanup")?; diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml index d4d562af..a030bdd2 100644 --- a/ublue/skillet/crates/workstation/Cargo.toml +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -7,10 +7,14 @@ edition = "2021" workspace = true [dependencies] +cloudflare.workspace = true hex.workspace = true keepass.workspace = true keyutils.workspace = true rpassword.workspace = true +serde.workspace = true +serde_json.workspace = true sha2.workspace = true +skillet_caddy.workspace = true tempfile.workspace = true thiserror.workspace = true diff --git a/ublue/skillet/crates/cli/src/cloudflare.rs b/ublue/skillet/crates/workstation/src/cloudflare/mod.rs similarity index 82% rename from ublue/skillet/crates/cli/src/cloudflare.rs rename to ublue/skillet/crates/workstation/src/cloudflare/mod.rs index d8e6d284..6554ad97 100644 --- a/ublue/skillet/crates/cli/src/cloudflare.rs +++ b/ublue/skillet/crates/workstation/src/cloudflare/mod.rs @@ -1,4 +1,3 @@ -use anyhow::{anyhow, Context, Result}; use cloudflare::framework::{ auth::Credentials, client::{blocking_api::HttpApiClient, ClientConfig}, @@ -10,19 +9,64 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{ collections::{BTreeMap, BTreeSet}, + error::Error as StdError, net::IpAddr, time::{Duration, SystemTime, UNIX_EPOCH}, }; +use thiserror::Error; const API_BASE: &str = "https://api.cloudflare.com/client/v4/"; const RECORD_TTL: u32 = 120; #[cfg(test)] -#[path = "cloudflare_tests.rs"] +#[path = "tests.rs"] mod tests; +#[derive(Debug, Error)] +pub enum CloudflareError { + #[error("{context}: {source}")] + Source { + context: String, + #[source] + source: Box, + }, + #[error("{0}")] + Invalid(String), +} + +pub type Result = std::result::Result; + +fn source(context: impl Into, error: E) -> CloudflareError +where + E: StdError + Send + Sync + 'static, +{ + CloudflareError::Source { + context: context.into(), + source: Box::new(error), + } +} + +trait ResultContext { + fn context(self, context: impl Into) -> Result; +} + +impl ResultContext for std::result::Result +where + E: StdError + Send + Sync + 'static, +{ + fn context(self, context: impl Into) -> Result { + self.map_err(|error| source(context, error)) + } +} + +impl From for CloudflareError { + fn from(error: serde_json::Error) -> Self { + source("Cloudflare JSON", error) + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct RecordRef { +pub struct RecordRef { pub id: String, pub name: String, #[serde(rename = "type")] @@ -32,43 +76,49 @@ pub(crate) struct RecordRef { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct IssuedToken { +pub struct IssuedToken { pub id: String, pub value: String, pub expires_on: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct Zone { +pub struct Zone { pub id: String, pub name: String, pub account: Option, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct ZoneAccount { +pub struct ZoneAccount { pub id: String, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct OwnedDns { +pub struct OwnedDns { pub marker: String, pub records: Vec, } #[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct DesiredRecord { +pub struct DesiredRecord { pub name: String, pub record_type: String, pub content: String, } -pub(crate) struct Cloudflare { +pub struct Cloudflare { base: String, } +impl Default for Cloudflare { + fn default() -> Self { + Self::new() + } +} + impl Cloudflare { - pub(crate) fn new() -> Self { + pub fn new() -> Self { Self { base: API_BASE.to_string(), } @@ -113,7 +163,7 @@ impl Cloudflare { .context("building cloudflare-rs client")?; client .request(&endpoint) - .map_err(|error| anyhow!("Cloudflare API request failed: {error}")) + .map_err(|error| source("Cloudflare API request failed", error)) } fn result( @@ -127,7 +177,7 @@ impl Cloudflare { Ok(self.request(token, method, path, query, body)?.result.0) } - pub(crate) fn zone(&self, token: &str, zone_id: &str) -> Result { + pub fn zone(&self, token: &str, zone_id: &str) -> Result { validate_zone_id(zone_id)?; let value = self .result(token, Method::GET, format!("zones/{zone_id}"), None, None) @@ -135,12 +185,16 @@ impl Cloudflare { serde_json::from_value(value).context("decoding Cloudflare zone") } - pub(crate) fn account_id(zone: &Zone) -> Result<&str> { + pub fn account_id(zone: &Zone) -> Result<&str> { zone.account .as_ref() .map(|account| account.id.as_str()) .filter(|id| validate_token_id(id).is_ok()) - .ok_or_else(|| anyhow!("Cloudflare zone response does not include a valid account ID")) + .ok_or_else(|| { + CloudflareError::Invalid( + "Cloudflare zone response does not include a valid account ID".into(), + ) + }) } fn permission_groups(&self, token: &str, account_id: &str) -> Result<(String, String)> { @@ -152,9 +206,9 @@ impl Cloudflare { Some("per_page=500".to_string()), None, ).context("discovering Cloudflare account-token permission groups; the issuer needs Account > API Tokens > Read or Write")?; - let groups = value - .as_array() - .ok_or_else(|| anyhow!("Cloudflare permission group response is malformed"))?; + let groups = value.as_array().ok_or_else(|| { + CloudflareError::Invalid("Cloudflare permission group response is malformed".into()) + })?; let mut zone_read = None; let mut dns_write = None; for group in groups { @@ -189,11 +243,11 @@ impl Cloudflare { } match (zone_read, dns_write) { (Some(read), Some(write)) => Ok((read, write)), - _ => Err(anyhow!("Cloudflare did not expose selectable Zone Read and DNS Write permission groups for this account")), + _ => Err(CloudflareError::Invalid("Cloudflare did not expose selectable Zone Read and DNS Write permission groups for this account".into())), } } - pub(crate) fn create_zone_token( + pub fn create_zone_token( &self, creator_token: &str, zone_id: &str, @@ -224,7 +278,7 @@ impl Cloudflare { serde_json::from_value(value).context("decoding newly issued Cloudflare token") } - pub(crate) fn replace_named_zone_token( + pub fn replace_named_zone_token( &self, creator_token: &str, zone_id: &str, @@ -241,7 +295,7 @@ impl Cloudflare { self.create_zone_token(creator_token, zone_id, account_id, name, lifetime) } - pub(crate) fn token_ids_by_name( + pub fn token_ids_by_name( &self, creator_token: &str, account_id: &str, @@ -257,11 +311,9 @@ impl Cloudflare { Some(format!("page={page}&per_page=100")), None, )?; - let values = response - .result - .0 - .as_array() - .ok_or_else(|| anyhow!("Cloudflare token list response is malformed"))?; + let values = response.result.0.as_array().ok_or_else(|| { + CloudflareError::Invalid("Cloudflare token list response is malformed".into()) + })?; matches.extend(values.iter().filter_map(|token| { (token.get("name").and_then(Value::as_str) == Some(name)) .then(|| token.get("id").and_then(Value::as_str)) @@ -282,7 +334,7 @@ impl Cloudflare { Ok(matches) } - pub(crate) fn revoke_token( + pub fn revoke_token( &self, creator_token: &str, account_id: &str, @@ -391,7 +443,7 @@ impl Cloudflare { Ok(()) } - pub(crate) fn reconcile_dns( + pub fn reconcile_dns( &self, token: &str, zone_id: &str, @@ -402,7 +454,9 @@ impl Cloudflare { validate_zone_id(zone_id)?; validate_fqdn(ui_domain)?; if marker.is_empty() || marker.len() > 255 { - return Err(anyhow!("invalid Skillet DNS ownership marker")); + return Err(CloudflareError::Invalid( + "invalid Skillet DNS ownership marker".into(), + )); } let mut wanted: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); for record in desired { @@ -423,10 +477,10 @@ impl Cloudflare { .filter(|record| desired_names.contains(&record.name.to_ascii_lowercase())) { if record.comment.as_deref() != Some(marker) { - return Err(anyhow!( + return Err(CloudflareError::Invalid(format!( "DNS name {} has a record not owned by this Skillet deployment", record.name - )); + ))); } } @@ -473,7 +527,7 @@ impl Cloudflare { .filter(|record| record.comment.as_deref() == Some(marker)) { if stale.name != ui_domain && !stale.name.ends_with(&format!(".{ui_domain}")) { - return Err(anyhow!("Skillet ownership marker is present outside the configured UI domain; refusing cleanup")); + return Err(CloudflareError::Invalid("Skillet ownership marker is present outside the configured UI domain; refusing cleanup".into())); } if !retained_ids.contains(stale.id.as_str()) { self.delete_record(token, zone_id, &stale.id)?; @@ -492,7 +546,7 @@ impl Cloudflare { }) } - pub(crate) fn remove_dns_marker( + pub fn remove_dns_marker( &self, token: &str, zone_id: &str, @@ -508,7 +562,7 @@ impl Cloudflare { if !matches!(record.record_type.as_str(), "A" | "AAAA" | "CNAME") || !(record.name == ui_domain || record.name.ends_with(&format!(".{ui_domain}"))) { - return Err(anyhow!("Skillet ownership marker appears on an unexpected DNS record; refusing cleanup")); + return Err(CloudflareError::Invalid("Skillet ownership marker appears on an unexpected DNS record; refusing cleanup".into())); } self.delete_record(token, zone_id, &record.id)?; } @@ -516,7 +570,7 @@ impl Cloudflare { } } -pub(crate) fn desired_records( +pub fn desired_records( machine_name: &str, addresses: &BTreeSet, sites: &skillet_caddy::CaddySites, @@ -538,8 +592,8 @@ pub(crate) fn desired_records( }); } if !has_v4 || !has_v6 { - return Err(anyhow!( - "Tailscale device must have both IPv4 and IPv6 addresses before publishing DNS" + return Err(CloudflareError::Invalid( + "Tailscale device must have both IPv4 and IPv6 addresses before publishing DNS".into(), )); } for service in &sites.services { @@ -559,25 +613,29 @@ pub(crate) fn desired_records( Ok(result) } -pub(crate) fn validate_zone_id(id: &str) -> Result<()> { +pub fn validate_zone_id(id: &str) -> Result<()> { if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(anyhow!( - "Cloudflare Zone ID must be 32 hexadecimal characters" + return Err(CloudflareError::Invalid( + "Cloudflare Zone ID must be 32 hexadecimal characters".into(), )); } Ok(()) } -pub(crate) fn validate_token_id(id: &str) -> Result<()> { +pub fn validate_token_id(id: &str) -> Result<()> { if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(anyhow!("Cloudflare token ID is malformed")); + return Err(CloudflareError::Invalid( + "Cloudflare token ID is malformed".into(), + )); } Ok(()) } fn validate_record_id(id: &str) -> Result<()> { if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err(anyhow!("Cloudflare DNS record ID is malformed")); + return Err(CloudflareError::Invalid( + "Cloudflare DNS record ID is malformed".into(), + )); } Ok(()) } @@ -589,30 +647,42 @@ fn validate_record_content(record_type: &str, content: &str) -> Result<()> { .parse::() .context("invalid IP in DNS plan")?; if (record_type == "A") != address.is_ipv4() { - return Err(anyhow!("DNS record address family does not match type")); + return Err(CloudflareError::Invalid( + "DNS record address family does not match type".into(), + )); } } "CNAME" => validate_fqdn(content)?, - _ => return Err(anyhow!("unsupported managed DNS record type")), + _ => { + return Err(CloudflareError::Invalid( + "unsupported managed DNS record type".into(), + )) + } } Ok(()) } fn validate_fqdn(name: &str) -> Result<()> { - skillet_caddy::validate_domain(name).map_err(|error| anyhow!("{error}")) + skillet_caddy::validate_domain(name) + .map_err(|error| CloudflareError::Invalid(error.to_string())) } fn expiry_rfc3339(lifetime: Duration) -> Result { let seconds = SystemTime::now() .checked_add(lifetime) - .ok_or_else(|| anyhow!("Cloudflare token expiry overflow"))? - .duration_since(UNIX_EPOCH)? + .ok_or_else(|| CloudflareError::Invalid("Cloudflare token expiry overflow".into()))? + .duration_since(UNIX_EPOCH) + .map_err(|error| source("calculating Cloudflare token expiry", error))? .as_secs(); let days = seconds / 86_400; let time = seconds % 86_400; - let (year, month, day) = civil_from_days(i64::try_from(days)?); + let (year, month, day) = civil_from_days( + i64::try_from(days).map_err(|error| source("converting Cloudflare token expiry", error))?, + ); if !(1..=9999).contains(&year) { - return Err(anyhow!("Cloudflare token expiry is outside RFC3339 range")); + return Err(CloudflareError::Invalid( + "Cloudflare token expiry is outside RFC3339 range".into(), + )); } Ok(format!( "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", diff --git a/ublue/skillet/crates/cli/src/cloudflare_tests.rs b/ublue/skillet/crates/workstation/src/cloudflare/tests.rs similarity index 100% rename from ublue/skillet/crates/cli/src/cloudflare_tests.rs rename to ublue/skillet/crates/workstation/src/cloudflare/tests.rs diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index be60e966..39949e20 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -1,3 +1,4 @@ //! Workstation-only provisioning capabilities. +pub mod cloudflare; pub mod vault; From 798cca4fae64cf8e37d5fb82bd4c660efc56d5de Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 01:22:15 +0200 Subject: [PATCH 150/252] refactor(workstation): extract Tailscale provider --- ublue/butane/ACCEPTANCE.md | 13 ++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 12 +- ublue/skillet/Cargo.lock | 2 +- ublue/skillet/crates/cli/Cargo.toml | 1 - ublue/skillet/crates/cli/src/main.rs | 1 - .../skillet/crates/cli/src/secret_delivery.rs | 6 +- ublue/skillet/crates/workstation/Cargo.toml | 1 + ublue/skillet/crates/workstation/src/lib.rs | 1 + .../src/tailscale/mod.rs} | 171 +++++++++++++----- .../src/tailscale/tests.rs} | 0 10 files changed, 150 insertions(+), 58 deletions(-) rename ublue/skillet/crates/{cli/src/tailscale.rs => workstation/src/tailscale/mod.rs} (69%) rename ublue/skillet/crates/{cli/src/tailscale_tests.rs => workstation/src/tailscale/tests.rs} (100%) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index a675078d..d6ee4d68 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -983,3 +983,16 @@ host tool; it still uses normal command approvals. all-target Clippy and the static-musl build passed. CI container integration exited 0; both applies succeeded, repeat apply issued no restart, and its disposable container was removed. No live Cloudflare API mutation was made. + +### Tailscale workstation provider library, 2026-10-04 + +- Moved OAuth, auth-key, tagged-device lookup, and device-removal operations + into `skillet_workstation::tailscale`; removed the CLI's direct `reqwest` + dependency. Provider errors use `thiserror` and preserve transport sources. +- Local HTTP fixture tests passed for token/auth-key requests, tagged lookup and + removal, and API errors. Workspace formatting, offline tests, strict + all-target Clippy, and the CI container integration command passed. The + integration command applied the beezelbot base twice, confirmed the repeat + caused no service restart, and removed its disposable container. +- Live Tailscale API/VM acceptance was not run. Lifecycle ownership and + production-versus-test orchestration remain pending in workstream 4. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 2ac6b242..0b5e93be 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -101,7 +101,11 @@ and VM delivery have duplicate implementations with different validation. validation, and local HTTP fixtures now live in `skillet_workstation`. Its library API uses `thiserror`; the CLI contains only the current orchestration decisions calling that API. -- Pending: Tailscale provider and lifecycle orchestration still live in CLI - modules, and VM resource ownership persistence has not been consolidated - with the lifecycle coordinator. Finish the remaining sequence items and run - named-VM acceptance. +- Implemented: Tailscale's bounded HTTP client, OAuth auth-key creation, + tagged-device lookup/removal, and local protocol tests now live in + `skillet_workstation::tailscale`; the CLI no longer depends directly on + `reqwest` for this provider. +- Pending: environment/host credential-delivery orchestration, durable versus + disposable policy, provider/VM cleanup ownership persistence, and production + plus named-VM acceptance remain in CLI modules. Finish the remaining sequence + items before closing this workstream. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index a11a0503..c5f489ed 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2282,7 +2282,6 @@ dependencies = [ "hex", "keepass", "keyutils", - "reqwest", "rpassword", "serde", "serde_json", @@ -2469,6 +2468,7 @@ dependencies = [ "hex", "keepass", "keyutils", + "reqwest", "rpassword", "serde", "serde_json", diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 4fb489f4..db4f09b8 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -20,7 +20,6 @@ tracing-subscriber.workspace = true anyhow = "1.0" keepass.workspace = true rpassword.workspace = true -reqwest.workspace = true keyutils.workspace = true hex.workspace = true sha2.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index c22259bd..0d8d91f6 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -6,7 +6,6 @@ use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; mod secret_delivery; -mod tailscale; mod test_fixture; mod vm; diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index e3224dc9..49541652 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -1,9 +1,9 @@ use super::{ - butane_root, tailscale, vm_name, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, - VmProvisionArgs, + butane_root, vm_name, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, VmProvisionArgs, }; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; +use skillet_workstation::tailscale; use skillet_workstation::vault::Vault; use std::{ fs, @@ -242,7 +242,7 @@ fn tailscale_credentials(vault: &Vault) -> Result { .ok_or_else(|| { anyhow!("KeePassXC entry skillet/tailscale/provisioner-client-secret is missing") })?; - tailscale::OAuthCredentials::new(client_id, client_secret) + Ok(tailscale::OAuthCredentials::new(client_id, client_secret)?) } fn ssh_command(args: &SecretDeliverArgs) -> Command { diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml index a030bdd2..565403e2 100644 --- a/ublue/skillet/crates/workstation/Cargo.toml +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -12,6 +12,7 @@ hex.workspace = true keepass.workspace = true keyutils.workspace = true rpassword.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index 39949e20..f922c91e 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -1,4 +1,5 @@ //! Workstation-only provisioning capabilities. pub mod cloudflare; +pub mod tailscale; pub mod vault; diff --git a/ublue/skillet/crates/cli/src/tailscale.rs b/ublue/skillet/crates/workstation/src/tailscale/mod.rs similarity index 69% rename from ublue/skillet/crates/cli/src/tailscale.rs rename to ublue/skillet/crates/workstation/src/tailscale/mod.rs index 1f1442f5..16b88fc4 100644 --- a/ublue/skillet/crates/cli/src/tailscale.rs +++ b/ublue/skillet/crates/workstation/src/tailscale/mod.rs @@ -1,15 +1,58 @@ -use anyhow::{anyhow, Context, Result}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::{collections::BTreeSet, fmt::Write as _, time::Duration}; +use std::{collections::BTreeSet, error::Error as StdError, fmt::Write as _, time::Duration}; +use thiserror::Error; const API_BASE: &str = "https://api.tailscale.com/api/v2"; -pub(crate) const PROVISIONER_TAG: &str = "tag:skillet-provisioner"; -pub(crate) const SERVER_TAG: &str = "tag:skillet-server"; -pub(crate) const SMOKE_TAG: &str = "tag:skillet-smoke"; +pub const PROVISIONER_TAG: &str = "tag:skillet-provisioner"; +pub const SERVER_TAG: &str = "tag:skillet-server"; +pub const SMOKE_TAG: &str = "tag:skillet-smoke"; -pub(crate) struct OAuthCredentials { +#[derive(Debug, Error)] +pub enum TailscaleError { + #[error("{context}: {source}")] + Source { + context: String, + #[source] + source: Box, + }, + #[error("{0}")] + Invalid(String), +} + +pub type Result = std::result::Result; + +fn source(context: impl Into, error: E) -> TailscaleError +where + E: StdError + Send + Sync + 'static, +{ + TailscaleError::Source { + context: context.into(), + source: Box::new(error), + } +} + +trait ResultContext { + fn context(self, context: impl Into) -> Result; +} + +impl ResultContext for std::result::Result +where + E: StdError + Send + Sync + 'static, +{ + fn context(self, context: impl Into) -> Result { + self.map_err(|error| source(context, error)) + } +} + +impl From for TailscaleError { + fn from(error: serde_json::Error) -> Self { + source("Tailscale JSON", error) + } +} + +pub struct OAuthCredentials { client_id: String, client_secret: String, api: ApiClient, @@ -24,7 +67,9 @@ impl ApiClient { fn new(base_url: &str) -> Result { let base_url = base_url.trim_end_matches('/'); if !base_url.starts_with("http://") && !base_url.starts_with("https://") { - return Err(anyhow!("invalid Tailscale API base URL")); + return Err(TailscaleError::Invalid( + "invalid Tailscale API base URL".into(), + )); } let http = Client::builder() .connect_timeout(Duration::from_secs(10)) @@ -68,10 +113,14 @@ impl ApiClient { .map(str::to_owned) }); return Err(match message { - Some(message) => { - anyhow!("Tailscale API returned HTTP {}: {message}", status.as_u16()) - } - None => anyhow!("Tailscale API request failed with HTTP {}", status.as_u16()), + Some(message) => TailscaleError::Invalid(format!( + "Tailscale API returned HTTP {}: {message}", + status.as_u16() + )), + None => TailscaleError::Invalid(format!( + "Tailscale API request failed with HTTP {}", + status.as_u16() + )), }); } if payload.is_empty() { @@ -82,9 +131,11 @@ impl ApiClient { } impl OAuthCredentials { - pub(crate) fn new(client_id: String, client_secret: String) -> Result { + pub fn new(client_id: String, client_secret: String) -> Result { if client_id.trim().is_empty() || client_secret.trim().is_empty() { - return Err(anyhow!("Tailscale OAuth credentials are empty")); + return Err(TailscaleError::Invalid( + "Tailscale OAuth credentials are empty".into(), + )); } Ok(Self { client_id, @@ -96,7 +147,9 @@ impl OAuthCredentials { #[cfg(test)] fn with_api_base(client_id: String, client_secret: String, base_url: &str) -> Result { if client_id.trim().is_empty() || client_secret.trim().is_empty() { - return Err(anyhow!("Tailscale OAuth credentials are empty")); + return Err(TailscaleError::Invalid( + "Tailscale OAuth credentials are empty".into(), + )); } Ok(Self { client_id, @@ -128,28 +181,34 @@ impl OAuthCredentials { .and_then(Value::as_str) .filter(|token| !token.is_empty()) .map(ToOwned::to_owned) - .ok_or_else(|| anyhow!("Tailscale OAuth response did not contain an access token")) + .ok_or_else(|| { + TailscaleError::Invalid( + "Tailscale OAuth response did not contain an access token".into(), + ) + }) } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct DeviceRecord { - pub(crate) id: String, - pub(crate) hostname: String, - pub(crate) addresses: BTreeSet, +pub struct DeviceRecord { + pub id: String, + pub hostname: String, + pub addresses: BTreeSet, } -pub(crate) struct AuthKey { - pub(crate) key: String, +pub struct AuthKey { + pub key: String, } -pub(crate) fn create_auth_key( +pub fn create_auth_key( credentials: &OAuthCredentials, target_tag: &str, description: &str, ) -> Result { if !matches!(target_tag, SERVER_TAG | SMOKE_TAG) { - return Err(anyhow!("unsupported Tailscale device tag")); + return Err(TailscaleError::Invalid( + "unsupported Tailscale device tag".into(), + )); } let token = credentials.access_token("auth_keys")?; let payload = json!({ @@ -173,19 +232,23 @@ pub(crate) fn create_auth_key( .get("key") .and_then(Value::as_str) .filter(|key| !key.is_empty()) - .ok_or_else(|| anyhow!("Tailscale auth key response did not contain a key"))? + .ok_or_else(|| { + TailscaleError::Invalid("Tailscale auth key response did not contain a key".into()) + })? .to_owned(); Ok(AuthKey { key }) } -pub(crate) fn find_device( +pub fn find_device( credentials: &OAuthCredentials, expected_hostname: &str, expected_tag: &str, addresses: &BTreeSet, ) -> Result { if !matches!(expected_tag, SERVER_TAG | SMOKE_TAG) || addresses.is_empty() { - return Err(anyhow!("invalid Tailscale device lookup constraints")); + return Err(TailscaleError::Invalid( + "invalid Tailscale device lookup constraints".into(), + )); } let token = credentials.access_token("devices:core")?; let response = credentials.api.request( @@ -197,7 +260,9 @@ pub(crate) fn find_device( let devices = response .get("devices") .and_then(Value::as_array) - .ok_or_else(|| anyhow!("Tailscale device list response is malformed"))?; + .ok_or_else(|| { + TailscaleError::Invalid("Tailscale device list response is malformed".into()) + })?; let matches = devices .iter() .filter_map(|device| device_record(device, expected_tag)) @@ -211,22 +276,24 @@ pub(crate) fn find_device( .collect::>(); match matches.as_slice() { [device] => Ok(device.clone()), - [] => Err(anyhow!( - "Tailscale has not registered the expected device yet" + [] => Err(TailscaleError::Invalid( + "Tailscale has not registered the expected device yet".into(), )), - _ => Err(anyhow!( - "multiple Tailscale devices match this VM; refusing ambiguity" + _ => Err(TailscaleError::Invalid( + "multiple Tailscale devices match this VM; refusing ambiguity".into(), )), } } -pub(crate) fn find_device_by_hostname( +pub fn find_device_by_hostname( credentials: &OAuthCredentials, expected_hostname: &str, expected_tag: &str, ) -> Result { if !matches!(expected_tag, SERVER_TAG | SMOKE_TAG) || expected_hostname.is_empty() { - return Err(anyhow!("invalid Tailscale device lookup constraints")); + return Err(TailscaleError::Invalid( + "invalid Tailscale device lookup constraints".into(), + )); } let token = credentials.access_token("devices:core")?; let response = credentials.api.request( @@ -238,7 +305,9 @@ pub(crate) fn find_device_by_hostname( let devices = response .get("devices") .and_then(Value::as_array) - .ok_or_else(|| anyhow!("Tailscale device list response is malformed"))?; + .ok_or_else(|| { + TailscaleError::Invalid("Tailscale device list response is malformed".into()) + })?; let matches = devices .iter() .filter_map(|device| device_record(device, expected_tag)) @@ -246,24 +315,26 @@ pub(crate) fn find_device_by_hostname( .collect::>(); match matches.as_slice() { [device] if !device.addresses.is_empty() => Ok(device.clone()), - [] => Err(anyhow!( + [] => Err(TailscaleError::Invalid(format!( "Tailscale has no device named {expected_hostname} with tag {expected_tag}" - )), - [_] => Err(anyhow!("Tailscale device has no registered addresses")), - _ => Err(anyhow!( + ))), + [_] => Err(TailscaleError::Invalid("Tailscale device has no registered addresses".into())), + _ => Err(TailscaleError::Invalid(format!( "multiple tagged Tailscale devices have hostname {expected_hostname}; refusing ambiguity" - )), + ))), } } -pub(crate) fn remove_device_for_hostname( +pub fn remove_device_for_hostname( credentials: &OAuthCredentials, hostname: &str, expected_tag: &str, expected: Option<&DeviceRecord>, ) -> Result> { if !matches!(expected_tag, SERVER_TAG | SMOKE_TAG) || hostname.is_empty() { - return Err(anyhow!("invalid Tailscale device removal constraints")); + return Err(TailscaleError::Invalid( + "invalid Tailscale device removal constraints".into(), + )); } let token = credentials.access_token("devices:core")?; let response = credentials.api.request( @@ -275,7 +346,9 @@ pub(crate) fn remove_device_for_hostname( let devices = response .get("devices") .and_then(Value::as_array) - .ok_or_else(|| anyhow!("Tailscale device list response is malformed"))?; + .ok_or_else(|| { + TailscaleError::Invalid("Tailscale device list response is malformed".into()) + })?; let matches = devices .iter() .filter_map(|device| device_record(device, expected_tag)) @@ -285,9 +358,9 @@ pub(crate) fn remove_device_for_hostname( [] => return Ok(None), [actual] => actual, _ => { - return Err(anyhow!( + return Err(TailscaleError::Invalid(format!( "multiple tagged Tailscale devices have hostname {hostname}; refusing removal" - )) + ))) } }; if let Some(expected) = expected { @@ -297,8 +370,8 @@ pub(crate) fn remove_device_for_hostname( .iter() .any(|address| expected.addresses.contains(address)) { - return Err(anyhow!( - "recorded Tailscale ID no longer matches this VM; refusing removal" + return Err(TailscaleError::Invalid( + "recorded Tailscale ID no longer matches this VM; refusing removal".into(), )); } } @@ -350,11 +423,13 @@ fn path_segment(value: &str) -> Result { .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) { - return Err(anyhow!("invalid Tailscale device ID")); + return Err(TailscaleError::Invalid( + "invalid Tailscale device ID".into(), + )); } Ok(value.to_owned()) } #[cfg(test)] -#[path = "tailscale_tests.rs"] +#[path = "tests.rs"] mod tests; diff --git a/ublue/skillet/crates/cli/src/tailscale_tests.rs b/ublue/skillet/crates/workstation/src/tailscale/tests.rs similarity index 100% rename from ublue/skillet/crates/cli/src/tailscale_tests.rs rename to ublue/skillet/crates/workstation/src/tailscale/tests.rs From badc341d3c68eeb0fcdc8b0bac824307b19c0fe9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 01:26:59 +0200 Subject: [PATCH 151/252] refactor(workstation): model provisioning policy --- ublue/butane/ACCEPTANCE.md | 11 +++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 5 ++ ublue/skillet/crates/cli/src/main.rs | 11 ++- .../skillet/crates/cli/src/secret_delivery.rs | 62 ++++++++++------- ublue/skillet/crates/workstation/src/lib.rs | 1 + .../workstation/src/provisioning_policy.rs | 69 +++++++++++++++++++ .../src/provisioning_policy/tests.rs | 44 ++++++++++++ 7 files changed, 173 insertions(+), 30 deletions(-) create mode 100644 ublue/skillet/crates/workstation/src/provisioning_policy.rs create mode 100644 ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index d6ee4d68..9a206c4e 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -996,3 +996,14 @@ host tool; it still uses normal command approvals. caused no service restart, and removed its disposable container. - Live Tailscale API/VM acceptance was not run. Lifecycle ownership and production-versus-test orchestration remain pending in workstream 4. + +### Explicit workstation environment policy, 2026-10-04 + +- Added typed production/test provisioning policy for UI domain lookup, + shared Cloudflare zone lookup, live versus staging ACME, Cloudflare token + lifetimes, cleanup-token lifetime, and production/disposable Tailscale tags. + Production UI delivery and disposable VM issue/cleanup now use that policy. +- The two policy unit tests and full offline workspace tests passed. Strict + workspace all-target Clippy passed. The CI container integration exited 0: + both applies succeeded, the second caused no service restart, and the + disposable container was removed. No provider or VM resources were changed. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 0b5e93be..6ef4b302 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -105,6 +105,11 @@ and VM delivery have duplicate implementations with different validation. tagged-device lookup/removal, and local protocol tests now live in `skillet_workstation::tailscale`; the CLI no longer depends directly on `reqwest` for this provider. +- Implemented: `skillet_workstation::provisioning_policy` captures production + versus test environment names and vault paths, ACME staging, Cloudflare + credential lifetimes, cleanup-token lifetime, and Tailscale device tags. + Delivery and cleanup consume this policy; the Cloudflare zone ID uses the + shared `skillet/environments/dns/cloudflare-zone-id` vault entry. - Pending: environment/host credential-delivery orchestration, durable versus disposable policy, provider/VM cleanup ownership persistence, and production plus named-VM acceptance remain in CLI modules. Finish the remaining sequence diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 0d8d91f6..f64c561a 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -87,16 +87,13 @@ enum UiEnvironmentName { } impl UiEnvironmentName { - fn as_str(self) -> &'static str { + fn policy(self) -> skillet_workstation::provisioning_policy::ProvisioningPolicy { + use skillet_workstation::provisioning_policy::{Environment, ProvisioningPolicy}; match self { - Self::Production => "production", - Self::Test => "test", + Self::Production => ProvisioningPolicy::new(Environment::Production), + Self::Test => ProvisioningPolicy::new(Environment::Test), } } - - fn acme_staging(self) -> bool { - matches!(self, Self::Test) - } } #[derive(clap::Subcommand, Debug)] diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 49541652..8a444168 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -60,9 +60,12 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { } "tailscale" => { let credentials = tailscale_credentials(&vault)?; + let policy = UiEnvironmentName::Production.policy(); let auth_key = tailscale::create_auth_key( &credentials, - tailscale::SERVER_TAG, + policy.tailscale_tag( + skillet_workstation::provisioning_policy::DeviceClass::ProductionHost, + ), &format!("Skillet {0} production host", args.hostname), )?; let transport = credential_transport(args)?; @@ -102,12 +105,13 @@ fn deliver_caddy_from_vault( ui_config: Option<&skillet_hosts::HostUiConfig>, ) -> Result<()> { let ui_config = ui_config.ok_or_else(|| anyhow!("missing host UI declaration"))?; - let environment = args.environment.as_str(); - let domain_path = format!("skillet/environments/{environment}/dns/ui-domain"); + let policy = args.environment.policy(); + let environment = policy.name(); + let domain_path = policy.ui_domain_entry(); let domain_prefix = vault.get(&domain_path)?; - let zone_path = format!("skillet/environments/{environment}/dns/cloudflare-zone-id"); + let zone_path = policy.cloudflare_zone_entry(); let zone_id = vault - .get(&zone_path)? + .get(zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; let zone_id = zone_id.trim(); skillet_workstation::cloudflare::validate_zone_id(zone_id)?; @@ -126,13 +130,16 @@ fn deliver_caddy_from_vault( &args.hostname, &skillet_caddy::UiEnvironment { ui_domain: domain.clone(), - acme_staging: args.environment.acme_staging(), + acme_staging: policy.acme_staging(), }, &ui_config.services, )?; let tailnet = tailscale_credentials(vault)?; - let device = - tailscale::find_device_by_hostname(&tailnet, &args.hostname, tailscale::SERVER_TAG)?; + let device = tailscale::find_device_by_hostname( + &tailnet, + &args.hostname, + policy.tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::ProductionHost), + )?; let dns_marker = format!("skillet:{environment}:{}", args.hostname); let dns = skillet_workstation::cloudflare::desired_records( &sites.machine_hostname, @@ -178,7 +185,7 @@ fn host_acme_token( zone_id: &str, account_id: &str, ) -> Result { - let environment = args.environment.as_str(); + let environment = args.environment.policy().name(); let token_path = format!( "skillet/environments/{environment}/hosts/{}/cloudflare/acme-token", args.hostname @@ -355,13 +362,15 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let mut vault = Vault::open(&vault_path, args.key_file.as_deref())?; let credentials = tailscale_credentials(&vault)?; let expected_hostname = name.as_str(); + let policy = UiEnvironmentName::Test.policy(); write_pending_tailscale(&run_dir, expected_hostname)?; let mut addresses = vm_tailscale_addresses(&mut ssh).unwrap_or_default(); if addresses.is_empty() { let auth_key = tailscale::create_auth_key( &credentials, - tailscale::SMOKE_TAG, + policy + .tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::DisposableVm), &format!("Skillet disposable VM {expected_hostname}"), )?; ssh.install("tailscale_auth_key", &auth_key.key)?; @@ -380,7 +389,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let record = tailscale::find_device( &credentials, expected_hostname, - tailscale::SMOKE_TAG, + policy.tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::DisposableVm), &addresses, )?; save_vm_tailscale_record(&run_dir, &record)?; @@ -419,10 +428,11 @@ pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { }; let vault = Vault::open(&vault_path, args.key_file.as_deref())?; let credentials = tailscale_credentials(&vault)?; + let policy = UiEnvironmentName::Test.policy(); if tailscale::remove_device_for_hostname( &credentials, &name, - tailscale::SMOKE_TAG, + policy.tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::DisposableVm), expected.as_ref(), )? .is_some() @@ -455,12 +465,13 @@ fn provision_vm_ui( vault: &mut Vault, device: &tailscale::DeviceRecord, ) -> Result<()> { - let environment = UiEnvironmentName::Test.as_str(); - let domain_path = format!("skillet/environments/{environment}/dns/ui-domain"); - let zone_path = format!("skillet/environments/{environment}/dns/cloudflare-zone-id"); + let policy = UiEnvironmentName::Test.policy(); + let environment = policy.name(); + let domain_path = policy.ui_domain_entry(); + let zone_path = policy.cloudflare_zone_entry(); let domain_prefix = vault.get(&domain_path)?; let zone_id = vault - .get(&zone_path)? + .get(zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; let creator = vault .get("skillet/cloudflare/token-creator")? @@ -481,7 +492,7 @@ fn provision_vm_ui( &args.hostname, &skillet_caddy::UiEnvironment { ui_domain: ui_domain.clone(), - acme_staging: true, + acme_staging: policy.acme_staging(), }, &host_ui.services, )?; @@ -511,7 +522,7 @@ fn provision_vm_ui( &ownership.zone_id, &account_id, &ownership.token_name, - Some(std::time::Duration::from_hours(12)), + policy.cloudflare_token_lifetime(), )?; ownership.token_id = Some(issued.id.clone()); ownership.expires_on.clone_from(&issued.expires_on); @@ -584,7 +595,8 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( let bytes = fs::read(metadata_path).context("reading Cloudflare VM ownership metadata")?; let ownership: CloudflareVmOwnership = serde_json::from_slice(&bytes).context("decoding Cloudflare VM ownership metadata")?; - if ownership.environment != UiEnvironmentName::Test.as_str() { + let policy = UiEnvironmentName::Test.policy(); + if ownership.environment != policy.name() { return Err(anyhow!( "refusing VM cleanup for a non-test Cloudflare environment" )); @@ -611,9 +623,13 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" ) })?; - let configured_zone = vault.get("skillet/environments/test/dns/cloudflare-zone-id")? - .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: skillet/environments/test/dns/cloudflare-zone-id"))?; - let configured_prefix = vault.get("skillet/environments/test/dns/ui-domain")?; + let configured_zone = vault.get(policy.cloudflare_zone_entry())?.ok_or_else(|| { + anyhow!( + "KeePassXC Cloudflare zone entry is missing: {}", + policy.cloudflare_zone_entry() + ) + })?; + let configured_prefix = vault.get(&policy.ui_domain_entry())?; if configured_zone.trim() != ownership.zone_id { return Err(anyhow!("test Cloudflare configuration differs from the recorded VM owner; restore the original vault values before cleanup")); } @@ -634,7 +650,7 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( &ownership.zone_id, &account_id, &cleanup_name, - Some(std::time::Duration::from_mins(15)), + Some(policy.cleanup_token_lifetime()), )?; let cleanup = (|| { api.zone(&cleanup_token.value, &ownership.zone_id)?; diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index f922c91e..e199ebe9 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -1,5 +1,6 @@ //! Workstation-only provisioning capabilities. pub mod cloudflare; +pub mod provisioning_policy; pub mod tailscale; pub mod vault; diff --git a/ublue/skillet/crates/workstation/src/provisioning_policy.rs b/ublue/skillet/crates/workstation/src/provisioning_policy.rs new file mode 100644 index 00000000..eb228f62 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/provisioning_policy.rs @@ -0,0 +1,69 @@ +use std::time::Duration; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Environment { + Production, + Test, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DeviceClass { + ProductionHost, + DisposableVm, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProvisioningPolicy { + environment: Environment, +} + +impl ProvisioningPolicy { + pub const fn new(environment: Environment) -> Self { + Self { environment } + } + + pub const fn environment(self) -> Environment { + self.environment + } + + pub const fn name(self) -> &'static str { + match self.environment { + Environment::Production => "production", + Environment::Test => "test", + } + } + + pub fn ui_domain_entry(self) -> String { + format!("skillet/environments/{}/dns/ui-domain", self.name()) + } + + pub fn cloudflare_zone_entry(self) -> &'static str { + "skillet/environments/dns/cloudflare-zone-id" + } + + pub const fn acme_staging(self) -> bool { + matches!(self.environment, Environment::Test) + } + + pub const fn cloudflare_token_lifetime(self) -> Option { + match self.environment { + Environment::Production => None, + Environment::Test => Some(Duration::from_hours(12)), + } + } + + pub const fn cleanup_token_lifetime(self) -> Duration { + Duration::from_mins(15) + } + + pub const fn tailscale_tag(self, device: DeviceClass) -> &'static str { + match device { + DeviceClass::ProductionHost => crate::tailscale::SERVER_TAG, + DeviceClass::DisposableVm => crate::tailscale::SMOKE_TAG, + } + } +} + +#[cfg(test)] +#[path = "provisioning_policy/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs b/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs new file mode 100644 index 00000000..f69580d8 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs @@ -0,0 +1,44 @@ +use super::{DeviceClass, Environment, ProvisioningPolicy}; +use std::time::Duration; + +#[test] +fn production_policy_selects_durable_credentials_and_live_acme() { + let policy = ProvisioningPolicy::new(Environment::Production); + + assert_eq!(policy.name(), "production"); + assert_eq!( + policy.ui_domain_entry(), + "skillet/environments/production/dns/ui-domain" + ); + assert_eq!( + policy.cloudflare_zone_entry(), + "skillet/environments/dns/cloudflare-zone-id" + ); + assert_eq!(policy.cloudflare_token_lifetime(), None); + assert!(!policy.acme_staging()); + assert_eq!( + policy.tailscale_tag(DeviceClass::ProductionHost), + "tag:skillet-server" + ); +} + +#[test] +fn disposable_policy_selects_short_lived_credentials_and_staging_acme() { + let policy = ProvisioningPolicy::new(Environment::Test); + + assert_eq!(policy.name(), "test"); + assert_eq!( + policy.ui_domain_entry(), + "skillet/environments/test/dns/ui-domain" + ); + assert_eq!( + policy.cloudflare_token_lifetime(), + Some(Duration::from_hours(12)) + ); + assert_eq!(policy.cleanup_token_lifetime(), Duration::from_mins(15)); + assert!(policy.acme_staging()); + assert_eq!( + policy.tailscale_tag(DeviceClass::DisposableVm), + "tag:skillet-smoke" + ); +} From 4da75718020cd5d3c12322ef94555af5221630a4 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 01:31:22 +0200 Subject: [PATCH 152/252] refactor(podman): move DNS policy to host baseline --- ublue/butane/ACCEPTANCE.md | 14 ++++++++ ublue/design/pihole-network.md | 11 +++--- ublue/plan/REFACTOR-05-PODMAN-MODEL.md | 13 ++++++- ublue/plan/SKILLET-REFACTOR.md | 2 +- .../crates/host-profiles/src/hosts_tests.rs | 2 ++ ublue/skillet/crates/host-profiles/src/lib.rs | 3 ++ .../crates/host-profiles/src/profile.rs | 6 ++++ ublue/skillet/crates/podman/src/lib.rs | 34 ++++++++++--------- ublue/skillet/crates/podman/src/tests.rs | 20 ++++++++--- 9 files changed, 77 insertions(+), 28 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 9a206c4e..7a19723a 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1007,3 +1007,17 @@ host tool; it still uses normal command approvals. workspace all-target Clippy passed. The CI container integration exited 0: both applies succeeded, the second caused no service restart, and the disposable container was removed. No provider or VM resources were changed. + +### Podman host-wide DNS policy, 2026-10-04 + +- Moved Aardvark's host-wide DNS listener config out of each container apply. + The canonical profile selects the policy when Pi-hole is declared; host + composition writes it before service recipes. Container apply now only owns + its Quadlet and declared network. +- Podman and host-profile tests passed, including idempotent listener config + and profile selection. Full offline workspace tests, strict all-target + Clippy, formatting, and the CI container integration passed. The CI + beezelbot base fixture applied twice without restart and its container was + removed. +- Live Pi-hole port 53 and service-name DNS behavior was not verified; no + `/dev/kvm` is available on this workstation. diff --git a/ublue/design/pihole-network.md b/ublue/design/pihole-network.md index e84a8581..ed52e1b4 100644 --- a/ublue/design/pihole-network.md +++ b/ublue/design/pihole-network.md @@ -21,11 +21,12 @@ mechanism and LAN client resolver settings remain to be decided before those hosts are deployed. Pi-hole's host port 53 conflicts with Netavark's default Aardvark DNS listener. -Skillet moves Aardvark to port 54 through Podman's rootful `dns_bind_port`, -preserving service-name DNS while Pi-hole owns host port 53. This is global -Podman configuration and must be verified on the target uCore release. Keep the -bridge non-internal; alternate Aardvark ports have a known limitation on -internal networks. +When a host profile declares Pi-hole, host composition writes Podman's rootful +`dns_bind_port=54` policy once before applying its services. Container recipes +only declare their network attachments; they do not own this global setting. +This must be verified on the target uCore release. Keep the bridge +non-internal; alternate Aardvark ports have a known limitation on internal +networks. Podman does not update an existing network's settings from a changed Quadlet. Skillet records the accepted network definition and refuses drift. Changing diff --git a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md index efd37a7c..58e6a6e9 100644 --- a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md +++ b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md @@ -1,6 +1,6 @@ # 5. Podman configuration with explicit host policy -Status: planned; follows workstreams 2–4 in +Status: in progress; follows workstreams 2–4 in [the prerequisite roadmap](SKILLET-REFACTOR.md). Further feature milestones wait for that roadmap's completion. @@ -16,6 +16,17 @@ Current defects: runtime user/network settings can come from both typed fields and raw directives; unused user fields misdescribe runtime identity. Per-container apply also owns a global DNS-port policy and converges shared networks repeatedly. +## Progress + +- Implemented: moved the host-wide Aardvark DNS listener setting out of generic + container apply. The canonical host profile selects it when Pi-hole is + declared, and host composition writes it once before service recipes. The + focused Podman helper is idempotent; standalone container apply no longer + changes global Podman DNS policy. Named-VM validation remains pending. +- Pending: typed process identity/mapping/volume ownership, typed container + network attachments and publications, shared network baseline ownership, + storage dependencies, and full service migration. + ## Implementation sequence 1. Inventory actual resource usage before changing the model. Define explicit diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index cda1a277..61d52f47 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -24,7 +24,7 @@ assignments to execute concurrently against the same interfaces. | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; live acceptance pending | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress | -| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Planned | +| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | In progress | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Planned | | 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Planned | diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index f7d3284c..7abcb6ec 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -80,6 +80,7 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s assert_eq!(clamps.signed_image, Some("ghcr.io/gbagnoli/ucore-clamps")); assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); assert!(clamps.requires_data_mount); + assert!(clamps.requires_pihole_dns_listener_policy()); let network = clamps.service_network(); assert!(network.options.contains(&"IPv6=true".to_string())); assert!(network @@ -100,6 +101,7 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s ); assert!(beezelbot.masked_units.is_empty()); assert!(beezelbot.requires_data_mount); + assert!(!beezelbot.requires_pihole_dns_listener_policy()); assert_eq!( beezelbot.credential_consumers(), [ diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index 96dfde2d..6a330e70 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -233,6 +233,9 @@ fn apply_profile( "/data", )?; } + if profile.requires_pihole_dns_listener_policy() { + skillet_podman::ensure_dns_listener_port(files, 54)?; + } for service in &profile.services { match &service.config { ServiceConfig::Pihole { custom_dns } => { diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index 2240a69e..8b1dedbc 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -115,6 +115,12 @@ impl HostProfile { self.services.iter().any(|service| service.name() == name) } + pub fn requires_pihole_dns_listener_policy(&self) -> bool { + self.services + .iter() + .any(|service| matches!(service.config, ServiceConfig::Pihole { .. })) + } + pub fn requires_full_apply_credentials(&self) -> bool { self.services.iter().any(|service| { matches!( diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 0796e285..a2b484c4 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -125,6 +125,24 @@ pub struct PodmanNetwork { pub options: Vec, } +/// Configure Aardvark's host-wide listener before applying services that +/// publish DNS on the host. This policy belongs to host composition, not to +/// each container that happens to use a bridge network. +pub fn ensure_dns_listener_port( + files: &F, + port: u16, +) -> Result { + let config_dir = Path::new("/etc/containers/containers.conf.d"); + files.ensure_directory(config_dir, Some(0o755), Some("root"), Some("root"))?; + Ok(files.ensure_file( + &config_dir.join("90-skillet-aardvark.conf"), + format!("[network]\ndns_bind_port={port}\n").as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?) +} + #[allow(clippy::similar_names)] pub fn container(system: &S, files: &F, config: PodmanConfig) -> Result where @@ -138,22 +156,6 @@ where let config_revisions = config.config_revisions; let mut network_states = Vec::new(); - // Pi-hole publishes port 53 on the host. Keep Netavark's bridge DNS - // listener off that port while retaining its container-name DNS service. - // This is a global Podman setting, so only manage it when using a - // DNS-enabled user-defined network. - if !config.networks.is_empty() { - let config_dir = Path::new("/etc/containers/containers.conf.d"); - files.ensure_directory(config_dir, Some(0o755), Some("root"), Some("root"))?; - files.ensure_file( - &config_dir.join("90-skillet-aardvark.conf"), - b"[network]\ndns_bind_port=54\n", - Some(0o644), - Some("root"), - Some("root"), - )?; - } - // A container's reference to the network Quadlet creates the systemd // dependency that starts the network before the container. for network in &config.networks { diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index e98d4385..52ffb38c 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -46,7 +46,7 @@ fn clamps_network() -> PodmanNetwork { } #[test] -fn shared_network_quadlet_is_written_and_attached_idempotently() { +fn container_apply_owns_network_quadlet_but_not_host_dns_policy() { let system = MockSystem::new(); let files = MockFiles::new(); system.ensure_podman_secret("dummy", "first").unwrap(); @@ -56,10 +56,9 @@ fn shared_network_quadlet_is_written_and_attached_idempotently() { container(&system, &files, config).unwrap(); let managed_files = files.files.lock().unwrap(); - let dns_config = managed_files - .get("/etc/containers/containers.conf.d/90-skillet-aardvark.conf") - .unwrap(); - assert_eq!(dns_config, b"[network]\ndns_bind_port=54\n"); + assert!( + !managed_files.contains_key("/etc/containers/containers.conf.d/90-skillet-aardvark.conf") + ); let network = managed_files .get("/etc/containers/systemd/clamps.network") .unwrap(); @@ -81,6 +80,17 @@ fn shared_network_quadlet_is_written_and_attached_idempotently() { assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); } +#[test] +fn host_dns_listener_policy_is_independently_idempotent() { + let files = MockFiles::new(); + assert!(ensure_dns_listener_port(&files, 54).unwrap()); + assert!(!ensure_dns_listener_port(&files, 54).unwrap()); + assert_eq!( + files.files.lock().unwrap()["/etc/containers/containers.conf.d/90-skillet-aardvark.conf"], + b"[network]\ndns_bind_port=54\n" + ); +} + #[test] fn changing_a_created_network_fails_before_replacing_its_quadlet() { let system = MockSystem::new(); From bb94318bd629acd00781088d8a93497da1c38bc5 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 01:40:36 +0200 Subject: [PATCH 153/252] refactor(podman): model process identity explicitly --- ublue/butane/ACCEPTANCE.md | 15 ++ ublue/design/podman-runtime.md | 12 + ublue/design/skillet-architecture.md | 2 + ublue/plan/REFACTOR-05-PODMAN-MODEL.md | 9 +- ublue/skillet/crates/caddy/src/lib.rs | 10 +- ublue/skillet/crates/cli/src/test_fixture.rs | 10 +- ublue/skillet/crates/host-profiles/src/lib.rs | 10 +- ublue/skillet/crates/pihole/src/lib.rs | 12 +- ublue/skillet/crates/podman/src/lib.rs | 218 +++++++++++++----- ublue/skillet/crates/podman/src/tests.rs | 74 +++++- ublue/skillet/crates/syncthing/src/lib.rs | 10 +- ublue/skillet/crates/unifi/src/lib.rs | 12 +- 12 files changed, 281 insertions(+), 113 deletions(-) create mode 100644 ublue/design/podman-runtime.md diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 7a19723a..74f461ff 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1021,3 +1021,18 @@ host tool; it still uses normal command approvals. removed. - Live Pi-hole port 53 and service-name DNS behavior was not verified; no `/dev/kvm` is available on this workstation. + +### Explicit Podman process identity, 2026-10-04 + +- Replaced the ambiguous container UID/GID + host-user fields with explicit + image-default, named, and numeric identities. Namespace mapping is a separate + opt-in input requiring an existing host account and subordinate ranges; the + generic adapter no longer creates accounts. UniFi's named process user now + comes from the typed identity field. +- Podman and UniFi tests passed for image-default, named, numeric, mapped + identity, map-range validation, and rejection of raw typed-field conflicts + before effects. Full offline workspace tests, strict all-target Clippy, + formatting, and the CI container integration passed. The container applied + beezelbot base twice with no restart on the second apply, then was removed. +- No live container identity or ownership checks ran; `/dev/kvm` is unavailable + on this workstation. diff --git a/ublue/design/podman-runtime.md b/ublue/design/podman-runtime.md new file mode 100644 index 00000000..643f68a6 --- /dev/null +++ b/ublue/design/podman-runtime.md @@ -0,0 +1,12 @@ +# Podman runtime configuration + +Container process identity, host volume ownership, and user namespace mapping +are separate inputs. Recipes use the image's declared user explicitly when +appropriate, or declare a named/numeric process identity. Host ownership of +data directories is configured by the owning application. A namespace mapping +is opt-in, maps an existing host account through its subordinate-ID ranges, +and never creates host accounts as a side effect. + +Host-wide Podman settings are selected and applied once by host composition. +Individual container recipes declare their own service configuration and +network attachments; they do not change global daemon policy. diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 89c6a06e..f7af942f 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -41,6 +41,8 @@ provisioning, and synthetic test fixtures. Effects need injectable capability boundaries covering both observations and mutations. Pure configuration and composition use ordinary data and functions. This makes host tests independent of the workstation and keeps one declaration authoritative across entry points. +Podman runtime identity, host data ownership, and namespace mappings remain +separate configuration concepts; see [Podman runtime configuration](podman-runtime.md). Move VM lifecycle orchestration from Bash into Rust. One orchestrator owns the typed manifest, identity validation, readiness, recovery, and destruction, diff --git a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md index 58e6a6e9..0dcba26f 100644 --- a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md +++ b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md @@ -23,8 +23,13 @@ apply also owns a global DNS-port policy and converges shared networks repeatedl declared, and host composition writes it once before service recipes. The focused Podman helper is idempotent; standalone container apply no longer changes global Podman DNS policy. Named-VM validation remains pending. -- Pending: typed process identity/mapping/volume ownership, typed container - network attachments and publications, shared network baseline ownership, +- Implemented: replaced the ambiguous container UID/GID plus host-user fields + with image-default, named, and numeric process identities, and a separate + optional namespace mapping. Removed unused implicit host-account creation; + UniFi now declares its image's named process user as typed data. Invalid + names and raw `User`/`UIDMap`/`GIDMap` conflicts fail before file effects. +- Pending: typed host volume ownership, typed container network attachments and + publications, shared network baseline ownership, storage dependencies, and full service migration. ## Implementation sequence diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index ae94b091..85d97b3a 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -6,7 +6,7 @@ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; use skillet_podman::{ - self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, QuadletSecret, SecretTarget, + self, PodmanConfig, PodmanError, PodmanNetwork, ProcessIdentity, QuadletSecret, SecretTarget, Volume, }; use std::{collections::BTreeMap, path::Path}; @@ -315,12 +315,8 @@ where name: "caddy".to_string(), image: "ghcr.io/caddybuilds/caddy-cloudflare:2".to_string(), networks: vec![network], - user: ContainerUser { - container_uid: 0, - container_gid: 0, - host_user: None, - }, - create_host_user: false, + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, volumes: vec![ Volume { host_path: "/etc/skillet/caddy".to_string(), diff --git a/ublue/skillet/crates/cli/src/test_fixture.rs b/ublue/skillet/crates/cli/src/test_fixture.rs index 24cb6947..c447b03b 100644 --- a/ublue/skillet/crates/cli/src/test_fixture.rs +++ b/ublue/skillet/crates/cli/src/test_fixture.rs @@ -5,7 +5,7 @@ use skillet_core::{ system::{SystemError, SystemResource}, }; use skillet_podman::{ - ContainerUser, PodmanConfig, PodmanError, QuadletSecret, SecretTarget, Volume, + PodmanConfig, PodmanError, ProcessIdentity, QuadletSecret, SecretTarget, Volume, }; use std::{collections::BTreeMap, path::Path}; use thiserror::Error; @@ -95,12 +95,8 @@ pub(super) fn apply( name: "skillet-smoke-fixture".to_string(), image: "docker.io/library/alpine:3.20".to_string(), networks: Vec::new(), - user: ContainerUser { - container_uid: 0, - container_gid: 0, - host_user: None, - }, - create_host_user: false, + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, volumes: vec![ Volume { host_path: "/etc/skillet-smoke".to_string(), diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index 6a330e70..64eada82 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -6,7 +6,7 @@ use skillet_core::{ system::{SystemError, SystemResource}, }; use skillet_podman::{ - ContainerUser, PodmanConfig, PodmanNetwork, QuadletSecret, SecretTarget, Volume, + PodmanConfig, PodmanNetwork, ProcessIdentity, QuadletSecret, SecretTarget, Volume, }; use std::collections::BTreeMap; use thiserror::Error; @@ -150,12 +150,8 @@ fn tailscale_config(hostname: &str, auth_key: String, state_path: &str) -> Podma name: "tailscale".to_string(), image: "docker.io/tailscale/tailscale:stable".to_string(), networks: Vec::new(), - user: ContainerUser { - container_uid: 0, - container_gid: 0, - host_user: None, - }, - create_host_user: false, + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, volumes: vec![Volume { host_path: state_path.to_string(), container_path: "/var/lib/tailscale".to_string(), diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 9c189b49..7252917a 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -4,7 +4,7 @@ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; use skillet_podman::{ - self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, QuadletSecret, Volume, + self, PodmanConfig, PodmanError, PodmanNetwork, ProcessIdentity, QuadletSecret, Volume, }; use std::collections::BTreeMap; use std::path::Path; @@ -85,12 +85,6 @@ where )?; // 4. Define container - let user = ContainerUser { - container_uid: 0, - container_gid: 0, - host_user: None, - }; - // SELinux relabeling (:z, shared) so the container can access these // host paths on enforcing systems such as uCore. let volumes = vec![ @@ -152,8 +146,8 @@ where name: "pihole".to_string(), image: "docker.io/pihole/pihole:latest".to_string(), networks: vec![network], - user, - create_host_user: false, + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, volumes, secrets, config_revisions: vec![custom_list.into_bytes()], diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index a2b484c4..98f6731e 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -2,7 +2,7 @@ use askama::Template; use sha2::{Digest, Sha256}; use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; use skillet_core::system::{ - AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, + AccountLookupResource, PodmanSecretResource, ServiceResource, SystemError, }; use std::collections::BTreeMap; use std::fmt::Write as _; @@ -20,6 +20,8 @@ pub enum PodmanError { UserMapping(String), #[error("Invalid Podman network unit name: {0}")] InvalidNetworkName(String), + #[error("Podman directive {0} conflicts with the typed container identity")] + ConflictingIdentityDirective(&'static str), #[error("Network configuration changed for {0}; stop its consumers, remove the Podman network and its applied marker, then apply again")] NetworkConfigChanged(String), #[error("No valid {kind} subordinate-ID range for account {account}")] @@ -38,10 +40,17 @@ struct QuadletTemplate { sections: BTreeMap>, } -pub struct ContainerUser { - pub container_uid: u32, - pub container_gid: u32, - pub host_user: Option, +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProcessIdentity { + ImageDefault, + Numeric { uid: u32, gid: u32 }, + Named { user: String, group: Option }, +} + +/// Explicitly map one existing host account to the configured numeric +/// container identity through the user's subordinate-ID ranges. +pub struct UserNamespaceMapping { + pub host_user: HostUser, } pub enum HostUser { @@ -107,8 +116,8 @@ pub struct PodmanConfig { pub name: String, pub image: String, pub networks: Vec, - pub user: ContainerUser, - pub create_host_user: bool, + pub process_identity: ProcessIdentity, + pub namespace_mapping: Option, pub volumes: Vec, pub secrets: Vec, /// Content consumed at container startup outside the Quadlet definition. @@ -146,9 +155,10 @@ pub fn ensure_dns_listener_port( #[allow(clippy::similar_names)] pub fn container(system: &S, files: &F, config: PodmanConfig) -> Result where - S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + S: AccountLookupResource + PodmanSecretResource + ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { + validate_process_identity(&config)?; let name = &config.name; info!("Ensuring podman container: {name}"); @@ -187,23 +197,13 @@ where .push(format!("Network={}.network", network.unit_name)); } - // 1. Resolve and ensure host user - let host_info = resolve_host_user(system, &config.user, config.create_host_user)?; - - // 2. Calculate mappings - if let Some((uid_host, gid_host, username)) = &host_info { - let sub_uid = discover_subid_range(files, "/etc/subuid", username, "UID")?; - let sub_gid = discover_subid_range(files, "/etc/subgid", username, "GID")?; - calculate_user_mappings( - &config.user, - *uid_host, - *gid_host, - username, - sub_uid, - sub_gid, - &mut extra_config, - )?; - } + add_process_identity( + system, + files, + &config.process_identity, + config.namespace_mapping.as_ref(), + &mut extra_config, + )?; // 3. Ensure volumes and secrets let container_section = extra_config.entry("Container".to_string()).or_default(); @@ -287,49 +287,143 @@ fn render_network(network: &PodmanNetwork) -> Result { Ok(content) } -fn resolve_host_user( +fn resolve_host_user( system: &S, - user: &ContainerUser, - create: bool, -) -> Result, PodmanError> { - if let Some(hu) = &user.host_user { - let (username, uid, gid) = match hu { - HostUser::Name(ref n) => { - if create { - system.ensure_user(n, None, None)?; - } - let u = system.user_by_name(n)?.ok_or_else(|| { - PodmanError::UserMapping(format!("User {n} not found on host")) - })?; - (n.clone(), u.uid, u.primary_gid) + mapping: &UserNamespaceMapping, +) -> Result<(u32, u32, String), PodmanError> { + let (username, uid, gid) = match &mapping.host_user { + HostUser::Name(n) => { + let u = system + .user_by_name(n)? + .ok_or_else(|| PodmanError::UserMapping(format!("User {n} not found on host")))?; + (n.clone(), u.uid, u.primary_gid) + } + HostUser::Uid(u) => { + let u_info = system + .user_by_uid(*u)? + .ok_or_else(|| PodmanError::UserMapping(format!("UID {u} not found on host")))?; + (u_info.name, *u, u_info.primary_gid) + } + }; + Ok((uid, gid, username)) +} + +fn validate_identity_component(value: &str) -> Result<(), PodmanError> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(PodmanError::UserMapping( + "invalid named container identity component".to_string(), + )); + } + Ok(()) +} + +#[allow(clippy::similar_names)] +fn add_process_identity( + system: &S, + files: &F, + identity: &ProcessIdentity, + mapping: Option<&UserNamespaceMapping>, + extra_config: &mut BTreeMap>, +) -> Result<(), PodmanError> +where + S: AccountLookupResource + ?Sized, + F: FileReadResource + ?Sized, +{ + let container = extra_config.entry("Container".to_string()).or_default(); + match identity { + ProcessIdentity::Numeric { uid, gid } if mapping.is_none() => { + container.push(format!("User={uid}:{gid}")); + } + ProcessIdentity::ImageDefault | ProcessIdentity::Numeric { .. } => {} + ProcessIdentity::Named { user, group } => { + if let Some(group) = group { + container.push(format!("User={user}:{group}")); + } else { + container.push(format!("User={user}")); } - HostUser::Uid(u) => { - let u_info = system.user_by_uid(*u)?.ok_or_else(|| { - PodmanError::UserMapping(format!("UID {u} not found on host")) - })?; - (u_info.name, *u, u_info.primary_gid) + } + } + let Some(mapping) = mapping else { + return Ok(()); + }; + let ProcessIdentity::Numeric { uid, gid } = identity else { + return Err(PodmanError::UserMapping( + "namespace mapping requires an explicit numeric container identity".to_string(), + )); + }; + let (host_uid, host_gid, username) = resolve_host_user(system, mapping)?; + let sub_uid = discover_subid_range(files, "/etc/subuid", &username, "UID")?; + let sub_gid = discover_subid_range(files, "/etc/subgid", &username, "GID")?; + calculate_user_mappings( + UserMappingInputs { + uid_container: *uid, + gid_container: *gid, + uid_host: host_uid, + gid_host: host_gid, + username: &username, + sub_uid, + sub_gid, + }, + extra_config, + ) +} + +fn validate_process_identity(config: &PodmanConfig) -> Result<(), PodmanError> { + for line in config.extra_config.get("Container").into_iter().flatten() { + for (key, prefix) in [ + ("User", "User="), + ("UIDMap", "UIDMap="), + ("GIDMap", "GIDMap="), + ] { + if line.starts_with(prefix) { + return Err(PodmanError::ConflictingIdentityDirective(key)); + } + } + } + match &config.process_identity { + ProcessIdentity::ImageDefault => { + if config.namespace_mapping.is_some() { + return Err(PodmanError::UserMapping( + "namespace mapping requires an explicit numeric container identity".to_string(), + )); + } + } + ProcessIdentity::Numeric { .. } => {} + ProcessIdentity::Named { user, group } => { + validate_identity_component(user)?; + if let Some(group) = group { + validate_identity_component(group)?; + } + if config.namespace_mapping.is_some() { + return Err(PodmanError::UserMapping( + "namespace mapping requires an explicit numeric container identity".to_string(), + )); } - }; - Ok(Some((uid, gid, username))) - } else { - Ok(None) + } } + Ok(()) } // uid/gid subid ranges are intentionally parallel; the one-letter // difference is the whole point #[allow(clippy::similar_names)] fn calculate_user_mappings( - user: &ContainerUser, - uid_host: u32, - gid_host: u32, - username: &str, - sub_uid: SubordinateRange, - sub_gid: SubordinateRange, + inputs: UserMappingInputs<'_>, extra_config: &mut BTreeMap>, ) -> Result<(), PodmanError> { - let uid_container = user.container_uid; - let gid_container = user.container_gid; + let UserMappingInputs { + uid_container, + gid_container, + uid_host, + gid_host, + username, + sub_uid, + sub_gid, + } = inputs; validate_subordinate_range(sub_uid, username, uid_container)?; validate_subordinate_range(sub_gid, username, gid_container)?; @@ -366,6 +460,18 @@ fn calculate_user_mappings( Ok(()) } +#[derive(Clone, Copy)] +#[allow(clippy::struct_field_names)] +struct UserMappingInputs<'a> { + uid_container: u32, + gid_container: u32, + uid_host: u32, + gid_host: u32, + username: &'a str, + sub_uid: SubordinateRange, + sub_gid: SubordinateRange, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct SubordinateRange { start: u32, diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index 52ffb38c..3c40ffc5 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -12,12 +12,8 @@ fn fixture() -> PodmanConfig { name: "unit-fixture".to_string(), image: "example.invalid/fixture:1".to_string(), networks: Vec::new(), - user: ContainerUser { - container_uid: 0, - container_gid: 0, - host_user: None, - }, - create_host_user: false, + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, volumes: Vec::new(), secrets: vec![QuadletSecret { secret_name: "dummy".to_string(), @@ -139,6 +135,63 @@ fn repeat_apply_and_stopped_service() { assert!(!container(&system, &files, fixture()).unwrap()); assert_eq!(system.start_count.load(Ordering::SeqCst), 1); assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(!quadlet.lines().any(|line| line.starts_with("User="))); +} + +#[test] +fn numeric_process_identity_does_not_implicitly_create_namespace_mapping() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config.process_identity = ProcessIdentity::Numeric { + uid: 1001, + gid: 1002, + }; + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("User=1001:1002")); + assert!(!quadlet.contains("UIDMap=")); + assert!(!quadlet.contains("GIDMap=")); + assert!(files.files.lock().unwrap().get("/etc/subuid").is_none()); +} + +#[test] +fn named_identity_is_typed_and_conflicting_raw_identity_fails_before_effects() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config.process_identity = ProcessIdentity::Named { + user: "service-user".to_string(), + group: Some("service-group".to_string()), + }; + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("User=service-user:service-group")); + + let empty_files = MockFiles::new(); + let mut conflicting = fixture(); + conflicting + .extra_config + .entry("Container".to_string()) + .or_default() + .push("User=other".to_string()); + assert!(matches!( + container(&system, &empty_files, conflicting), + Err(PodmanError::ConflictingIdentityDirective("User")) + )); + assert!(empty_files.files.lock().unwrap().is_empty()); } #[test] @@ -207,11 +260,10 @@ fn subordinate_ranges_are_read_from_injected_files_and_render_user_maps() { b"service:400000:65536\n".to_vec(), ); let mut config = fixture(); - config.user = ContainerUser { - container_uid: 999, - container_gid: 999, - host_user: Some(HostUser::Name("service".to_string())), - }; + config.process_identity = ProcessIdentity::Numeric { uid: 999, gid: 999 }; + config.namespace_mapping = Some(UserNamespaceMapping { + host_user: HostUser::Name("service".to_string()), + }); container(&system, &files, config).unwrap(); let quadlet = String::from_utf8( files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index 7f108070..1c9c15c7 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -2,7 +2,7 @@ use skillet_core::files::{FileError, FileMutationResource, FileReadResource, Sto use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; -use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, PodmanNetwork, Volume}; +use skillet_podman::{self, PodmanConfig, PodmanError, PodmanNetwork, ProcessIdentity, Volume}; use std::collections::BTreeMap; use std::path::Path; use thiserror::Error; @@ -88,12 +88,8 @@ where name: "syncthing".to_string(), image: "docker.io/syncthing/syncthing:latest".to_string(), networks: vec![config.network], - user: ContainerUser { - container_uid: 0, - container_gid: 0, - host_user: None, - }, - create_host_user: false, + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, volumes, secrets: Vec::new(), config_revisions: Vec::new(), diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs index ec5d732c..83ef6799 100644 --- a/ublue/skillet/crates/unifi/src/lib.rs +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -4,7 +4,7 @@ use skillet_core::files::{ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; -use skillet_podman::{self, ContainerUser, PodmanConfig, PodmanError, Volume}; +use skillet_podman::{self, PodmanConfig, PodmanError, ProcessIdentity, Volume}; use std::{collections::BTreeMap, path::Path}; use thiserror::Error; use tracing::info; @@ -54,7 +54,6 @@ where "ContainerName=unifi".to_string(), "Environment=TZ=Europe/Madrid".to_string(), "Network=host".to_string(), - "User=unifi".to_string(), ], ); extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); @@ -83,12 +82,11 @@ where name: "unifi".to_string(), image: "docker.io/jacobalberty/unifi:latest".to_string(), networks: Vec::new(), - user: ContainerUser { - container_uid: 0, - container_gid: 0, - host_user: None, + process_identity: ProcessIdentity::Named { + user: "unifi".to_string(), + group: None, }, - create_host_user: false, + namespace_mapping: None, volumes: vec![Volume { host_path: DATA_PATH.to_string(), container_path: "/unifi".to_string(), From d4368030461fddc13c06b79c423b42ffca4a0714 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 01:53:34 +0200 Subject: [PATCH 154/252] refactor(podman): type networks and port bindings --- ublue/butane/ACCEPTANCE.md | 13 ++ ublue/design/podman-runtime.md | 6 +- ublue/plan/REFACTOR-05-PODMAN-MODEL.md | 13 +- ublue/skillet/crates/caddy/src/lib.rs | 22 +- ublue/skillet/crates/caddy/src/tests.rs | 15 +- ublue/skillet/crates/cli/src/test_fixture.rs | 3 +- .../crates/host-profiles/src/hosts_tests.rs | 14 +- ublue/skillet/crates/host-profiles/src/lib.rs | 24 +- .../crates/host-profiles/src/profile.rs | 9 + ublue/skillet/crates/pihole/src/lib.rs | 29 ++- ublue/skillet/crates/pihole/src/tests.rs | 23 +- ublue/skillet/crates/podman/src/lib.rs | 207 ++++++++++++++---- ublue/skillet/crates/podman/src/tests.rs | 87 +++++++- ublue/skillet/crates/syncthing/src/lib.rs | 39 +++- ublue/skillet/crates/syncthing/src/tests.rs | 10 +- ublue/skillet/crates/unifi/src/lib.rs | 6 +- 16 files changed, 380 insertions(+), 140 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 74f461ff..dbf16aa6 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1036,3 +1036,16 @@ host tool; it still uses normal command approvals. beezelbot base twice with no restart on the second apply, then was removed. - No live container identity or ownership checks ran; `/dev/kvm` is unavailable on this workstation. + +### Typed Podman networks and port publications, 2026-10-04 + +- Host composition now creates each profile bridge network once; application + recipes attach by network name. Container network mode and port bindings are + typed values. Dual-stack Pi-hole, Syncthing, and Caddy publications plus + UniFi/Tailscale host networking retain their prior intent. Raw directives + that conflict with typed fields fail validation. +- Podman, service, and full workspace tests passed. Strict all-target Clippy + passed. The CI container integration exited 0; beezelbot base applied twice + without a restart on the repeat and its container was removed. +- No named-VM DNS/discovery or service networking test ran; `/dev/kvm` is not + available here. diff --git a/ublue/design/podman-runtime.md b/ublue/design/podman-runtime.md index 643f68a6..7657c1a7 100644 --- a/ublue/design/podman-runtime.md +++ b/ublue/design/podman-runtime.md @@ -8,5 +8,7 @@ is opt-in, maps an existing host account through its subordinate-ID ranges, and never creates host accounts as a side effect. Host-wide Podman settings are selected and applied once by host composition. -Individual container recipes declare their own service configuration and -network attachments; they do not change global daemon policy. +Host composition also creates and records each shared network definition once. +Container recipes attach by network name and declare host-network mode or port +publications with typed values; they do not carry duplicate network definitions +or change global daemon policy. diff --git a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md index 0dcba26f..c8f72ebe 100644 --- a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md +++ b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md @@ -28,9 +28,16 @@ apply also owns a global DNS-port policy and converges shared networks repeatedl optional namespace mapping. Removed unused implicit host-account creation; UniFi now declares its image's named process user as typed data. Invalid names and raw `User`/`UIDMap`/`GIDMap` conflicts fail before file effects. -- Pending: typed host volume ownership, typed container network attachments and - publications, shared network baseline ownership, - storage dependencies, and full service migration. +- Implemented: shared bridge definitions are now created once by host + composition and carry change-refusal markers; containers declare named bridge + or host-network attachments. IPv4/IPv6 port publications are typed and + validated, and conflicting raw `Network`/`PublishPort` directives fail + before effects. Pi-hole, Syncthing, Caddy, UniFi, Tailscale, and the fixture + were migrated without changing their intended ports or modes. +- Pending: typed host volume ownership, reusable storage dependency helpers, + application unit dependency migration, old/new Quadlet comparison, and + named-VM validation for process identity, DNS, service discovery, and + persistence. ## Implementation sequence diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index 85d97b3a..47ad6f55 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -6,8 +6,8 @@ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; use skillet_podman::{ - self, PodmanConfig, PodmanError, PodmanNetwork, ProcessIdentity, QuadletSecret, SecretTarget, - Volume, + self, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, PortPublication, + ProcessIdentity, QuadletSecret, SecretTarget, Volume, }; use std::{collections::BTreeMap, path::Path}; use thiserror::Error; @@ -270,7 +270,7 @@ pub fn apply( system: &S, files: &F, sites: &CaddySites, - network: PodmanNetwork, + network_name: &str, ) -> Result<(), CaddyError> where S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, @@ -293,8 +293,6 @@ where vec![ "AutoUpdate=registry".to_string(), "ContainerName=caddy".to_string(), - "PublishPort=[::]:443:443/tcp".to_string(), - "PublishPort=0.0.0.0:443:443/tcp".to_string(), ], ); container.insert("Service".to_string(), vec!["Restart=always".to_string()]); @@ -314,7 +312,19 @@ where let config = PodmanConfig { name: "caddy".to_string(), image: "ghcr.io/caddybuilds/caddy-cloudflare:2".to_string(), - networks: vec![network], + network_attachments: vec![NetworkAttachment::Bridge(network_name.to_string())], + port_publications: ["::", "0.0.0.0"] + .into_iter() + .map(|address| PortPublication { + host_address: match address { + "::" => std::net::Ipv6Addr::UNSPECIFIED.into(), + _ => std::net::Ipv4Addr::UNSPECIFIED.into(), + }, + host_port: 443, + container_port: 443, + protocol: PortProtocol::Tcp, + }) + .collect(), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes: vec![ diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index b9a1bdaf..ca3faf2e 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -3,7 +3,6 @@ use skillet_core::{ system::PodmanSecretResource, test_utils::{MockFiles, MockSystem}, }; -use skillet_podman::PodmanNetwork; use std::sync::atomic::Ordering; fn syncthing_only() -> Vec { @@ -15,16 +14,6 @@ fn syncthing_only() -> Vec { }] } -fn test_network() -> PodmanNetwork { - PodmanNetwork { - unit_name: "beezelbot".to_string(), - options: vec![ - "NetworkName=beezelbot".to_string(), - "Driver=bridge".to_string(), - ], - } -} - #[test] fn applying_unchanged_caddy_sites_preserves_the_container() { let system = MockSystem::new(); @@ -41,7 +30,7 @@ fn applying_unchanged_caddy_sites_preserves_the_container() { &syncthing_only(), ) .unwrap(); - super::apply(&system, &files, &sites, test_network()).unwrap(); + super::apply(&system, &files, &sites, "beezelbot").unwrap(); let initial_restart_count = system.restart_count.load(Ordering::SeqCst); let initial_config = files .files @@ -58,7 +47,7 @@ fn applying_unchanged_caddy_sites_preserves_the_container() { .unwrap() .contains_key("/etc/skillet/caddy/Caddyfile")); - super::apply(&system, &files, &sites, test_network()).unwrap(); + super::apply(&system, &files, &sites, "beezelbot").unwrap(); assert_eq!( system.restart_count.load(Ordering::SeqCst), diff --git a/ublue/skillet/crates/cli/src/test_fixture.rs b/ublue/skillet/crates/cli/src/test_fixture.rs index c447b03b..3347637d 100644 --- a/ublue/skillet/crates/cli/src/test_fixture.rs +++ b/ublue/skillet/crates/cli/src/test_fixture.rs @@ -94,7 +94,8 @@ pub(super) fn apply( PodmanConfig { name: "skillet-smoke-fixture".to_string(), image: "docker.io/library/alpine:3.20".to_string(), - networks: Vec::new(), + network_attachments: Vec::new(), + port_publications: Vec::new(), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes: vec![ diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 7abcb6ec..8277208c 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -30,13 +30,19 @@ fn clamps_tailscale_uses_host_network_and_persistent_state() { assert_eq!(config.name, "tailscale"); assert_eq!(config.image, "docker.io/tailscale/tailscale:stable"); - assert!(config.networks.is_empty()); + assert_eq!( + config.network_attachments, + [skillet_podman::NetworkAttachment::Host] + ); assert_eq!(config.volumes.len(), 1); assert_eq!(config.volumes[0].host_path, "/var/lib/data/tailscale"); assert_eq!(config.volumes[0].container_path, "/var/lib/tailscale"); let container = &config.extra_config["Container"]; assert!(container.contains(&"ContainerName=tailscale".to_string())); - assert!(container.contains(&"Network=host".to_string())); + assert_eq!( + config.network_attachments, + [skillet_podman::NetworkAttachment::Host] + ); assert!(container.contains(&"AddCapability=NET_ADMIN".to_string())); assert!(container.contains(&"AddCapability=NET_RAW".to_string())); assert!(container.contains(&"AddDevice=/dev/net/tun:/dev/net/tun".to_string())); @@ -178,7 +184,7 @@ fn host_ui_declarations_include_only_the_services_each_host_runs() { .collect::>(), ["pihole", "syncthing"] ); - assert_eq!(clamps.network.unit_name, "clamps"); + assert_eq!(clamps.network_name, "clamps"); assert_eq!(clamps.services[0].upstream, "pihole"); assert_eq!(clamps.services[0].port, 8088); assert!(clamps.services[0].aliases.is_empty()); @@ -190,7 +196,7 @@ fn host_ui_declarations_include_only_the_services_each_host_runs() { assert_eq!(beezelbot.services.len(), 1); assert_eq!(beezelbot.services[0].name, "syncthing"); assert_eq!(beezelbot.services[0].aliases, ["sync.{host}"]); - assert_eq!(beezelbot.network.unit_name, "beezelbot"); + assert_eq!(beezelbot.network_name, "beezelbot"); assert!(ui_config_for_host("unknown-host").is_none()); } diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index 64eada82..f168d9e5 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -5,9 +5,7 @@ use skillet_core::{ files::{FileError, FileResource}, system::{SystemError, SystemResource}, }; -use skillet_podman::{ - PodmanConfig, PodmanNetwork, ProcessIdentity, QuadletSecret, SecretTarget, Volume, -}; +use skillet_podman::{PodmanConfig, ProcessIdentity, QuadletSecret, SecretTarget, Volume}; use std::collections::BTreeMap; use thiserror::Error; @@ -110,7 +108,7 @@ pub const CLOUDFLARE_ACME_TOKEN_CREDENTIAL: &str = "cloudflare_acme_token"; /// both on the workstation (to build delivery payloads) and on the host (to /// validate and apply them). pub struct HostUiConfig { - pub network: PodmanNetwork, + pub network_name: String, pub services: Vec, } @@ -118,7 +116,7 @@ pub fn ui_config_for_host(hostname: &str) -> Option { let profile = profile_for_name(hostname)?; let services = profile.ui_services(); (!services.is_empty()).then(|| HostUiConfig { - network: profile.service_network(), + network_name: profile.id.as_str().to_string(), services, }) } @@ -138,7 +136,6 @@ fn tailscale_config(hostname: &str, auth_key: String, state_path: &str) -> Podma format!("Environment=TS_HOSTNAME={hostname}"), "Environment=TS_STATE_DIR=/var/lib/tailscale".to_string(), "Environment=TS_USERSPACE=false".to_string(), - "Network=host".to_string(), ], ), ( @@ -149,7 +146,8 @@ fn tailscale_config(hostname: &str, auth_key: String, state_path: &str) -> Podma PodmanConfig { name: "tailscale".to_string(), image: "docker.io/tailscale/tailscale:stable".to_string(), - networks: Vec::new(), + network_attachments: vec![skillet_podman::NetworkAttachment::Host], + port_publications: Vec::new(), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes: vec![Volume { @@ -196,7 +194,7 @@ fn apply_syncthing( data_group: data_group.to_string(), uid, gid, - network: profile.service_network(), + network_name: profile.id.as_str().to_string(), }, )?; Ok(()) @@ -232,6 +230,9 @@ fn apply_profile( if profile.requires_pihole_dns_listener_policy() { skillet_podman::ensure_dns_listener_port(files, 54)?; } + if profile.requires_service_network() { + skillet_podman::ensure_network(system, files, &profile.service_network())?; + } for service in &profile.services { match &service.config { ServiceConfig::Pihole { custom_dns } => { @@ -263,7 +264,7 @@ fn apply_profile( .iter() .map(|(ip, domain)| ((*ip).to_string(), (*domain).to_string())) .collect::>(), - profile.service_network(), + profile.id.as_str().to_string(), )?; } ServiceConfig::Syncthing { .. } => apply_syncthing(system, files, profile, service)?, @@ -346,7 +347,7 @@ fn apply_caddy_host( ))); } let ui_config = HostUiConfig { - network: profile.service_network(), + network_name: profile.id.as_str().to_string(), services, }; if profile.requires_data_mount { @@ -356,6 +357,7 @@ fn apply_caddy_host( "/data", )?; } + skillet_podman::ensure_network(system, files, &profile.service_network())?; let sites = skillet_caddy::CaddySites::parse( credentials.require(CADDY_SITES_CREDENTIAL)?, hostname, @@ -365,6 +367,6 @@ fn apply_caddy_host( .require(CLOUDFLARE_ACME_TOKEN_CREDENTIAL)? .to_string(); system.ensure_podman_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL, &token)?; - skillet_caddy::apply(system, files, &sites, ui_config.network)?; + skillet_caddy::apply(system, files, &sites, &ui_config.network_name)?; Ok(()) } diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index 8b1dedbc..2596212e 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -121,6 +121,15 @@ impl HostProfile { .any(|service| matches!(service.config, ServiceConfig::Pihole { .. })) } + pub fn requires_service_network(&self) -> bool { + self.services.iter().any(|service| { + matches!( + service.config, + ServiceConfig::Pihole { .. } | ServiceConfig::Syncthing { .. } + ) || service.ui.is_some() + }) + } + pub fn requires_full_apply_credentials(&self) -> bool { self.services.iter().any(|service| { matches!( diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 7252917a..8c8e5d8f 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -4,7 +4,8 @@ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; use skillet_podman::{ - self, PodmanConfig, PodmanError, PodmanNetwork, ProcessIdentity, QuadletSecret, Volume, + self, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, PortPublication, + ProcessIdentity, QuadletSecret, Volume, }; use std::collections::BTreeMap; use std::path::Path; @@ -43,7 +44,7 @@ pub fn apply( user_config: &PiholeUser, secrets: Vec, custom_records: BTreeMap, - network: PodmanNetwork, + network_name: String, ) -> Result<(), PiholeError> where S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, @@ -116,10 +117,6 @@ where "Environment=FTLCONF_webserver_port=8088o,[::]:8088o".to_string(), "Environment=TZ=Europe/Madrid".to_string(), "Environment=WEBPASSWORD_FILE=/run/secrets/pihole_web_password".to_string(), - "PublishPort=[::]:53:53/tcp".to_string(), - "PublishPort=[::]:53:53/udp".to_string(), - "PublishPort=0.0.0.0:53:53/tcp".to_string(), - "PublishPort=0.0.0.0:53:53/udp".to_string(), ], ); extra_config.insert( @@ -145,7 +142,8 @@ where PodmanConfig { name: "pihole".to_string(), image: "docker.io/pihole/pihole:latest".to_string(), - networks: vec![network], + network_attachments: vec![NetworkAttachment::Bridge(network_name)], + port_publications: dns_port_publications(), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes, @@ -158,6 +156,23 @@ where Ok(()) } +fn dns_port_publications() -> Vec { + [ + std::net::Ipv6Addr::UNSPECIFIED.into(), + std::net::Ipv4Addr::UNSPECIFIED.into(), + ] + .into_iter() + .flat_map(|host_address| { + [PortProtocol::Tcp, PortProtocol::Udp].map(move |protocol| PortPublication { + host_address, + host_port: 53, + container_port: 53, + protocol, + }) + }) + .collect() +} + #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/ublue/skillet/crates/pihole/src/tests.rs b/ublue/skillet/crates/pihole/src/tests.rs index 2e78fa5a..09d1346d 100644 --- a/ublue/skillet/crates/pihole/src/tests.rs +++ b/ublue/skillet/crates/pihole/src/tests.rs @@ -3,7 +3,7 @@ use skillet_core::{ system::PodmanSecretResource, test_utils::{MockFiles, MockSystem}, }; -use skillet_podman::{PodmanNetwork, QuadletSecret, SecretTarget}; +use skillet_podman::{QuadletSecret, SecretTarget}; use std::collections::BTreeMap; #[test] @@ -33,19 +33,7 @@ fn pihole_uses_dual_stack_dns_network_and_registry_updates() { }, }], BTreeMap::new(), - PodmanNetwork { - unit_name: "clamps".to_string(), - options: vec![ - "DisableDNS=false".to_string(), - "Driver=bridge".to_string(), - "Gateway=172.26.26.1".to_string(), - "Gateway=fd59:4e23:2950:11f5::1".to_string(), - "IPv6=true".to_string(), - "NetworkName=clamps".to_string(), - "Subnet=172.26.26.0/24".to_string(), - "Subnet=fd59:4e23:2950:11f5::/64".to_string(), - ], - }, + "clamps".to_string(), ) .unwrap(); @@ -73,11 +61,4 @@ fn pihole_uses_dual_stack_dns_network_and_registry_updates() { ); } assert!(!quadlet.contains("Pod=")); - let network = String::from_utf8_lossy( - generated - .get("/etc/containers/systemd/clamps.network") - .unwrap(), - ); - assert!(network.contains("DisableDNS=false")); - assert!(network.contains("IPv6=true")); } diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 98f6731e..d497410f 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -6,7 +6,7 @@ use skillet_core::system::{ }; use std::collections::BTreeMap; use std::fmt::Write as _; -use std::{path::Path, str::FromStr}; +use std::{net::IpAddr, path::Path, str::FromStr}; use thiserror::Error; use tracing::info; @@ -22,6 +22,10 @@ pub enum PodmanError { InvalidNetworkName(String), #[error("Podman directive {0} conflicts with the typed container identity")] ConflictingIdentityDirective(&'static str), + #[error("invalid or conflicting typed Podman container directive: {0}")] + ConflictingContainerDirective(&'static str), + #[error("invalid Podman port publication")] + InvalidPortPublication, #[error("Network configuration changed for {0}; stop its consumers, remove the Podman network and its applied marker, then apply again")] NetworkConfigChanged(String), #[error("No valid {kind} subordinate-ID range for account {account}")] @@ -115,7 +119,8 @@ impl QuadletSecret { pub struct PodmanConfig { pub name: String, pub image: String, - pub networks: Vec, + pub network_attachments: Vec, + pub port_publications: Vec, pub process_identity: ProcessIdentity, pub namespace_mapping: Option, pub volumes: Vec, @@ -134,6 +139,46 @@ pub struct PodmanNetwork { pub options: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NetworkAttachment { + Host, + Bridge(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PortProtocol { + Tcp, + Udp, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PortPublication { + pub host_address: IpAddr, + pub host_port: u16, + pub container_port: u16, + pub protocol: PortProtocol, +} + +impl PortPublication { + fn to_directive(&self) -> Result { + if self.host_port == 0 || self.container_port == 0 { + return Err(PodmanError::InvalidPortPublication); + } + let address = match self.host_address { + IpAddr::V4(address) => address.to_string(), + IpAddr::V6(address) => format!("[{address}]"), + }; + let protocol = match self.protocol { + PortProtocol::Tcp => "tcp", + PortProtocol::Udp => "udp", + }; + Ok(format!( + "PublishPort={address}:{}:{}/{}", + self.host_port, self.container_port, protocol + )) + } +} + /// Configure Aardvark's host-wide listener before applying services that /// publish DNS on the host. This policy belongs to host composition, not to /// each container that happens to use a bridge network. @@ -152,6 +197,46 @@ pub fn ensure_dns_listener_port( )?) } +pub fn ensure_network( + system: &S, + files: &F, + network: &PodmanNetwork, +) -> Result<(), PodmanError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let content = render_network(network)?; + let marker_path = + Path::new("/var/lib/skillet/networks").join(format!("{}.applied", network.unit_name)); + if let Some(applied) = files.read_file(&marker_path)? { + if applied != content.as_bytes() { + return Err(PodmanError::NetworkConfigChanged(network.unit_name.clone())); + } + } + let quadlet_dir = Path::new("/etc/containers/systemd"); + files.ensure_directory(quadlet_dir, Some(0o755), Some("root"), Some("root"))?; + if files.ensure_file( + &quadlet_dir.join(format!("{}.network", network.unit_name)), + content.as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )? { + system.daemon_reload()?; + } + let state_dir = Path::new("/var/lib/skillet/networks"); + files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_file( + &marker_path, + content.as_bytes(), + Some(0o644), + Some("root"), + Some("root"), + )?; + Ok(()) +} + #[allow(clippy::similar_names)] pub fn container(system: &S, files: &F, config: PodmanConfig) -> Result where @@ -159,42 +244,30 @@ where F: FileMutationResource + FileReadResource + ?Sized, { validate_process_identity(&config)?; + validate_container_settings(&config)?; let name = &config.name; info!("Ensuring podman container: {name}"); let mut extra_config = config.extra_config; let config_revisions = config.config_revisions; - let mut network_states = Vec::new(); - - // A container's reference to the network Quadlet creates the systemd - // dependency that starts the network before the container. - for network in &config.networks { - let network_content = render_network(network)?; - let marker_path = - Path::new("/var/lib/skillet/networks").join(format!("{}.applied", network.unit_name)); - if let Some(applied) = files.read_file(&marker_path)? { - if applied != network_content.as_bytes() { - return Err(PodmanError::NetworkConfigChanged(network.unit_name.clone())); + for network in &config.network_attachments { + let directive = match network { + NetworkAttachment::Host => "Network=host".to_string(), + NetworkAttachment::Bridge(name) => { + validate_network_attachment_name(name)?; + format!("Network={name}.network") } - } - - let quadlet_dir = Path::new("/etc/containers/systemd"); - files.ensure_directory(quadlet_dir, Some(0o755), Some("root"), Some("root"))?; - let network_unit_changed = files.ensure_file( - &quadlet_dir.join(format!("{}.network", network.unit_name)), - network_content.as_bytes(), - Some(0o644), - Some("root"), - Some("root"), - )?; - if network_unit_changed { - system.daemon_reload()?; - } - network_states.push((marker_path, network_content.into_bytes())); + }; extra_config .entry("Container".to_string()) .or_default() - .push(format!("Network={}.network", network.unit_name)); + .push(directive); + } + for publication in &config.port_publications { + extra_config + .entry("Container".to_string()) + .or_default() + .push(publication.to_directive()?); } add_process_identity( @@ -245,21 +318,71 @@ where &config_revisions, )?; - if !network_states.is_empty() { - let state_dir = Path::new("/var/lib/skillet/networks"); - files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; - for (marker_path, content) in network_states { - files.ensure_file( - &marker_path, - &content, - Some(0o644), - Some("root"), - Some("root"), - )?; - } + Ok(changed) +} + +fn validate_network_attachment_name(name: &str) -> Result<(), PodmanError> { + let valid = !name.is_empty() + && name + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_.-".contains(character)); + if valid { + Ok(()) + } else { + Err(PodmanError::ConflictingContainerDirective("Network")) } +} - Ok(changed) +fn validate_container_settings(config: &PodmanConfig) -> Result<(), PodmanError> { + let mut network_names = std::collections::BTreeSet::new(); + let mut has_host_network = false; + for attachment in &config.network_attachments { + match attachment { + NetworkAttachment::Host => { + has_host_network = true; + } + NetworkAttachment::Bridge(name) => { + validate_network_attachment_name(name)?; + if !network_names.insert(name) { + return Err(PodmanError::ConflictingContainerDirective("Network")); + } + } + } + } + if has_host_network && (!network_names.is_empty() || config.network_attachments.len() > 1) { + return Err(PodmanError::ConflictingContainerDirective("Network")); + } + let mut publications = std::collections::BTreeSet::new(); + for publication in &config.port_publications { + if publication.host_port == 0 || publication.container_port == 0 { + return Err(PodmanError::InvalidPortPublication); + } + let key = ( + publication.host_address, + publication.host_port, + publication.container_port, + match publication.protocol { + PortProtocol::Tcp => 0, + PortProtocol::Udp => 1, + }, + ); + if !publications.insert(key) { + return Err(PodmanError::ConflictingContainerDirective("PublishPort")); + } + } + for line in config.extra_config.get("Container").into_iter().flatten() { + if line.starts_with("Network=") { + return Err(PodmanError::ConflictingContainerDirective("Network")); + } + if line.starts_with("PublishPort=") { + return Err(PodmanError::ConflictingContainerDirective("PublishPort")); + } + } + Ok(()) } fn render_network(network: &PodmanNetwork) -> Result { diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index 3c40ffc5..d98641dc 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -11,7 +11,8 @@ fn fixture() -> PodmanConfig { PodmanConfig { name: "unit-fixture".to_string(), image: "example.invalid/fixture:1".to_string(), - networks: Vec::new(), + network_attachments: Vec::new(), + port_publications: Vec::new(), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes: Vec::new(), @@ -42,13 +43,16 @@ fn clamps_network() -> PodmanNetwork { } #[test] -fn container_apply_owns_network_quadlet_but_not_host_dns_policy() { +fn shared_network_is_created_separately_and_attached_idempotently() { let system = MockSystem::new(); let files = MockFiles::new(); system.ensure_podman_secret("dummy", "first").unwrap(); + ensure_network(&system, &files, &clamps_network()).unwrap(); let mut config = fixture(); - config.networks.push(clamps_network()); + config + .network_attachments + .push(NetworkAttachment::Bridge("clamps".to_string())); container(&system, &files, config).unwrap(); let managed_files = files.files.lock().unwrap(); @@ -71,7 +75,9 @@ fn container_apply_owns_network_quadlet_but_not_host_dns_policy() { drop(managed_files); let mut repeated = fixture(); - repeated.networks.push(clamps_network()); + repeated + .network_attachments + .push(NetworkAttachment::Bridge("clamps".to_string())); container(&system, &files, repeated).unwrap(); assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); } @@ -87,15 +93,76 @@ fn host_dns_listener_policy_is_independently_idempotent() { ); } +#[test] +fn typed_network_and_dual_stack_port_publications_render_quadlet_directives() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config + .network_attachments + .push(NetworkAttachment::Bridge("clamps".to_string())); + config.port_publications = vec![ + PortPublication { + host_address: std::net::Ipv6Addr::UNSPECIFIED.into(), + host_port: 53, + container_port: 53, + protocol: PortProtocol::Udp, + }, + PortPublication { + host_address: std::net::Ipv4Addr::UNSPECIFIED.into(), + host_port: 53, + container_port: 53, + protocol: PortProtocol::Tcp, + }, + ]; + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("Network=clamps.network")); + assert!(quadlet.contains("PublishPort=[::]:53:53/udp")); + assert!(quadlet.contains("PublishPort=0.0.0.0:53:53/tcp")); +} + +#[test] +fn invalid_or_conflicting_typed_container_settings_fail_before_effects() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config.network_attachments = vec![ + NetworkAttachment::Host, + NetworkAttachment::Bridge("clamps".to_string()), + ]; + assert!(matches!( + container(&system, &files, config), + Err(PodmanError::ConflictingContainerDirective("Network")) + )); + assert!(files.files.lock().unwrap().is_empty()); + + let mut config = fixture(); + config.port_publications.push(PortPublication { + host_address: std::net::Ipv4Addr::UNSPECIFIED.into(), + host_port: 0, + container_port: 53, + protocol: PortProtocol::Udp, + }); + assert!(matches!( + container(&system, &files, config), + Err(PodmanError::InvalidPortPublication) + )); + assert!(files.files.lock().unwrap().is_empty()); +} + #[test] fn changing_a_created_network_fails_before_replacing_its_quadlet() { let system = MockSystem::new(); let files = MockFiles::new(); system.ensure_podman_secret("dummy", "first").unwrap(); - let mut initial = fixture(); - initial.networks.push(clamps_network()); - container(&system, &files, initial).unwrap(); + ensure_network(&system, &files, &clamps_network()).unwrap(); let original = files .files @@ -106,10 +173,8 @@ fn changing_a_created_network_fails_before_replacing_its_quadlet() { .unwrap(); let mut changed = clamps_network(); changed.options.push("Internal=true".to_string()); - let mut config = fixture(); - config.networks.push(changed); assert!(matches!( - container(&system, &files, config), + ensure_network(&system, &files, &changed), Err(PodmanError::NetworkConfigChanged(name)) if name == "clamps" )); assert_eq!( @@ -120,7 +185,7 @@ fn changing_a_created_network_fails_before_replacing_its_quadlet() { .get("/etc/containers/systemd/clamps.network"), Some(&original) ); - assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 0); } #[test] diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index 1c9c15c7..fdf40784 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -2,7 +2,10 @@ use skillet_core::files::{FileError, FileMutationResource, FileReadResource, Sto use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; -use skillet_podman::{self, PodmanConfig, PodmanError, PodmanNetwork, ProcessIdentity, Volume}; +use skillet_podman::{ + self, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, PortPublication, + ProcessIdentity, Volume, +}; use std::collections::BTreeMap; use std::path::Path; use thiserror::Error; @@ -24,7 +27,7 @@ pub struct SyncthingConfig { pub data_group: String, pub uid: u32, pub gid: u32, - pub network: PodmanNetwork, + pub network_name: String, } pub fn apply(system: &S, files: &F, config: SyncthingConfig) -> Result<(), SyncthingError> @@ -58,10 +61,6 @@ where "ContainerName=syncthing".to_string(), format!("Environment=PGID={}", config.gid), format!("Environment=PUID={}", config.uid), - "PublishPort=[::]:22000:22000/tcp".to_string(), - "PublishPort=0.0.0.0:22000:22000/tcp".to_string(), - "PublishPort=[::]:22000:22000/udp".to_string(), - "PublishPort=0.0.0.0:22000:22000/udp".to_string(), ], ); extra_config.insert( @@ -87,7 +86,33 @@ where PodmanConfig { name: "syncthing".to_string(), image: "docker.io/syncthing/syncthing:latest".to_string(), - networks: vec![config.network], + network_attachments: vec![NetworkAttachment::Bridge(config.network_name)], + port_publications: vec![ + PortPublication { + host_address: std::net::Ipv6Addr::UNSPECIFIED.into(), + host_port: 22000, + container_port: 22000, + protocol: PortProtocol::Tcp, + }, + PortPublication { + host_address: std::net::Ipv4Addr::UNSPECIFIED.into(), + host_port: 22000, + container_port: 22000, + protocol: PortProtocol::Tcp, + }, + PortPublication { + host_address: std::net::Ipv6Addr::UNSPECIFIED.into(), + host_port: 22000, + container_port: 22000, + protocol: PortProtocol::Udp, + }, + PortPublication { + host_address: std::net::Ipv4Addr::UNSPECIFIED.into(), + host_port: 22000, + container_port: 22000, + protocol: PortProtocol::Udp, + }, + ], process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes, diff --git a/ublue/skillet/crates/syncthing/src/tests.rs b/ublue/skillet/crates/syncthing/src/tests.rs index 10d6ab1d..fe6e1308 100644 --- a/ublue/skillet/crates/syncthing/src/tests.rs +++ b/ublue/skillet/crates/syncthing/src/tests.rs @@ -15,15 +15,7 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { data_group: "giacomo".to_string(), uid: 1000, gid: 1000, - network: PodmanNetwork { - unit_name: "clamps".to_string(), - options: vec![ - "DisableDNS=false".to_string(), - "Driver=bridge".to_string(), - "NetworkName=clamps".to_string(), - "Subnet=172.26.26.0/24".to_string(), - ], - }, + network_name: "clamps".to_string(), }, ) .unwrap(); diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs index 83ef6799..88f936cf 100644 --- a/ublue/skillet/crates/unifi/src/lib.rs +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -4,7 +4,7 @@ use skillet_core::files::{ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; -use skillet_podman::{self, PodmanConfig, PodmanError, ProcessIdentity, Volume}; +use skillet_podman::{self, NetworkAttachment, PodmanConfig, PodmanError, ProcessIdentity, Volume}; use std::{collections::BTreeMap, path::Path}; use thiserror::Error; use tracing::info; @@ -53,7 +53,6 @@ where "AutoUpdate=registry".to_string(), "ContainerName=unifi".to_string(), "Environment=TZ=Europe/Madrid".to_string(), - "Network=host".to_string(), ], ); extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); @@ -81,7 +80,8 @@ where PodmanConfig { name: "unifi".to_string(), image: "docker.io/jacobalberty/unifi:latest".to_string(), - networks: Vec::new(), + network_attachments: vec![NetworkAttachment::Host], + port_publications: Vec::new(), process_identity: ProcessIdentity::Named { user: "unifi".to_string(), group: None, From 3ef996b0968fd3d7f889b8e5f3f17d449580e41d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 02:00:13 +0200 Subject: [PATCH 155/252] refactor(podman): share data mount dependencies --- ublue/butane/ACCEPTANCE.md | 14 ++++ ublue/design/podman-runtime.md | 6 ++ ublue/plan/REFACTOR-05-PODMAN-MODEL.md | 6 +- ublue/skillet/crates/caddy/src/lib.rs | 10 +-- ublue/skillet/crates/cli/src/test_fixture.rs | 1 + ublue/skillet/crates/host-profiles/src/lib.rs | 1 + ublue/skillet/crates/pihole/src/lib.rs | 10 +-- ublue/skillet/crates/podman/src/lib.rs | 83 ++++++++++++++++++- ublue/skillet/crates/podman/src/tests.rs | 37 +++++++++ ublue/skillet/crates/syncthing/src/lib.rs | 10 +-- ublue/skillet/crates/unifi/src/lib.rs | 10 +-- 11 files changed, 158 insertions(+), 30 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index dbf16aa6..4b5811e5 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1049,3 +1049,17 @@ host tool; it still uses normal command approvals. without a restart on the repeat and its container was removed. - No named-VM DNS/discovery or service networking test ran; `/dev/kvm` is not available here. + +### Shared service-data mount dependency, 2026-10-04 + +- Added a reusable typed Podman mount dependency that renders preparation, + bind, ordering, and mountpoint assertion directives. Persistent Pi-hole, + Syncthing, UniFi, Caddy, and Tailscale containers use it. Ignition still owns + filesystem and graphroot preparation; applications own their child data + directories and ownership. +- The dependency rendering/rejection tests and full workspace tests passed. + Strict all-target Clippy and the CI container integration passed; both + beezelbot base applies succeeded with no restart on the second apply and the + disposable container was removed. +- Live mount/boot-order checks need a named VM and remain unverified because + `/dev/kvm` is unavailable here. diff --git a/ublue/design/podman-runtime.md b/ublue/design/podman-runtime.md index 7657c1a7..fdc06f74 100644 --- a/ublue/design/podman-runtime.md +++ b/ublue/design/podman-runtime.md @@ -12,3 +12,9 @@ Host composition also creates and records each shared network definition once. Container recipes attach by network name and declare host-network mode or port publications with typed values; they do not carry duplicate network definitions or change global daemon policy. + +Persistent service containers use the shared `/var/lib/data` mount dependency: +their units require its preparation service, bind to the mount, and assert the +mountpoint exists. Ignition creates and prepares the Btrfs filesystem and +Podman graphroot; application recipes validate mounts and manage their own +service directories and ownership. diff --git a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md index c8f72ebe..da65c365 100644 --- a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md +++ b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md @@ -34,8 +34,10 @@ apply also owns a global DNS-port policy and converges shared networks repeatedl validated, and conflicting raw `Network`/`PublishPort` directives fail before effects. Pi-hole, Syncthing, Caddy, UniFi, Tailscale, and the fixture were migrated without changing their intended ports or modes. -- Pending: typed host volume ownership, reusable storage dependency helpers, - application unit dependency migration, old/new Quadlet comparison, and +- Implemented: added a reusable typed mount dependency for persistent service + containers. Pi-hole, Syncthing, UniFi, Caddy, and Tailscale use the shared + data mount helper; invalid mount inputs fail before container effects. +- Pending: typed host volume ownership, old/new Quadlet comparison, and named-VM validation for process identity, DNS, service discovery, and persistence. diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index 47ad6f55..903fe570 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -6,8 +6,8 @@ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; use skillet_podman::{ - self, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, PortPublication, - ProcessIdentity, QuadletSecret, SecretTarget, Volume, + self, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, + PortPublication, ProcessIdentity, QuadletSecret, SecretTarget, Volume, }; use std::{collections::BTreeMap, path::Path}; use thiserror::Error; @@ -301,11 +301,6 @@ where vec![ "Description=Caddy private UI reverse proxy".to_string(), "After=network-online.target".to_string(), - "Requires=skillet-data-prepare.service".to_string(), - "After=skillet-data-prepare.service".to_string(), - "BindsTo=var-lib-data.mount".to_string(), - "After=var-lib-data.mount".to_string(), - "AssertPathIsMountPoint=/var/lib/data".to_string(), ], ); @@ -325,6 +320,7 @@ where protocol: PortProtocol::Tcp, }) .collect(), + storage_dependency: Some(MountDependency::shared_service_data()), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes: vec![ diff --git a/ublue/skillet/crates/cli/src/test_fixture.rs b/ublue/skillet/crates/cli/src/test_fixture.rs index 3347637d..2bf7af31 100644 --- a/ublue/skillet/crates/cli/src/test_fixture.rs +++ b/ublue/skillet/crates/cli/src/test_fixture.rs @@ -96,6 +96,7 @@ pub(super) fn apply( image: "docker.io/library/alpine:3.20".to_string(), network_attachments: Vec::new(), port_publications: Vec::new(), + storage_dependency: None, process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes: vec![ diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index f168d9e5..bdd978be 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -148,6 +148,7 @@ fn tailscale_config(hostname: &str, auth_key: String, state_path: &str) -> Podma image: "docker.io/tailscale/tailscale:stable".to_string(), network_attachments: vec![skillet_podman::NetworkAttachment::Host], port_publications: Vec::new(), + storage_dependency: Some(skillet_podman::MountDependency::shared_service_data()), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes: vec![Volume { diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 8c8e5d8f..ed67ee0c 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -4,8 +4,8 @@ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; use skillet_podman::{ - self, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, PortPublication, - ProcessIdentity, QuadletSecret, Volume, + self, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, + PortPublication, ProcessIdentity, QuadletSecret, Volume, }; use std::collections::BTreeMap; use std::path::Path; @@ -124,11 +124,6 @@ where vec![ "Description=Pi. Hole".to_string(), "After=network-online.target".to_string(), - "Requires=skillet-data-prepare.service".to_string(), - "After=skillet-data-prepare.service".to_string(), - "BindsTo=var-lib-data.mount".to_string(), - "After=var-lib-data.mount".to_string(), - "AssertPathIsMountPoint=/var/lib/data".to_string(), ], ); extra_config.insert( @@ -144,6 +139,7 @@ where image: "docker.io/pihole/pihole:latest".to_string(), network_attachments: vec![NetworkAttachment::Bridge(network_name)], port_publications: dns_port_publications(), + storage_dependency: Some(MountDependency::shared_service_data()), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes, diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index d497410f..28318c71 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -6,7 +6,11 @@ use skillet_core::system::{ }; use std::collections::BTreeMap; use std::fmt::Write as _; -use std::{net::IpAddr, path::Path, str::FromStr}; +use std::{ + net::IpAddr, + path::{Path, PathBuf}, + str::FromStr, +}; use thiserror::Error; use tracing::info; @@ -26,6 +30,8 @@ pub enum PodmanError { ConflictingContainerDirective(&'static str), #[error("invalid Podman port publication")] InvalidPortPublication, + #[error("invalid Podman storage dependency: {0}")] + InvalidStorageDependency(&'static str), #[error("Network configuration changed for {0}; stop its consumers, remove the Podman network and its applied marker, then apply again")] NetworkConfigChanged(String), #[error("No valid {kind} subordinate-ID range for account {account}")] @@ -121,6 +127,7 @@ pub struct PodmanConfig { pub image: String, pub network_attachments: Vec, pub port_publications: Vec, + pub storage_dependency: Option, pub process_identity: ProcessIdentity, pub namespace_mapping: Option, pub volumes: Vec, @@ -159,6 +166,55 @@ pub struct PortPublication { pub protocol: PortProtocol, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MountDependency { + pub mount_path: PathBuf, + pub mount_unit: String, + pub prepare_unit: Option, +} + +impl MountDependency { + pub fn shared_service_data() -> Self { + Self { + mount_path: PathBuf::from("/var/lib/data"), + mount_unit: "var-lib-data.mount".to_string(), + prepare_unit: Some("skillet-data-prepare.service".to_string()), + } + } + + fn validate(&self) -> Result<(), PodmanError> { + if !self.mount_path.is_absolute() + || self.mount_path == Path::new("/") + || self + .mount_path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(PodmanError::InvalidStorageDependency("mount path")); + } + validate_unit_name(&self.mount_unit, "mount unit")?; + if let Some(unit) = &self.prepare_unit { + validate_unit_name(unit, "prepare unit")?; + } + Ok(()) + } + + fn unit_directives(&self) -> Vec { + let mut directives = Vec::new(); + if let Some(prepare) = &self.prepare_unit { + directives.push(format!("Requires={prepare}")); + directives.push(format!("After={prepare}")); + } + directives.push(format!("BindsTo={}", self.mount_unit)); + directives.push(format!("After={}", self.mount_unit)); + directives.push(format!( + "AssertPathIsMountPoint={}", + self.mount_path.display() + )); + directives + } +} + impl PortPublication { fn to_directive(&self) -> Result { if self.host_port == 0 || self.container_port == 0 { @@ -269,6 +325,12 @@ where .or_default() .push(publication.to_directive()?); } + if let Some(dependency) = &config.storage_dependency { + extra_config + .entry("Unit".to_string()) + .or_default() + .extend(dependency.unit_directives()); + } add_process_identity( system, @@ -382,9 +444,28 @@ fn validate_container_settings(config: &PodmanConfig) -> Result<(), PodmanError> return Err(PodmanError::ConflictingContainerDirective("PublishPort")); } } + if let Some(dependency) = &config.storage_dependency { + dependency.validate()?; + } Ok(()) } +fn validate_unit_name(value: &str, context: &'static str) -> Result<(), PodmanError> { + let valid = !value.is_empty() + && value + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_.-@".contains(character)); + if valid { + Ok(()) + } else { + Err(PodmanError::InvalidStorageDependency(context)) + } +} + fn render_network(network: &PodmanNetwork) -> Result { let valid_name = !network.unit_name.is_empty() && network diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index d98641dc..5be5bf0f 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -13,6 +13,7 @@ fn fixture() -> PodmanConfig { image: "example.invalid/fixture:1".to_string(), network_attachments: Vec::new(), port_publications: Vec::new(), + storage_dependency: None, process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes: Vec::new(), @@ -156,6 +157,42 @@ fn invalid_or_conflicting_typed_container_settings_fail_before_effects() { assert!(files.files.lock().unwrap().is_empty()); } +#[test] +fn shared_data_mount_dependency_is_rendered_and_invalid_dependency_fails_early() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config.storage_dependency = Some(MountDependency::shared_service_data()); + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + for directive in [ + "Requires=skillet-data-prepare.service", + "After=skillet-data-prepare.service", + "BindsTo=var-lib-data.mount", + "After=var-lib-data.mount", + "AssertPathIsMountPoint=/var/lib/data", + ] { + assert!(quadlet.contains(directive), "missing {directive}"); + } + + let empty_files = MockFiles::new(); + let mut invalid = fixture(); + invalid.storage_dependency = Some(MountDependency { + mount_path: std::path::PathBuf::from("/var/lib/data/../other"), + mount_unit: "var-lib-data.mount".to_string(), + prepare_unit: None, + }); + assert!(matches!( + container(&system, &empty_files, invalid), + Err(PodmanError::InvalidStorageDependency("mount path")) + )); + assert!(empty_files.files.lock().unwrap().is_empty()); +} + #[test] fn changing_a_created_network_fails_before_replacing_its_quadlet() { let system = MockSystem::new(); diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index fdf40784..12454001 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -3,8 +3,8 @@ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; use skillet_podman::{ - self, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, PortPublication, - ProcessIdentity, Volume, + self, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, + PortPublication, ProcessIdentity, Volume, }; use std::collections::BTreeMap; use std::path::Path; @@ -68,11 +68,6 @@ where vec![ "Description=Syncthing file synchronization".to_string(), "After=network-online.target".to_string(), - "Requires=skillet-data-prepare.service".to_string(), - "After=skillet-data-prepare.service".to_string(), - "BindsTo=var-lib-data.mount".to_string(), - "After=var-lib-data.mount".to_string(), - "AssertPathIsMountPoint=/var/lib/data".to_string(), ], ); extra_config.insert( @@ -113,6 +108,7 @@ where protocol: PortProtocol::Udp, }, ], + storage_dependency: Some(MountDependency::shared_service_data()), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, volumes, diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs index 88f936cf..65166ef9 100644 --- a/ublue/skillet/crates/unifi/src/lib.rs +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -4,7 +4,9 @@ use skillet_core::files::{ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; -use skillet_podman::{self, NetworkAttachment, PodmanConfig, PodmanError, ProcessIdentity, Volume}; +use skillet_podman::{ + self, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, ProcessIdentity, Volume, +}; use std::{collections::BTreeMap, path::Path}; use thiserror::Error; use tracing::info; @@ -62,11 +64,6 @@ where "Description=UniFi Network application".to_string(), "After=network-online.target".to_string(), "Wants=network-online.target".to_string(), - "Requires=skillet-data-prepare.service".to_string(), - "After=skillet-data-prepare.service".to_string(), - "BindsTo=var-lib-data.mount".to_string(), - "After=var-lib-data.mount".to_string(), - "AssertPathIsMountPoint=/var/lib/data".to_string(), ], ); extra_config.insert( @@ -82,6 +79,7 @@ where image: "docker.io/jacobalberty/unifi:latest".to_string(), network_attachments: vec![NetworkAttachment::Host], port_publications: Vec::new(), + storage_dependency: Some(MountDependency::shared_service_data()), process_identity: ProcessIdentity::Named { user: "unifi".to_string(), group: None, From 9c54351e14e9b9e1ffed5af5706158c771140002 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 02:49:56 +0200 Subject: [PATCH 156/252] refactor(skillet): unify activation and recording --- .../plan/REFACTOR-06-CONVERGENCE-RECORDING.md | 29 ++- ublue/skillet/Cargo.lock | 3 + ublue/skillet/crates/btrbk/Cargo.toml | 2 + ublue/skillet/crates/btrbk/src/lib.rs | 34 ++- ublue/skillet/crates/btrbk/src/tests.rs | 6 +- ublue/skillet/crates/cli-common/src/lib.rs | 104 ++++++-- .../crates/cli-common/src/recording_tests.rs | 78 ++++++ ublue/skillet/crates/core/Cargo.toml | 1 + ublue/skillet/crates/core/src/activation.rs | 94 ++++++++ .../crates/core/src/activation/tests.rs | 118 +++++++++ ublue/skillet/crates/core/src/lib.rs | 1 + ublue/skillet/crates/core/src/recorder.rs | 226 +++++++++++------- .../skillet/crates/core/src/recorder_tests.rs | 27 +++ ublue/skillet/crates/core/src/resource_op.rs | 17 +- ublue/skillet/crates/core/src/test_utils.rs | 5 + ublue/skillet/crates/hardening/src/lib.rs | 41 ++-- ublue/skillet/crates/podman/src/lib.rs | 44 ++-- ublue/skillet/crates/podman/src/tests.rs | 20 +- 18 files changed, 663 insertions(+), 187 deletions(-) create mode 100644 ublue/skillet/crates/cli-common/src/recording_tests.rs create mode 100644 ublue/skillet/crates/core/src/activation.rs create mode 100644 ublue/skillet/crates/core/src/activation/tests.rs create mode 100644 ublue/skillet/crates/core/src/recorder_tests.rs diff --git a/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md b/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md index b21753af..9e912336 100644 --- a/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md +++ b/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md @@ -1,8 +1,7 @@ # 6. Explicit convergence outcomes and failure-safe recording -Status: planned; follows workstreams 3–5 in -[the prerequisite roadmap](SKILLET-REFACTOR.md). -Further feature milestones wait for that roadmap's completion. +Status: implementation complete; named-VM acceptance pending. Feature +milestones remain gated by [the prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate @@ -13,9 +12,27 @@ Further feature milestones wait for that roadmap's completion. - `../design/{skillet-architecture,smoke-vms}.md` and existing failure-recovery unit/VM fixture tests. -Current defects: activation markers are implemented separately in hardening -and Podman; returned booleans can describe only file changes despite a restart. -Recordings are saved only after successful apply and lack results/complete inputs. +Original defects: activation markers were implemented separately in hardening +and Podman; returned booleans described file changes despite restarts. +Recordings were saved only after successful apply and exposed payload hashes. + +## Progress, 2026-10-04 + +- Implemented one shared activation helper for hardening, Podman containers, + and the btrbk timer. It reports definition changes, starts/restarts, and + recovery of pending activation; marker state is committed only after a + successful service action. Persistent services are restarted after desired + changes, stopped unchanged services are started, and completed oneshots are + not rerun just because they are inactive. +- Implemented version 1 diagnostic recordings with overall outcome and + per-operation success/change/failure. Recording omits file contents, content + hashes, secret payload fingerprints, and raw operation errors. Writes use a + synced temporary file and atomic replacement. Failed applies are recorded; + if recording also fails, the returned error retains both causes. +- Focused tests cover changed/no-op/stopped states, reload/start/restart + interruption and retry, oneshots, secret redaction, failed apply recording, + and simultaneous apply/record failure. Full VM-level failure/reboot acceptance + remains pending. ## Implementation sequence diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index c5f489ed..255eeadf 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2324,6 +2324,8 @@ dependencies = [ name = "skillet_btrbk" version = "0.1.0" dependencies = [ + "hex", + "sha2 0.10.9", "skillet_core", "thiserror 1.0.69", ] @@ -2362,6 +2364,7 @@ dependencies = [ "hex", "nix", "serde", + "serde_yml", "sha2 0.10.9", "tempfile", "thiserror 1.0.69", diff --git a/ublue/skillet/crates/btrbk/Cargo.toml b/ublue/skillet/crates/btrbk/Cargo.toml index 43c901a9..db517d34 100644 --- a/ublue/skillet/crates/btrbk/Cargo.toml +++ b/ublue/skillet/crates/btrbk/Cargo.toml @@ -9,6 +9,8 @@ workspace = true [dependencies] skillet_core.workspace = true thiserror.workspace = true +sha2.workspace = true +hex.workspace = true [dev-dependencies] skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/btrbk/src/lib.rs b/ublue/skillet/crates/btrbk/src/lib.rs index bfbc2f45..b9a9c67c 100644 --- a/ublue/skillet/crates/btrbk/src/lib.rs +++ b/ublue/skillet/crates/btrbk/src/lib.rs @@ -1,5 +1,7 @@ +use sha2::{Digest, Sha256}; use skillet_core::{ - files::{FileError, FileMutationResource, StorageResource}, + activation::{self, ActivationRequest, ConsumerKind}, + files::{FileError, FileMutationResource, FileReadResource, StorageResource}, system::{ServiceResource, SystemError}, }; use std::path::{Component, Path, PathBuf}; @@ -16,6 +18,8 @@ pub enum BtrbkError { File(#[from] FileError), #[error("System error: {0}")] System(#[from] SystemError), + #[error("Activation error: {0}")] + Activation(#[from] skillet_core::activation::ActivationError), #[error("Invalid Btrfs subvolume path '{0}': use safe relative path components beneath /var/lib/data")] InvalidSubvolume(String), } @@ -29,7 +33,7 @@ pub struct BtrbkConfig { pub fn apply(system: &S, files: &F, config: &BtrbkConfig) -> Result<(), BtrbkError> where S: ServiceResource + ?Sized, - F: FileMutationResource + StorageResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, { if config.snapshot_subvolumes.is_empty() { return Ok(()); @@ -81,13 +85,27 @@ where Some("root"), )?; - if config_changed || service_changed || timer_changed { - system.daemon_reload()?; - } + let definition_changed = config_changed || service_changed || timer_changed; + let mut hasher = Sha256::new(); + hasher.update(render_config(&sources).as_bytes()); + hasher.update(service_unit().as_bytes()); + hasher.update(timer_unit().as_bytes()); + let revision = hex::encode(hasher.finalize()); system.service_enable("skillet-btrbk.timer")?; - if !system.service_is_active("skillet-btrbk.timer")? { - system.service_start("skillet-btrbk.timer")?; - } + let state_dir = Path::new("/var/lib/skillet/btrbk"); + files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; + activation::activate( + system, + files, + &ActivationRequest { + service: "skillet-btrbk.timer".to_string(), + state_path: state_dir.join("timer.applied"), + revision: revision.into_bytes(), + definition_changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + }, + )?; Ok(()) } diff --git a/ublue/skillet/crates/btrbk/src/tests.rs b/ublue/skillet/crates/btrbk/src/tests.rs index 6d7409e8..c088a864 100644 --- a/ublue/skillet/crates/btrbk/src/tests.rs +++ b/ublue/skillet/crates/btrbk/src/tests.rs @@ -103,14 +103,10 @@ fn repeated_apply_keeps_managed_state_and_timer_running() { }; apply(&system, &files, &config).unwrap(); apply(&system, &files, &config).unwrap(); - assert_eq!( - system.start_count.load(std::sync::atomic::Ordering::SeqCst), - 1 - ); assert_eq!( system .restart_count .load(std::sync::atomic::Ordering::SeqCst), - 0 + 1 ); } diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index b541da19..0a272e7c 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -1,4 +1,5 @@ use clap::Parser; +use serde::Serialize; use skillet_core::credentials::{CredentialInputs, CredentialManager}; use skillet_core::files::{FileResource, LocalFileResource}; use skillet_core::recorder::Recorder; @@ -28,6 +29,16 @@ pub enum CliCommonError { CredentialInstall(#[from] skillet_core::credential_install::CredentialInstallError), #[error("Systemd credential error: {0}")] SystemdCredential(#[from] skillet_core::credentials::CredentialError), + #[error("Configuration apply failed: {apply}; diagnostic recording also failed: {recording}")] + ApplyAndRecord { apply: String, recording: String }, +} + +#[derive(Serialize)] +struct DiagnosticRecording<'a> { + format_version: u32, + host: &'a str, + outcome: &'static str, + operations: Vec, } #[derive(Parser, Debug)] @@ -166,36 +177,13 @@ fn handle_apply_with_credentials( where F: Fn(&dyn SystemResource, &dyn FileResource, &CredentialInputs) -> Result<(), String>, { - use std::io::Write as _; - info!("Starting Skillet configuration for {}...", hostname); let system = LinuxSystemResource::new(); let files = LocalFileResource::new(); if let Some(path) = record_path { - let recorder_system = Recorder::new(system); - let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); - - apply_fn(&recorder_system, &recorder_files, credentials).map_err(CliCommonError::Config)?; - - let ops = recorder_system.get_ops(); - let yaml = serde_yml::to_string(&ops)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let mut temp = tempfile::NamedTempFile::new_in( - path.parent().unwrap_or_else(|| std::path::Path::new(".")), - )?; - temp.write_all(yaml.as_bytes())?; - temp.persist(&path).map_err(|e| { - CliCommonError::Io(std::io::Error::other(format!( - "Failed to persist recording to {}: {}", - path.display(), - e - ))) - })?; - info!("Recording saved to {}", path.display()); + handle_recorded_apply(hostname, &path, system, files, credentials, apply_fn)?; } else { apply_fn(&system, &files, credentials).map_err(CliCommonError::Config)?; } @@ -203,3 +191,71 @@ where info!("Configuration applied successfully."); Ok(()) } + +fn handle_recorded_apply( + hostname: &str, + path: &std::path::Path, + system: S, + files: F, + credentials: &CredentialInputs, + apply_fn: Apply, +) -> Result<(), CliCommonError> +where + S: SystemResource, + F: FileResource, + Apply: Fn(&dyn SystemResource, &dyn FileResource, &CredentialInputs) -> Result<(), String>, +{ + let recorder_system = Recorder::new(system); + let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); + let apply_result = apply_fn(&recorder_system, &recorder_files, credentials); + let diagnostic = DiagnosticRecording { + format_version: 1, + host: hostname, + outcome: if apply_result.is_ok() { + "succeeded" + } else { + "failed" + }, + operations: recorder_system.get_ops(), + }; + let record_result = persist_recording(path, &diagnostic); + if let Err(apply) = apply_result { + if let Err(recording) = record_result { + return Err(CliCommonError::ApplyAndRecord { apply, recording }); + } + return Err(CliCommonError::Config(apply)); + } + record_result.map_err(CliCommonError::Config)?; + info!("Recording saved to {}", path.display()); + Ok(()) +} + +fn persist_recording( + path: &std::path::Path, + diagnostic: &DiagnosticRecording<'_>, +) -> Result<(), String> { + use std::io::Write as _; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")); + fs::create_dir_all(parent).map_err(|error| format!("create {}: {error}", parent.display()))?; + let yaml = serde_yml::to_string(diagnostic).map_err(|error| format!("serialize: {error}"))?; + let mut temp = tempfile::NamedTempFile::new_in(parent) + .map_err(|error| format!("create temporary recording: {error}"))?; + temp.write_all(yaml.as_bytes()) + .map_err(|error| format!("write temporary recording: {error}"))?; + temp.as_file() + .sync_all() + .map_err(|error| format!("sync temporary recording: {error}"))?; + temp.persist(path) + .map_err(|error| format!("persist {}: {}", path.display(), error.error))?; + std::fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("sync recording directory {}: {error}", parent.display()))?; + Ok(()) +} + +#[cfg(test)] +#[path = "recording_tests.rs"] +mod recording_tests; diff --git a/ublue/skillet/crates/cli-common/src/recording_tests.rs b/ublue/skillet/crates/cli-common/src/recording_tests.rs new file mode 100644 index 00000000..96a6321a --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/recording_tests.rs @@ -0,0 +1,78 @@ +use super::*; +use skillet_core::resource_op::{EffectResult, RecordedOperation, ResourceOp}; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +#[test] +fn diagnostic_recording_is_versioned_and_contains_only_sanitized_results() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("nested/apply.yml"); + let diagnostic = DiagnosticRecording { + format_version: 1, + host: "fixture-host", + outcome: "failed", + operations: vec![RecordedOperation { + operation: ResourceOp::EnsurePodmanSecret { + name: "fixture-secret".to_string(), + }, + result: EffectResult::Failed, + }], + }; + persist_recording(&path, &diagnostic).unwrap(); + let output = fs::read_to_string(path).unwrap(); + assert!(output.contains("format_version: 1")); + assert!(output.contains("outcome: failed")); + assert!(output.contains("status: failed")); + assert!(!output.contains("payload")); +} + +#[test] +fn failed_apply_is_recorded_without_secret_value() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("apply.yml"); + let secret = "distinctive-failed-apply-secret"; + let system = MockSystem::new(); + system.fail_restart_once.store(true, Ordering::SeqCst); + let result = handle_recorded_apply( + "fixture-host", + &path, + system, + MockFiles::new(), + &CredentialInputs::default(), + |system, _, _| { + system + .ensure_podman_secret("fixture-secret", secret) + .map_err(|error| error.to_string())?; + system + .service_restart("fixture.service") + .map_err(|error| error.to_string()) + }, + ); + assert!( + matches!(result, Err(CliCommonError::Config(message)) if message.contains("injected restart failure")) + ); + let output = fs::read_to_string(path).unwrap(); + assert!(output.contains("outcome: failed")); + assert!(output.contains("changed: true")); + assert!(output.contains("status: failed")); + assert!(!output.contains(secret)); +} + +#[test] +fn recording_failure_preserves_apply_cause() { + let directory = tempfile::tempdir().unwrap(); + let parent_file = directory.path().join("parent-file"); + fs::write(&parent_file, "block directory creation").unwrap(); + let path = parent_file.join("apply.yml"); + let result = handle_recorded_apply( + "fixture-host", + &path, + MockSystem::new(), + MockFiles::new(), + &CredentialInputs::default(), + |_, _, _| Err("original apply cause".to_string()), + ); + assert!( + matches!(result, Err(CliCommonError::ApplyAndRecord { apply, .. }) if apply == "original apply cause") + ); +} diff --git a/ublue/skillet/crates/core/Cargo.toml b/ublue/skillet/crates/core/Cargo.toml index 801fa94e..b3f17e88 100644 --- a/ublue/skillet/crates/core/Cargo.toml +++ b/ublue/skillet/crates/core/Cargo.toml @@ -20,6 +20,7 @@ askama.workspace = true [dev-dependencies] tempfile.workspace = true +serde_yml.workspace = true [features] test-utils = [] diff --git a/ublue/skillet/crates/core/src/activation.rs b/ublue/skillet/crates/core/src/activation.rs new file mode 100644 index 00000000..cc0fef48 --- /dev/null +++ b/ublue/skillet/crates/core/src/activation.rs @@ -0,0 +1,94 @@ +use crate::{ + files::{FileError, FileMutationResource, FileReadResource}, + system::{ServiceResource, SystemError}, +}; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConsumerKind { + Persistent, + OneShot, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ActivationAction { + None, + Started, + Restarted, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ActivationOutcome { + pub definition_changed: bool, + pub action: ActivationAction, + pub recovered_pending_activation: bool, +} + +pub struct ActivationRequest { + pub service: String, + pub state_path: PathBuf, + pub revision: Vec, + pub definition_changed: bool, + pub reload_daemon: bool, + pub consumer_kind: ConsumerKind, +} + +#[derive(Debug, Error)] +pub enum ActivationError { + #[error("file operation failed: {0}")] + File(#[from] FileError), + #[error("system operation failed: {0}")] + System(#[from] SystemError), +} + +pub fn activate( + system: &S, + files: &F, + request: &ActivationRequest, +) -> Result +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let applied = files.read_file(&request.state_path)?; + let pending = applied.as_deref() != Some(request.revision.as_slice()); + let needs_activation = request.definition_changed || pending; + + if needs_activation && request.reload_daemon { + system.daemon_reload()?; + } + + let active = system.service_is_active(&request.service)?; + let action = match (request.consumer_kind, needs_activation, active) { + (ConsumerKind::Persistent, true, _) | (ConsumerKind::OneShot, true, true) => { + system.service_restart(&request.service)?; + ActivationAction::Restarted + } + (ConsumerKind::Persistent, false, false) | (ConsumerKind::OneShot, true, false) => { + system.service_start(&request.service)?; + ActivationAction::Started + } + _ => ActivationAction::None, + }; + + if needs_activation { + files.ensure_file( + &request.state_path, + &request.revision, + Some(0o644), + Some("root"), + Some("root"), + )?; + } + + Ok(ActivationOutcome { + definition_changed: request.definition_changed, + action, + recovered_pending_activation: pending && !request.definition_changed, + }) +} + +#[cfg(test)] +#[path = "activation/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/activation/tests.rs b/ublue/skillet/crates/core/src/activation/tests.rs new file mode 100644 index 00000000..0ddaf96f --- /dev/null +++ b/ublue/skillet/crates/core/src/activation/tests.rs @@ -0,0 +1,118 @@ +use super::*; +use crate::test_utils::{MockFiles, MockSystem}; +use std::path::Path; + +fn run( + system: &MockSystem, + files: &MockFiles, + request: &ActivationRequest, +) -> Result { + activate(system, files, request) +} + +fn request(revision: &[u8], changed: bool) -> ActivationRequest { + ActivationRequest { + service: "fixture.service".to_string(), + state_path: PathBuf::from("/var/lib/skillet/activation/fixture.applied"), + revision: revision.to_vec(), + definition_changed: changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + } +} + +#[test] +fn tracks_definition_change_then_a_true_noop() { + let system = MockSystem::new(); + let files = MockFiles::new(); + let first = run(&system, &files, &request(b"v1", true)).unwrap(); + assert_eq!(first.action, ActivationAction::Restarted); + let state_path = Path::new("/var/lib/skillet/activation/fixture.applied"); + assert_eq!( + files.read_file(state_path).unwrap().as_deref(), + Some(b"v1".as_slice()) + ); + let repeated = run(&system, &files, &request(b"v1", false)).unwrap(); + assert_eq!(repeated.action, ActivationAction::None); + assert!(!repeated.recovered_pending_activation); +} + +#[test] +fn stopped_persistent_service_is_started_without_definition_changes() { + let system = MockSystem::new(); + let files = MockFiles::new(); + run(&system, &files, &request(b"v1", true)).unwrap(); + system.service_stop("fixture.service").unwrap(); + let outcome = run(&system, &files, &request(b"v1", false)).unwrap(); + assert_eq!(outcome.action, ActivationAction::Started); +} + +#[test] +fn reload_and_restart_failures_leave_revision_pending_for_recovery() { + let system = MockSystem::new(); + let files = MockFiles::new(); + let state_path = Path::new("/var/lib/skillet/activation/fixture.applied"); + system + .fail_reload_once + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(run(&system, &files, &request(b"v1", true)).is_err()); + assert!(files.read_file(state_path).unwrap().is_none()); + run(&system, &files, &request(b"v1", true)).unwrap(); + + system + .fail_restart_once + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(run(&system, &files, &request(b"v2", true)).is_err()); + assert_eq!( + files.read_file(state_path).unwrap().as_deref(), + Some(b"v1".as_slice()) + ); + let retry = run(&system, &files, &request(b"v2", false)).unwrap(); + assert!(retry.recovered_pending_activation); + assert_eq!(retry.action, ActivationAction::Restarted); + assert_eq!( + files.read_file(state_path).unwrap().as_deref(), + Some(b"v2".as_slice()) + ); +} + +#[test] +fn failed_start_does_not_commit_revision_and_retries() { + let system = MockSystem::new(); + let files = MockFiles::new(); + let state_path = Path::new("/var/lib/skillet/activation/fixture.applied"); + let mut first = request(b"v1", true); + first.consumer_kind = ConsumerKind::OneShot; + system + .fail_start_once + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(run(&system, &files, &first).is_err()); + assert!(files.read_file(state_path).unwrap().is_none()); + let mut retry = request(b"v1", false); + retry.consumer_kind = ConsumerKind::OneShot; + let result = run(&system, &files, &retry).unwrap(); + assert!(result.recovered_pending_activation); + assert_eq!(result.action, ActivationAction::Started); + assert_eq!( + files.read_file(state_path).unwrap().as_deref(), + Some(b"v1".as_slice()) + ); +} + +#[test] +fn completed_oneshot_is_not_started_again_when_inactive() { + let system = MockSystem::new(); + let files = MockFiles::new(); + let mut initial = request(b"v1", true); + initial.consumer_kind = ConsumerKind::OneShot; + run(&system, &files, &initial).unwrap(); + system.service_stop("fixture.service").unwrap(); + let mut repeat = request(b"v1", false); + repeat.consumer_kind = ConsumerKind::OneShot; + let outcome = run(&system, &files, &repeat).unwrap(); + assert_eq!(outcome.action, ActivationAction::None); + assert_eq!( + system.start_count.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); +} diff --git a/ublue/skillet/crates/core/src/lib.rs b/ublue/skillet/crates/core/src/lib.rs index ece22a43..e53c4ddd 100644 --- a/ublue/skillet/crates/core/src/lib.rs +++ b/ublue/skillet/crates/core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod activation; pub mod credential_install; pub mod credentials; pub mod files; diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index e2c06708..e739ea7f 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -1,16 +1,15 @@ use crate::files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}; -use crate::resource_op::ResourceOp; +use crate::resource_op::{EffectResult, RecordedOperation, ResourceOp}; use crate::system::{ AccountLookupResource, AccountResource, GroupIdentity, PodmanSecretResource, ServiceResource, SystemError, UserIdentity, }; -use sha2::{Digest, Sha256}; use std::path::Path; use std::sync::{Arc, Mutex}; pub struct Recorder { inner: T, - ops: Arc>>, + ops: Arc>>, } impl Recorder { @@ -21,26 +20,40 @@ impl Recorder { } } - pub fn with_ops(inner: T, ops: Arc>>) -> Self { + pub fn with_ops(inner: T, ops: Arc>>) -> Self { Self { inner, ops } } - pub fn get_ops(&self) -> Vec { + pub fn get_ops(&self) -> Vec { self.ops .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() } - pub fn shared_ops(&self) -> Arc>> { + pub fn shared_ops(&self) -> Arc>> { self.ops.clone() } - fn record(&self, op: ResourceOp) { + fn record(&self, operation: ResourceOp, result: EffectResult) { self.ops .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(op); + .push(RecordedOperation { operation, result }); + } +} + +fn changed(result: &Result) -> EffectResult { + result + .as_ref() + .map_or(EffectResult::Failed, |value| EffectResult::Changed(*value)) +} + +fn succeeded(result: &Result) -> EffectResult { + if result.is_ok() { + EffectResult::Succeeded + } else { + EffectResult::Failed } } @@ -54,16 +67,18 @@ impl StorageResource for Recorder { self.inner .require_btrfs_subvolume_mount(path, backing_mount, subvolume_root) } - fn require_btrfs_subvolume(&self, path: &Path) -> Result<(), FileError> { self.inner.require_btrfs_subvolume(path) } - fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { - self.record(ResourceOp::EnsureBtrfsSubvolume { - path: path.display().to_string(), - }); - self.inner.ensure_btrfs_subvolume(path) + let result = self.inner.ensure_btrfs_subvolume(path); + self.record( + ResourceOp::EnsureBtrfsSubvolume { + path: path.display().to_string(), + }, + changed(&result), + ); + result } } @@ -82,42 +97,46 @@ impl FileMutationResource for Recorder { owner: Option<&str>, group: Option<&str>, ) -> Result { - let mut hasher = Sha256::new(); - hasher.update(content); - let hash = hex::encode(hasher.finalize()); - - self.record(ResourceOp::EnsureFile { - path: path.display().to_string(), - content_hash: hash, - mode: mode.map(|m| format!("0o{m:o}")), - owner: owner.map(ToString::to_string), - group: group.map(ToString::to_string), - }); - - self.inner.ensure_file(path, content, mode, owner, group) + let result = self.inner.ensure_file(path, content, mode, owner, group); + self.record( + ResourceOp::EnsureFile { + path: path.display().to_string(), + mode: mode.map(|m| format!("0o{m:o}")), + owner: owner.map(ToString::to_string), + group: group.map(ToString::to_string), + }, + changed(&result), + ); + result } - fn ensure_directory_with_ownership( &self, path: &Path, mode: Option, ownership: &Ownership, ) -> Result { - self.record(ResourceOp::EnsureDirectory { - path: path.display().to_string(), - mode: mode.map(|m| format!("0o{m:o}")), - ownership: ownership.clone(), - }); - - self.inner - .ensure_directory_with_ownership(path, mode, ownership) + let result = self + .inner + .ensure_directory_with_ownership(path, mode, ownership); + self.record( + ResourceOp::EnsureDirectory { + path: path.display().to_string(), + mode: mode.map(|m| format!("0o{m:o}")), + ownership: ownership.clone(), + }, + changed(&result), + ); + result } - fn delete_file(&self, path: &Path) -> Result { - self.record(ResourceOp::DeleteFile { - path: path.display().to_string(), - }); - self.inner.delete_file(path) + let result = self.inner.delete_file(path); + self.record( + ResourceOp::DeleteFile { + path: path.display().to_string(), + }, + changed(&result), + ); + result } } @@ -125,17 +144,15 @@ impl PodmanSecretResource for Recorder { fn podman_secret_id(&self, name: &str) -> Result { self.inner.podman_secret_id(name) } - fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { - let mut hasher = Sha256::new(); - hasher.update(payload.as_bytes()); - let hash = hex::encode(hasher.finalize()); - - self.record(ResourceOp::EnsurePodmanSecret { - name: name.to_string(), - payload_hash: hash, - }); - self.inner.ensure_podman_secret(name, payload) + let result = self.inner.ensure_podman_secret(name, payload); + self.record( + ResourceOp::EnsurePodmanSecret { + name: name.to_string(), + }, + changed(&result), + ); + result } } @@ -143,45 +160,60 @@ impl ServiceResource for Recorder { fn service_is_active(&self, name: &str) -> Result { self.inner.service_is_active(name) } - fn service_start(&self, name: &str) -> Result<(), SystemError> { - self.record(ResourceOp::ServiceStart { - name: name.to_string(), - }); - self.inner.service_start(name) + let result = self.inner.service_start(name); + self.record( + ResourceOp::ServiceStart { + name: name.to_string(), + }, + succeeded(&result), + ); + result } - fn service_stop(&self, name: &str) -> Result<(), SystemError> { - self.record(ResourceOp::ServiceStop { - name: name.to_string(), - }); - self.inner.service_stop(name) + let result = self.inner.service_stop(name); + self.record( + ResourceOp::ServiceStop { + name: name.to_string(), + }, + succeeded(&result), + ); + result } - fn service_restart(&self, name: &str) -> Result<(), SystemError> { - self.record(ResourceOp::ServiceRestart { - name: name.to_string(), - }); - self.inner.service_restart(name) + let result = self.inner.service_restart(name); + self.record( + ResourceOp::ServiceRestart { + name: name.to_string(), + }, + succeeded(&result), + ); + result } - fn service_reload(&self, name: &str) -> Result<(), SystemError> { - self.record(ResourceOp::ServiceReload { - name: name.to_string(), - }); - self.inner.service_reload(name) + let result = self.inner.service_reload(name); + self.record( + ResourceOp::ServiceReload { + name: name.to_string(), + }, + succeeded(&result), + ); + result } - fn service_enable(&self, name: &str) -> Result<(), SystemError> { - self.record(ResourceOp::ServiceEnable { - name: name.to_string(), - }); - self.inner.service_enable(name) + let result = self.inner.service_enable(name); + self.record( + ResourceOp::ServiceEnable { + name: name.to_string(), + }, + succeeded(&result), + ); + result } - fn daemon_reload(&self) -> Result<(), SystemError> { - self.record(ResourceOp::DaemonReload); - self.inner.daemon_reload() + let result = self.inner.daemon_reload(); + self.record(ResourceOp::DaemonReload, succeeded(&result)); + result } } @@ -189,11 +221,9 @@ impl AccountLookupResource for Recorder { fn user_by_name(&self, name: &str) -> Result, SystemError> { self.inner.user_by_name(name) } - fn user_by_uid(&self, uid: u32) -> Result, SystemError> { self.inner.user_by_uid(uid) } - fn group_by_name(&self, name: &str) -> Result, SystemError> { self.inner.group_by_name(name) } @@ -201,23 +231,35 @@ impl AccountLookupResource for Recorder { impl AccountResource for Recorder { fn ensure_group(&self, name: &str, gid: Option) -> Result { - self.record(ResourceOp::EnsureGroup { - name: name.to_string(), - }); - self.inner.ensure_group(name, gid) + let result = self.inner.ensure_group(name, gid); + self.record( + ResourceOp::EnsureGroup { + name: name.to_string(), + gid, + }, + changed(&result), + ); + result } - fn ensure_user( &self, name: &str, uid: Option, gid: Option, ) -> Result { - self.record(ResourceOp::EnsureUser { - name: name.to_string(), - uid, - gid, - }); - self.inner.ensure_user(name, uid, gid) + let result = self.inner.ensure_user(name, uid, gid); + self.record( + ResourceOp::EnsureUser { + name: name.to_string(), + uid, + gid, + }, + changed(&result), + ); + result } } + +#[cfg(test)] +#[path = "recorder_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/recorder_tests.rs b/ublue/skillet/crates/core/src/recorder_tests.rs new file mode 100644 index 00000000..6de016a4 --- /dev/null +++ b/ublue/skillet/crates/core/src/recorder_tests.rs @@ -0,0 +1,27 @@ +use super::*; +use crate::resource_op::EffectResult; +use crate::test_utils::MockSystem; +use sha2::Digest as _; +use std::sync::atomic::Ordering; + +#[test] +fn records_results_without_payloads_or_fingerprints() { + let system = MockSystem::new(); + let recorder = Recorder::new(system); + let secret = "distinctive-private-payload-for-recording-test"; + assert!(recorder + .ensure_podman_secret("fixture-secret", secret) + .unwrap()); + recorder + .inner + .fail_restart_once + .store(true, Ordering::SeqCst); + assert!(recorder.service_restart("fixture.service").is_err()); + + let operations = recorder.get_ops(); + assert_eq!(operations[0].result, EffectResult::Changed(true)); + assert_eq!(operations[1].result, EffectResult::Failed); + let yaml = serde_yml::to_string(&operations).unwrap(); + assert!(!yaml.contains(secret)); + assert!(!yaml.contains(&hex::encode(sha2::Sha256::digest(secret.as_bytes())))); +} diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index b13b7255..d5ac3ab4 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize}; pub enum ResourceOp { EnsureFile { path: String, - content_hash: String, mode: Option, owner: Option, group: Option, @@ -24,6 +23,7 @@ pub enum ResourceOp { }, EnsureGroup { name: String, + gid: Option, }, EnsureUser { name: String, @@ -32,7 +32,6 @@ pub enum ResourceOp { }, EnsurePodmanSecret { name: String, - payload_hash: String, }, ServiceStart { name: String, @@ -51,3 +50,17 @@ pub enum ResourceOp { }, DaemonReload, } + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +#[serde(tag = "status", content = "changed", rename_all = "snake_case")] +pub enum EffectResult { + Changed(bool), + Succeeded, + Failed, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct RecordedOperation { + pub operation: ResourceOp, + pub result: EffectResult, +} diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 450b44bf..76908a02 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -17,6 +17,7 @@ pub struct MockSystem { pub podman_secrets: Arc>>, pub secret_ids: Arc>>, pub fail_restart_once: Arc, + pub fail_start_once: Arc, pub fail_reload_once: Arc, pub restart_count: Arc, pub start_count: Arc, @@ -33,6 +34,7 @@ impl MockSystem { podman_secrets: Arc::new(Mutex::new(HashSet::new())), secret_ids: Arc::new(Mutex::new(HashMap::new())), fail_restart_once: Arc::new(AtomicBool::new(false)), + fail_start_once: Arc::new(AtomicBool::new(false)), fail_reload_once: Arc::new(AtomicBool::new(false)), restart_count: Arc::new(AtomicUsize::new(0)), start_count: Arc::new(AtomicUsize::new(0)), @@ -194,6 +196,9 @@ impl ServiceResource for MockSystem { fn service_start(&self, name: &str) -> Result<(), SystemError> { self.start_count.fetch_add(1, Ordering::SeqCst); + if self.fail_start_once.swap(false, Ordering::SeqCst) { + return Err(SystemError::Command("injected start failure".to_string())); + } self.services .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index c3d4d8a6..5b47ac47 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -1,4 +1,5 @@ use sha2::{Digest, Sha256}; +use skillet_core::activation::{self, ActivationRequest, ConsumerKind}; use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; use skillet_core::system::{ServiceResource, SystemError}; use std::path::Path; @@ -11,6 +12,8 @@ pub enum HardeningError { System(#[from] SystemError), #[error("File error: {0}")] File(#[from] FileError), + #[error("Activation error: {0}")] + Activation(#[from] skillet_core::activation::ActivationError), } pub fn apply(system: &S, files: &F) -> Result<(), HardeningError> @@ -46,7 +49,6 @@ fn converge_service_file( content: &[u8], mode: u32, service: &str, - ensure_active: bool, ) -> Result<(), HardeningError> where S: ServiceResource + ?Sized, @@ -56,20 +58,19 @@ where files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; let applied_path = state_dir.join(format!("{service}.applied")); let revision = hex::encode(Sha256::digest(content)); - let pending = files.read_file(&applied_path)?.as_deref() != Some(revision.as_bytes()); let changed = files.ensure_file(path, content, Some(mode), Some("root"), Some("root"))?; - if changed || pending { - system.service_restart(service)?; - files.ensure_file( - &applied_path, - revision.as_bytes(), - Some(0o644), - Some("root"), - Some("root"), - )?; - } else if ensure_active && !system.service_is_active(service)? { - system.service_start(service)?; - } + activation::activate( + system, + files, + &ActivationRequest { + service: service.to_string(), + state_path: applied_path, + revision: revision.into_bytes(), + definition_changed: changed, + reload_daemon: false, + consumer_kind: ConsumerKind::Persistent, + }, + )?; Ok(()) } @@ -85,15 +86,7 @@ where let content = include_bytes!("../files/sysctl.boxy.conf"); let path = sysctl_dir.join("99-hardening.conf"); - converge_service_file( - system, - files, - &path, - content, - 0o644, - "systemd-sysctl", - false, - )?; + converge_service_file(system, files, &path, content, 0o644, "systemd-sysctl")?; Ok(()) } @@ -111,7 +104,7 @@ where let content = include_bytes!("../files/sshd_config"); let path = Path::new("/etc/ssh/sshd_config"); - converge_service_file(system, files, path, content, 0o600, "sshd", true)?; + converge_service_file(system, files, path, content, 0o600, "sshd")?; Ok(()) } diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 28318c71..e4fbce07 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -1,5 +1,6 @@ use askama::Template; use sha2::{Digest, Sha256}; +use skillet_core::activation::{self, ActivationOutcome, ActivationRequest, ConsumerKind}; use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; use skillet_core::system::{ AccountLookupResource, PodmanSecretResource, ServiceResource, SystemError, @@ -20,6 +21,8 @@ pub enum PodmanError { System(#[from] SystemError), #[error("File error: {0}")] File(#[from] FileError), + #[error("Activation error: {0}")] + Activation(#[from] skillet_core::activation::ActivationError), #[error("User mapping error: {0}")] UserMapping(String), #[error("Invalid Podman network unit name: {0}")] @@ -294,7 +297,11 @@ where } #[allow(clippy::similar_names)] -pub fn container(system: &S, files: &F, config: PodmanConfig) -> Result +pub fn container( + system: &S, + files: &F, + config: PodmanConfig, +) -> Result where S: AccountLookupResource + PodmanSecretResource + ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, @@ -751,7 +758,7 @@ fn render_and_ensure_quadlet( sections: BTreeMap>, secret_ids: &[String], config_revisions: &[Vec], -) -> Result +) -> Result where S: ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, @@ -777,8 +784,6 @@ where let state_dir = Path::new("/var/lib/skillet/containers"); files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; let applied_path = state_dir.join(format!("{name}.applied")); - let applied = files.read_file(&applied_path)?; - let pending = applied.as_deref() != Some(revision.as_bytes()); let quadlet_dir = Path::new("/etc/containers/systemd"); files.ensure_directory(quadlet_dir, Some(0o755), Some("root"), Some("root"))?; @@ -792,24 +797,19 @@ where Some("root"), )?; - if changed || pending { - info!("Quadlet activation pending, triggering daemon-reload"); - system.daemon_reload()?; - info!("Restarting {name} to consume the desired definition and secrets"); - system.service_restart(name)?; - files.ensure_file( - &applied_path, - revision.as_bytes(), - Some(0o644), - Some("root"), - Some("root"), - )?; - } else if !system.service_is_active(name)? { - info!("Starting inactive {name}"); - system.service_start(name)?; - } - - Ok(changed) + let outcome = activation::activate( + system, + files, + &ActivationRequest { + service: name.to_string(), + state_path: applied_path, + revision: revision.into_bytes(), + definition_changed: changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + Ok(outcome) } #[cfg(test)] diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index 5be5bf0f..6ce5c446 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -1,4 +1,5 @@ use super::*; +use skillet_core::activation::ActivationAction; use skillet_core::test_utils::{MockFiles, MockSystem}; use std::sync::atomic::Ordering; @@ -230,11 +231,20 @@ fn repeat_apply_and_stopped_service() { let system = MockSystem::new(); let files = MockFiles::new(); system.ensure_podman_secret("dummy", "first").unwrap(); - assert!(container(&system, &files, fixture()).unwrap()); - assert!(!container(&system, &files, fixture()).unwrap()); + assert_eq!( + container(&system, &files, fixture()).unwrap().action, + ActivationAction::Restarted + ); + assert_eq!( + container(&system, &files, fixture()).unwrap().action, + ActivationAction::None + ); assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); system.service_stop("unit-fixture").unwrap(); - assert!(!container(&system, &files, fixture()).unwrap()); + assert_eq!( + container(&system, &files, fixture()).unwrap().action, + ActivationAction::Started + ); assert_eq!(system.start_count.load(Ordering::SeqCst), 1); assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); let quadlet = String::from_utf8( @@ -332,7 +342,9 @@ fn consumed_file_change_restarts_even_when_quadlet_is_unchanged() { container(&system, &files, first).unwrap(); let mut second = fixture(); second.config_revisions = vec![b"two".to_vec()]; - assert!(!container(&system, &files, second).unwrap()); + let changed = container(&system, &files, second).unwrap(); + assert!(!changed.definition_changed); + assert_eq!(changed.action, ActivationAction::Restarted); assert_eq!(system.restart_count.load(Ordering::SeqCst), 2); let mut repeat = fixture(); repeat.config_revisions = vec![b"two".to_vec()]; From 25cf3a7cd832591807bd4a766d7681f1d33798fe Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 02:50:16 +0200 Subject: [PATCH 157/252] test(skillet): isolate smoke fixture and verify artifacts --- .github/workflows/ci.yml | 24 ++-- ublue/AGENTS.md | 7 +- ublue/butane/ACCEPTANCE.md | 24 ++++ ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md | 32 ++++- ublue/plan/SKILLET-REFACTOR.md | 4 +- ublue/skillet/AGENTS.md | 2 +- ublue/skillet/Cargo.lock | 11 ++ ublue/skillet/Cargo.toml | 1 + ublue/skillet/README.md | 19 ++- ublue/skillet/crates/cli/src/main.rs | 122 +++++++++++++++--- .../crates/cli/src/smoke_artifact_tests.rs | 17 +++ .../skillet/crates/cli/src/test_entrypoint.sh | 4 +- ublue/skillet/crates/test-fixture/Cargo.toml | 17 +++ .../src/lib.rs} | 14 +- ublue/skillet/crates/test-fixture/src/main.rs | 9 ++ .../src/tests.rs} | 12 +- .../integration_tests/fixture-entrypoint.sh | 5 + .../skillet/integration_tests/smoke-guest.sh | 2 +- ublue/skillet/integration_tests/smoke-ssh.sh | 36 +++++- 19 files changed, 297 insertions(+), 65 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/smoke_artifact_tests.rs create mode 100644 ublue/skillet/crates/test-fixture/Cargo.toml rename ublue/skillet/crates/{cli/src/test_fixture.rs => test-fixture/src/lib.rs} (89%) create mode 100644 ublue/skillet/crates/test-fixture/src/main.rs rename ublue/skillet/crates/{cli/src/test_fixture_tests.rs => test-fixture/src/tests.rs} (78%) create mode 100644 ublue/skillet/integration_tests/fixture-entrypoint.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42c5355a..a00884d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,8 +47,12 @@ jobs: - uses: actions/checkout@v4 - name: Run ShellCheck run: | - find . -name "*.sh" -not -path "./.venv/*" -not -path "./berks-cookbooks/*" -exec shellcheck -x {} + - shellcheck -x run + find . -type f \ + \( -name '*.sh' -o -name run -o -name butane -o -name flatpak-virt \ + -o -name test-vm -o -name test-vm-ready -o -name virsh -o -name virt-install \ + -o -name setup-llama-server -o -name update-llama \) \ + -not -path './.git/*' -not -path './.venv/*' -not -path './berks-cookbooks/*' \ + -not -path '*/target/*' -exec shellcheck -x {} + skillet-checks: runs-on: ubuntu-latest @@ -60,6 +64,9 @@ jobs: filters: | rust: - 'ublue/skillet/**' + - 'ublue/butane/**' + - 'ublue/AGENTS.md' + - '.github/workflows/ci.yml' - name: Set up Rust if: steps.filter.outputs.rust == 'true' @@ -76,17 +83,18 @@ jobs: - name: Run Clippy if: steps.filter.outputs.rust == 'true' - run: cd ublue/skillet && cargo clippy -- -D warnings + run: cd ublue/skillet && cargo clippy --workspace --all-targets -- -D warnings + + - name: Check formatting + if: steps.filter.outputs.rust == 'true' + run: cd ublue/skillet && cargo fmt --all -- --check - name: Run Unit Tests if: steps.filter.outputs.rust == 'true' - run: cd ublue/skillet && cargo test + run: cd ublue/skillet && cargo test --workspace --all-targets - name: Run Integration Tests if: steps.filter.outputs.rust == 'true' run: | cd ublue/skillet - # Build binary explicitly for the test runner to find it - # (default target is x86_64-unknown-linux-musl, see .cargo/config.toml) - cargo build - ./target/x86_64-unknown-linux-musl/debug/skillet test run beezelbot --phase base --image fedora:latest + cargo run --bin skillet -- test run beezelbot --phase base --image fedora:latest diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 01c236e0..83a0fc5a 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -60,8 +60,11 @@ ## Local validation before commit - Before committing, run the CI checks that apply to the changed paths locally - and resolve failures first. For Skillet changes, run `cargo fmt --check`, - `cargo clippy -- -D warnings`, `cargo test`, and the CI integration command + and resolve failures first. For Skillet changes, run + `cargo fmt --all -- --check`, + `cargo clippy --workspace --all-targets -- -D warnings`, + `cargo test --workspace --all-targets`, and + `cargo run --bin skillet -- test run beezelbot --phase base --image fedora:latest` from `.github/workflows/ci.yml`. For shell changes, run the workflow's ShellCheck commands; for Python or Chef changes, run the corresponding Ruff/Mypy or Cookstyle commands. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 4b5811e5..db6c663d 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1063,3 +1063,27 @@ host tool; it still uses normal command approvals. disposable container was removed. - Live mount/boot-order checks need a named VM and remain unverified because `/dev/kvm` is unavailable here. + +### Shared activation, sanitized recording, and test fixture split, 2026-10-04 + +- Added shared activation/revision handling to hardening, Podman containers, + and the btrbk timer. Unit tests verify changed definitions, stopped services, + no-ops, oneshot semantics, and recovery after failed reload/start/restart. +- `apply --record` now writes versioned YAML atomically after both successful + and failed applies. Records include operation outcomes, omit content and + secret fingerprints, and do not retain raw effect error messages. Tests cover + failed applies and combined apply/record persistence failure. +- Extracted `skillet-smoke-fixture` from production host/CLI dispatch and moved + its guest entrypoint to a standalone shell file. Cargo-reported artifact + selection follows the invoked Skillet target/profile. The smoke runner + verifies the fixture transfer and the installed generic/host binaries against + the expected recorded hashes. CI includes Butane and helper paths, all-target + formatting/Clippy/tests, extensionless shell helpers, and the Fedora + container integration command. +- Local full workspace tests, strict all-target Clippy, formatting, ShellCheck, + and the CI container integration passed. The beezelbot base phase applied + twice; the repeat issued no service start/restart and the disposable container + was removed. The container runtime needed SIGKILL after its 10-second stop + timeout during cleanup; this did not fail the test. +- Fresh VM activation/reboot, host-profile acceptance, and retained-run recovery + are unverified because this workstation has no `/dev/kvm`. diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md index c7a9c142..66c8ff5c 100644 --- a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -1,8 +1,7 @@ # 7. Test layers, fixtures, build artifacts and CI -Status: planned; final workstream in [the prerequisite roadmap](SKILLET-REFACTOR.md). -Earlier slices still validate their changes as they land. Further feature -milestones wait for the full roadmap's completion. +Status: implementation complete; fresh-VM acceptance pending. Earlier slices +remain gated by [the prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate @@ -13,10 +12,29 @@ milestones wait for the full roadmap's completion. - `../../.github/workflows/ci.yml`, `../butane/tests/bootstrap.sh`, both READMEs, `../design/smoke-vms.md` and `../butane/ACCEPTANCE.md`. -Current defects: fixture smoke, host acceptance and mocked container checks -are conflated; target/artifact selection assumes default paths/instances; fakes -disagree with adapter behavior; extensionless helpers and Butane-only changes -miss relevant CI. A shell program remains embedded in a production Rust fixture. +Original defects: fixture smoke, host acceptance and mocked container checks +were conflated; target/artifact selection assumed default paths/instances; fakes +disagreed with adapter behavior; extensionless helpers and Butane-only changes +missed relevant CI. Shell code was embedded in a production Rust fixture. + +## Progress, 2026-10-04 + +- Moved the synthetic application out of the production CLI module into the + dedicated `skillet-smoke-fixture` package and binary. Its guest entrypoint is + a standalone `integration_tests/fixture-entrypoint.sh` file. The hidden + production CLI fixture command was removed; the guest SSH smoke installs the + fixture binary explicitly. +- Smoke derives Cargo target/profile from the invoked executable, builds the + fixture through Cargo JSON artifact reporting, and refuses an unreported or + missing executable. It verifies the fixture transfer and both installed + guest binaries against SHA-256 values recorded in the VM manifest. A unit + test covers custom target and release/debug selection. Existing VM identity, + instance, manifest SSH target, and port remain separately validated. +- CI now selects Rust validation when Skillet, Butane, its helpers, or the + workflow changes; it runs workspace formatting, all-target strict Clippy, + all-target tests, and the named Fedora container integration command. Shell + lint explicitly includes current extensionless scripts. Local commands pass; + `/dev/kvm` is absent, so fresh-VM acceptance is unverified. ## Implementation sequence diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 61d52f47..0dc2f14b 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -25,8 +25,8 @@ assignments to execute concurrently against the same interfaces. | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; live acceptance pending | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | In progress | -| 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Planned | -| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Planned | +| 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Implemented; VM acceptance pending | +| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Implemented; VM acceptance pending | Each earlier slice includes its own regression checks; testing does not wait for workstream 7. Workstream 1 creates the minimal VM/transport contracts used diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index e4eccbe3..687a5440 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -27,7 +27,7 @@ This document defines the architectural mandates and project structure for `skil ### 4. Quality Control & Validation - **Formatting & Linting**: Always run `cargo fmt` and `cargo clippy` after making changes to ensure code quality and consistency. **Clippy MUST be run with `pedantic` lints enabled (configured in `Cargo.toml`).** - **Verification**: Always run both: - - **Unit Tests**: `cargo test` across the workspace. + - **Unit Tests**: `cargo test --workspace --all-targets`. - **Runtime Smoke**: Run `integration_tests/smoke-ssh.sh` against an explicitly named disposable VM with real systemd and Podman for affected container resources. Record the guest state snapshots and failure diagnostics. ## Local musl toolchain diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 255eeadf..f9a3d56e 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2320,6 +2320,17 @@ dependencies = [ "skillet_podman", ] +[[package]] +name = "skillet-smoke-fixture" +version = "0.1.0" +dependencies = [ + "anyhow", + "skillet_cli_common", + "skillet_core", + "skillet_podman", + "thiserror 1.0.69", +] + [[package]] name = "skillet_btrbk" version = "0.1.0" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index c4751303..6f6bc53d 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/hosts/beezelbot", "crates/hosts/clamps", "crates/cli-common", + "crates/test-fixture", "crates/vm", "crates/host-profiles", "crates/workstation", diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 8f20e3aa..4f2fb43e 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -73,8 +73,10 @@ cargo run --release -p skillet -- test smoke clamps The smoke command defaults to that target, port, and generated key. Override them with `--target`, `--port`, and `--identity` when using a separately -provisioned VM. It takes the generic binary from the running executable and -uses the `skillet-clamps` binary installed by VM creation. Shared systemd +provisioned VM. It builds a separate `skillet-smoke-fixture` executable from +Cargo's reported artifact for the running Skillet target and profile. Before +running it, the guest verifies the fixture transfer and confirms both installed +Skillet binaries match the ready VM manifest. Shared systemd apply units use the generic binary and the stable host profile in `/etc/skillet/host`. Repeated smoke runs reset only the namespaced `/var/lib/skillet-smoke` fixture and its managed @@ -301,6 +303,19 @@ permissions. Live ACME and HTTPS acceptance remains pending. See the ## Development checks +- Workspace checks: `cargo fmt --all -- --check`, + `cargo clippy --workspace --all-targets -- -D warnings`, and + `cargo test --workspace --all-targets`. +- Container integration: `cargo run --bin skillet -- test run beezelbot + --phase base --image fedora:latest` (also run for `clamps` after changing + shared container behavior). +- ShellCheck covers `*.sh` plus the extensionless scripts named in the CI + workflow, including VM and workstation helpers. +- `apply --record PATH` writes version 1 YAML with host, overall result, and + per-operation outcomes. It stores no content, hashes, secret values, or raw + effect errors, and is written after failed applies as well. Older unversioned + operation arrays remain diagnostic artifacts and are never loaded as state. + - **Error Handling**: Use `thiserror` in library crates; `anyhow` is reserved for CLI binaries. No `unwrap()` or `expect()` in library code. - **Idempotency**: All modules must ensure system state idempotently. - **System Interactions**: Prioritize Rust crates (e.g., `zbus`, `users`) over shelling out to system commands. diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index f64c561a..8964acc8 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -6,7 +6,6 @@ use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; mod secret_delivery; -mod test_fixture; mod vm; #[derive(Parser, Debug)] @@ -102,9 +101,6 @@ enum TestCommands { Run(ContainerArgs), /// Exercise the real-systemd VM scenario using an explicit disposable SSH target Smoke(SmokeArgs), - /// Apply the disposable fixture used by the VM acceptance script - #[command(hide = true)] - FixtureApply, /// Create or destroy a disposable host VM Vm { #[command(subcommand)] @@ -222,6 +218,18 @@ struct SmokeArgs { identity: Option, } +struct SmokeInvocation<'a> { + target: &'a str, + port: u16, + host: &'a str, + credentials_required: bool, + fixture_binary: &'a std::path::Path, + fixture_sha256: &'a str, + host_binary: String, + identity: &'a std::path::Path, + deployed_hashes: &'a skillet_vm::manifest::ArtifactHashes, +} + fn main() -> Result<()> { let args = Args::parse(); let subscriber = FmtSubscriber::builder() @@ -279,12 +287,6 @@ fn main() -> Result<()> { Commands::Test { command: TestCommands::Smoke(args), } => run_smoke(&args)?, - Commands::Test { - command: TestCommands::FixtureApply, - } => skillet_cli_common::handle_apply("smoke fixture", None, |system, files| { - test_fixture::apply(system, files).map_err(|error| error.to_string()) - }) - .map_err(|error| anyhow!("Failed to apply smoke fixture: {error}"))?, Commands::Test { command: TestCommands::Vm { command }, } => run_vm_command(command)?, @@ -330,6 +332,9 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { args.instance )); } + let deployed_hashes = run.deployed.as_ref().ok_or_else(|| { + anyhow!("ready VM has no recorded deployed artifact hashes; rerun `test vm ready`") + })?; let target = format!("{}@{}", run.ssh.user, run.ssh.address); if args .target @@ -370,25 +375,64 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { let host_binary = format!("/var/usrlocal/bin/skillet-{}", run.identity.host()); let credentials_required = profile.requires_full_apply_credentials(); let binary = std::env::current_exe().context("locating the running Skillet binary failed")?; + let (fixture_artifact, fixture_hash, cargo_target, cargo_profile) = + build_smoke_fixture(&root, &binary)?; + info!( + target = cargo_target, + profile = cargo_profile, + generic_sha256 = deployed_hashes.generic, + host_sha256 = deployed_hashes.host, + fixture_sha256 = fixture_hash, + "Resolved smoke binaries" + ); let script = root.join("integration_tests/smoke-ssh.sh"); if !script.is_file() { return Err(anyhow!("smoke runner not found at {}", script.display())); } + run_smoke_runner( + &script, + SmokeInvocation { + target: &target, + port: run.ssh.port, + host: run.identity.host(), + credentials_required, + fixture_binary: &fixture_artifact, + fixture_sha256: &fixture_hash, + host_binary, + identity: &identity, + deployed_hashes, + }, + ) +} + +fn run_smoke_runner(script: &std::path::Path, invocation: SmokeInvocation<'_>) -> Result<()> { let status = std::process::Command::new("bash") .arg(script) - .args(["--target", &target, "--disposable-target", "--port"]) - .arg(run.ssh.port.to_string()) - .args(["--host", run.identity.host()]) + .args([ + "--target", + invocation.target, + "--disposable-target", + "--port", + ]) + .arg(invocation.port.to_string()) + .args(["--host", invocation.host]) .args([ "--credentials-required", - if credentials_required { "yes" } else { "no" }, + if invocation.credentials_required { + "yes" + } else { + "no" + }, ]) - .args(["--binary"]) - .arg(&binary) + .args(["--fixture-binary"]) + .arg(invocation.fixture_binary) + .args(["--fixture-sha256", invocation.fixture_sha256]) + .args(["--generic-sha256", &invocation.deployed_hashes.generic]) + .args(["--host-sha256", &invocation.deployed_hashes.host]) .args(["--host-binary"]) - .arg(host_binary) + .arg(invocation.host_binary) .args(["--identity"]) - .arg(&identity) + .arg(invocation.identity) .status() .context("starting disposable-VM smoke runner failed")?; if !status.success() { @@ -399,6 +443,44 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { Ok(()) } +fn artifact_target_profile(binary: &std::path::Path) -> Result<(&str, &str)> { + let profile_directory = binary + .parent() + .ok_or_else(|| anyhow!("running binary has no parent directory"))?; + let profile_name = profile_directory + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| anyhow!("cannot infer Cargo profile from running binary path"))?; + let profile = if profile_name == "debug" { + "dev" + } else { + profile_name + }; + let target = profile_directory + .parent() + .and_then(|path| path.file_name()) + .and_then(|value| value.to_str()) + .ok_or_else(|| anyhow!("cannot infer Cargo target from running binary path"))?; + Ok((target, profile)) +} + +fn build_smoke_fixture<'a>( + workspace: &std::path::Path, + running_binary: &'a std::path::Path, +) -> Result<(PathBuf, String, &'a str, &'a str)> { + let (target, profile) = artifact_target_profile(running_binary)?; + let binary = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace, + packages: &["skillet-smoke-fixture"], + target, + profile, + })? + .remove("skillet-smoke-fixture") + .ok_or_else(|| anyhow!("Cargo did not report the smoke fixture executable"))?; + let hash = skillet_vm::delivery::sha256(&binary)?; + Ok((binary, hash, target, profile)) +} + fn run_vm_create(args: &VmCreateArgs) -> Result<()> { vm::create(args) } @@ -650,3 +732,7 @@ mod tests { assert!(parsed.is_ok()); } } + +#[cfg(test)] +#[path = "smoke_artifact_tests.rs"] +mod smoke_artifact_tests; diff --git a/ublue/skillet/crates/cli/src/smoke_artifact_tests.rs b/ublue/skillet/crates/cli/src/smoke_artifact_tests.rs new file mode 100644 index 00000000..679b28cf --- /dev/null +++ b/ublue/skillet/crates/cli/src/smoke_artifact_tests.rs @@ -0,0 +1,17 @@ +use super::artifact_target_profile; + +#[test] +fn smoke_artifact_selection_preserves_target_and_profile() { + let (target, profile) = artifact_target_profile(std::path::Path::new( + "/tmp/cargo-target/aarch64-unknown-linux-musl/release/skillet", + )) + .unwrap(); + assert_eq!(target, "aarch64-unknown-linux-musl"); + assert_eq!(profile, "release"); + let (target, profile) = artifact_target_profile(std::path::Path::new( + "/tmp/cargo-target/x86_64-unknown-linux-musl/debug/skillet", + )) + .unwrap(); + assert_eq!(target, "x86_64-unknown-linux-musl"); + assert_eq!(profile, "dev"); +} diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh index 8c8801ac..e7905565 100644 --- a/ublue/skillet/crates/cli/src/test_entrypoint.sh +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -11,7 +11,9 @@ if [ ! -x "$mock/systemctl" ]; then set -eu printf '%s\n' "$*" >> /tmp/skillet-test.systemctl.log case "$1" in - is-active) [ -f "/tmp/skillet-test-state/active-$3" ] ;; + is-active) + if [ -f "/tmp/skillet-test-state/active-$3" ]; then exit 0; else exit 3; fi + ;; start|restart) touch "/tmp/skillet-test-state/active-$2" ;; enable|daemon-reload|stop|reload) exit 0 ;; *) exit 2 ;; diff --git a/ublue/skillet/crates/test-fixture/Cargo.toml b/ublue/skillet/crates/test-fixture/Cargo.toml new file mode 100644 index 00000000..3db7b7ce --- /dev/null +++ b/ublue/skillet/crates/test-fixture/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skillet-smoke-fixture" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +skillet_cli_common.workspace = true +thiserror.workspace = true +anyhow = "1.0" + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/cli/src/test_fixture.rs b/ublue/skillet/crates/test-fixture/src/lib.rs similarity index 89% rename from ublue/skillet/crates/cli/src/test_fixture.rs rename to ublue/skillet/crates/test-fixture/src/lib.rs index 2bf7af31..016dfd3f 100644 --- a/ublue/skillet/crates/cli/src/test_fixture.rs +++ b/ublue/skillet/crates/test-fixture/src/lib.rs @@ -1,4 +1,4 @@ -//! Disposable integration-test fixture; it is not a host profile or apply path. +//! Disposable application used only by the real-runtime acceptance fixture. use skillet_core::{ files::{FileError, FileResource}, @@ -13,10 +13,10 @@ use thiserror::Error; const INPUT_DIR: &str = "/var/lib/skillet-smoke/desired"; const CONFIG_PATH: &str = "/etc/skillet-smoke/config"; const SECRET_NAME: &str = "skillet-smoke-dummy"; -const ENTRYPOINT: &[u8] = b"#!/bin/sh\nset -eu\ncp /fixture/config /data/observed-config\nsha256sum /run/secrets/skillet-smoke-dummy | cut -d' ' -f1 > /data/observed-secret-sha\nwhile :; do sleep 3600; done\n"; +const ENTRYPOINT: &[u8] = include_bytes!("../../../integration_tests/fixture-entrypoint.sh"); #[derive(Debug, Error)] -pub(super) enum FixtureError { +pub enum FixtureError { #[error("System error: {0}")] System(#[from] SystemError), #[error("File error: {0}")] @@ -27,10 +27,7 @@ pub(super) enum FixtureError { Input(String), } -pub(super) fn apply( - system: &dyn SystemResource, - files: &dyn FileResource, -) -> Result<(), FixtureError> { +pub fn apply(system: &dyn SystemResource, files: &dyn FileResource) -> Result<(), FixtureError> { let config = files .read_file(&Path::new(INPUT_DIR).join("config"))? .ok_or_else(|| FixtureError::Input("missing desired config".to_string()))?; @@ -39,7 +36,6 @@ pub(super) fn apply( .ok_or_else(|| FixtureError::Input("missing desired dummy secret".to_string()))?; let secret = String::from_utf8(secret) .map_err(|_| FixtureError::Input("dummy secret must be UTF-8".to_string()))?; - files.ensure_directory( Path::new("/etc/skillet-smoke"), Some(0o755), @@ -128,5 +124,5 @@ pub(super) fn apply( } #[cfg(test)] -#[path = "test_fixture_tests.rs"] +#[path = "tests.rs"] mod tests; diff --git a/ublue/skillet/crates/test-fixture/src/main.rs b/ublue/skillet/crates/test-fixture/src/main.rs new file mode 100644 index 00000000..33db9092 --- /dev/null +++ b/ublue/skillet/crates/test-fixture/src/main.rs @@ -0,0 +1,9 @@ +use anyhow::Context as _; + +fn main() -> anyhow::Result<()> { + skillet_cli_common::handle_apply("smoke fixture", None, |system, files| { + skillet_smoke_fixture::apply(system, files).map_err(|error| error.to_string()) + }) + .context("applying smoke fixture failed")?; + Ok(()) +} diff --git a/ublue/skillet/crates/cli/src/test_fixture_tests.rs b/ublue/skillet/crates/test-fixture/src/tests.rs similarity index 78% rename from ublue/skillet/crates/cli/src/test_fixture_tests.rs rename to ublue/skillet/crates/test-fixture/src/tests.rs index 62e17a7f..cd33884e 100644 --- a/ublue/skillet/crates/cli/src/test_fixture_tests.rs +++ b/ublue/skillet/crates/test-fixture/src/tests.rs @@ -10,14 +10,13 @@ fn fixture_requires_explicit_inputs() { } #[test] -fn fixture_uses_host_container_resources() { +fn fixture_uses_shared_host_container_resources() { let system = MockSystem::new(); let files = MockFiles::new(); - files - .files - .lock() - .unwrap() - .insert(format!("{INPUT_DIR}/config"), b"one".to_vec()); + files.files.lock().unwrap().insert( + format!("{INPUT_DIR}/config"), + b"config-secret-marker".to_vec(), + ); files .files .lock() @@ -30,6 +29,5 @@ fn fixture_uses_host_container_resources() { ) .unwrap(); assert!(quadlet.contains("Secret=skillet-smoke-dummy")); - assert!(quadlet.contains("WantedBy=multi-user.target")); assert!(resources.contains_key("/var/lib/skillet/containers/skillet-smoke-fixture.applied")); } diff --git a/ublue/skillet/integration_tests/fixture-entrypoint.sh b/ublue/skillet/integration_tests/fixture-entrypoint.sh new file mode 100644 index 00000000..9541b7d0 --- /dev/null +++ b/ublue/skillet/integration_tests/fixture-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +cp /fixture/config /data/observed-config +sha256sum /run/secrets/skillet-smoke-dummy | cut -d' ' -f1 > /data/observed-secret-sha +while :; do sleep 3600; done diff --git a/ublue/skillet/integration_tests/smoke-guest.sh b/ublue/skillet/integration_tests/smoke-guest.sh index 3444c84c..2bfce563 100755 --- a/ublue/skillet/integration_tests/smoke-guest.sh +++ b/ublue/skillet/integration_tests/smoke-guest.sh @@ -2,7 +2,7 @@ set -euo pipefail phase=${1:?phase required} -binary=/var/usrlocal/bin/skillet-smoke +binary=/var/usrlocal/bin/skillet-smoke-fixture host_binary=${2:?host binary path required} credentials_required=${3:?credential capability required} root=/var/lib/skillet-smoke diff --git a/ublue/skillet/integration_tests/smoke-ssh.sh b/ublue/skillet/integration_tests/smoke-ssh.sh index 8876ea21..dedd771a 100755 --- a/ublue/skillet/integration_tests/smoke-ssh.sh +++ b/ublue/skillet/integration_tests/smoke-ssh.sh @@ -2,7 +2,10 @@ set -euo pipefail target= -binary= +fixture_binary= +fixture_sha256= +generic_sha256= +host_sha256= host_binary= host= credentials_required= @@ -12,7 +15,10 @@ disposable=false while (($#)); do case "$1" in --target) target=${2:?}; shift 2 ;; - --binary) binary=${2:?}; shift 2 ;; + --fixture-binary) fixture_binary=${2:?}; shift 2 ;; + --fixture-sha256) fixture_sha256=${2:?}; shift 2 ;; + --generic-sha256) generic_sha256=${2:?}; shift 2 ;; + --host-sha256) host_sha256=${2:?}; shift 2 ;; --host-binary) host_binary=${2:?}; shift 2 ;; --host) host=${2:?}; shift 2 ;; --credentials-required) credentials_required=${2:?}; shift 2 ;; @@ -23,8 +29,8 @@ while (($#)); do esac done -if [[ -z $target || -z $binary || -z $host_binary || -z $host || -z $credentials_required || $disposable != true ]]; then - echo 'usage: smoke-ssh.sh --target USER@DISPOSABLE_VM --host HOST --credentials-required yes|no [--port PORT] [--identity PRIVATE_KEY] --disposable-target --binary LOCAL_SKILLET --host-binary REMOTE_SKILLET_HOST' >&2 +if [[ -z $target || -z $fixture_binary || -z $fixture_sha256 || -z $generic_sha256 || -z $host_sha256 || -z $host_binary || -z $host || -z $credentials_required || $disposable != true ]]; then + echo 'usage: smoke-ssh.sh --target USER@DISPOSABLE_VM --host HOST --credentials-required yes|no [--port PORT] [--identity PRIVATE_KEY] --disposable-target --fixture-binary LOCAL_FIXTURE --fixture-sha256 HASH --generic-sha256 HASH --host-sha256 HASH --host-binary REMOTE_SKILLET_HOST' >&2 exit 2 fi if [[ ! $target =~ ^[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+$ ]]; then @@ -40,7 +46,7 @@ if [[ $target == *@"$host" || $target == *@"$host".* ]] || echo "refusing the production host identity $host or a local SSH service on port 22" >&2 exit 2 fi -if [[ ! -f $binary || $host_binary != /* || ! $host_binary =~ ^/[A-Za-z0-9_./-]+$ ]]; then +if [[ ! -f $fixture_binary || ! $fixture_sha256 =~ ^[a-f0-9]{64}$ || ! $generic_sha256 =~ ^[a-f0-9]{64}$ || ! $host_sha256 =~ ^[a-f0-9]{64}$ || $host_binary != /* || ! $host_binary =~ ^/[A-Za-z0-9_./-]+$ ]]; then echo 'binary paths must name a local file and an absolute remote path' >&2 exit 2 fi @@ -61,9 +67,25 @@ if [[ -n $identity ]]; then ssh_opts+=(-i "$identity" -o IdentitiesOnly=yes -o UserKnownHostsFile="$known_hosts") scp_opts+=(-i "$identity" -o IdentitiesOnly=yes -o UserKnownHostsFile="$known_hosts") fi -scp -O "${scp_opts[@]}" "$binary" "$target:/var/tmp/skillet-smoke" +scp -O "${scp_opts[@]}" "$fixture_binary" "$target:/var/tmp/skillet-smoke-fixture" scp -O "${scp_opts[@]}" "$script_dir/smoke-guest.sh" "$target:/var/tmp/skillet-smoke-guest.sh" -ssh "${ssh_opts[@]}" "$target" 'sudo install -m 0755 /var/tmp/skillet-smoke /var/usrlocal/bin/skillet-smoke' +ssh "${ssh_opts[@]}" "$target" 'sudo install -m 0755 /var/tmp/skillet-smoke-fixture /var/usrlocal/bin/skillet-smoke-fixture' +fixture_remote_sha256=$(ssh "${ssh_opts[@]}" "$target" 'sha256sum /var/usrlocal/bin/skillet-smoke-fixture | cut -d " " -f 1') +if [[ $fixture_remote_sha256 != "$fixture_sha256" ]]; then + echo 'transferred fixture binary does not match the selected Cargo artifact' >&2 + exit 1 +fi +generic_remote_sha256=$(ssh "${ssh_opts[@]}" "$target" 'sha256sum /var/usrlocal/bin/skillet | cut -d " " -f 1') +if [[ $generic_remote_sha256 != "$generic_sha256" ]]; then + echo 'installed generic binary does not match the ready VM manifest' >&2 + exit 1 +fi +# shellcheck disable=SC2029 +host_remote_sha256=$(ssh "${ssh_opts[@]}" "$target" "sha256sum '$host_binary' | cut -d ' ' -f 1") +if [[ $host_remote_sha256 != "$host_sha256" ]]; then + echo 'installed host binary does not match the ready VM manifest' >&2 + exit 1 +fi boot_before=$(ssh "${ssh_opts[@]}" "$target" 'cat /proc/sys/kernel/random/boot_id') # The remote path is validated above and intentionally expanded on the client. # shellcheck disable=SC2029 From a597a54e007a44e26cb05e405128b003608c33df Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 03:10:46 +0200 Subject: [PATCH 158/252] refactor(skillet): share credential guest transport --- ublue/design/skillet-architecture.md | 8 +- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 11 +- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 11 +- ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/skillet/AGENTS.md | 6 + .../skillet/crates/cli/src/secret_delivery.rs | 251 +++++++++--------- ublue/skillet/crates/vm/src/provisioning.rs | 13 +- .../crates/vm/src/provisioning_tests.rs | 20 +- 8 files changed, 168 insertions(+), 154 deletions(-) diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index f7af942f..f92ef485 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -17,9 +17,11 @@ boot expectations from the canonical host profile, bounded probes and private diagnostics. It verifies original artifacts and signed boot evidence before recording readiness. A retry first clears the persisted Ready checkpoint and restores it only after acceptance. Guest operations are guarded by ownership -checks before and after each call. Remaining VM commands and credential -delivery stay on their existing paths until their slices land. Transport's -bounded I/O stays in memory; payload copies are zeroized after stdin transfer. +checks before and after each call. Production and disposable-VM credential +delivery use the same verified guest transport and typed credential installer. +Transport's bounded I/O stays in memory; payload copies are zeroized after +stdin transfer. Provider and VM lifecycle orchestration still needs to move +behind the workstation library boundary. Workstream 2 adds `skillet_hosts` as the single host capability declaration. It owns host composition and supplies boot, service, network, UI, storage, and diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 4dcfc08b..7ebc3d3c 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -22,7 +22,9 @@ implemented and used by the routine container runner; a custom target-directory integration check selects the reported binary. `GuestTransport`/`SshTransport` now provide explicit target/key policy, literal remote executable arguments, noninteractive execution and upload. Subprocess stdin/output stay in memory, -with bounded concurrent I/O and zeroized copied stdin. Fifty VM tests pass. +with bounded concurrent I/O and zeroized copied stdin. Seventy-five VM tests +pass. Port preflight tests inject the bind result so test success does not +depend on a free port in the reserved workstation range. No live VM was changed or imported. Retained binary updates now use Rust's shared lock, ownership checks, Cargo @@ -44,9 +46,10 @@ runs readiness from one create command. Matching interrupted runs resume from the manifest. The shell `test-vm` interface only translates arguments; the independent Bash installer and hidden CLI bridge commands are retired. No remaining script owns VM creation, reboot, access, inspection, or disposal. -Credential delivery has not yet adopted the shared transport. Workstream 2 -must consolidate the interim `boot_policy_for_host` lookup with the canonical -capability declaration. Native and Flatpak adapter contract tests and +Workstream 4 has now moved production and disposable-VM guest commands onto +the shared verified SSH transport. Workstream 2 must consolidate the interim +`boot_policy_for_host` lookup with the canonical capability declaration. +Native and Flatpak adapter contract tests and workstation checks remain required; live create/ready/update/reboot/destroy and retained-run acceptance have not yet passed. See [creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 6ef4b302..34fb097d 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -110,7 +110,14 @@ and VM delivery have duplicate implementations with different validation. credential lifetimes, cleanup-token lifetime, and Tailscale device tags. Delivery and cleanup consume this policy; the Cloudflare zone ID uses the shared `skillet/environments/dns/cloudflare-zone-id` vault entry. +- Implemented: production credential delivery and disposable-VM provisioning + use `skillet_vm::SshTransport` with verified recorded host keys and literal + executable arguments. Caddy activation, VM status probes, and the non-tailnet + HTTP probe no longer build remote shell command strings. Both targets install + credentials through the shared typed installer, with activation deferred + until all related Caddy credentials are present. - Pending: environment/host credential-delivery orchestration, durable versus disposable policy, provider/VM cleanup ownership persistence, and production - plus named-VM acceptance remain in CLI modules. Finish the remaining sequence - items before closing this workstream. + plus named-VM acceptance remain in CLI modules. Provider and VM lifecycle + orchestration still has mixed CLI/workstation ownership. Finish the remaining + sequence items before closing this workstream. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 0dc2f14b..d6db130e 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -23,7 +23,7 @@ assignments to execute concurrently against the same interfaces. | 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; live acceptance pending | -| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress | +| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress; production and VM credential delivery now share verified transport and installation | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | In progress | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Implemented; VM acceptance pending | | 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Implemented; VM acceptance pending | diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 687a5440..deac9f97 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -29,6 +29,9 @@ This document defines the architectural mandates and project structure for `skil - **Verification**: Always run both: - **Unit Tests**: `cargo test --workspace --all-targets`. - **Runtime Smoke**: Run `integration_tests/smoke-ssh.sh` against an explicitly named disposable VM with real systemd and Podman for affected container resources. Record the guest state snapshots and failure diagnostics. +- Tests for host-dependent observations must inject or simulate those + observations. Do not require a particular workstation port, account, service, + or external resource to happen to be available for a unit test. ## Local musl toolchain @@ -75,6 +78,9 @@ tracks existing violations and their migration, not completed implementation. particular deployment names. Keep secret values off command lines and out of manifests, recordings, and errors. - Credential-capable transport keeps stdin and captured output in memory. + Use the shared verified guest transport for production and VM delivery; pass + executable names and arguments as separate values rather than assembling + remote command strings in callers. Drain stdout/stderr concurrently with stdin, bound the complete operation including stream completion, and zeroize owned payload copies after transfer. - Use a common ownership representation for named and numeric identities. diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 8a444168..5ad549e8 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -3,6 +3,7 @@ use super::{ }; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; +use skillet_vm::transport::GuestTransport as _; use skillet_workstation::tailscale; use skillet_workstation::vault::Vault; use std::{ @@ -167,12 +168,17 @@ fn deliver_caddy_from_vault( value.as_bytes(), )?; } - let status = ssh_command(args) - .arg("sudo -n systemctl start skillet-caddy-apply.service") - .status() + let output = credential_transport(args)? + .execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "systemctl", "start", "skillet-caddy-apply.service"], + }, + None, + ) .context("starting Caddy apply after both credentials were delivered")?; - if !status.success() { - return Err(anyhow!("Caddy apply failed with status {status}")); + if !output.status.success() { + return Err(anyhow!("Caddy apply failed with status {}", output.status)); } Ok(()) } @@ -252,30 +258,6 @@ fn tailscale_credentials(vault: &Vault) -> Result { Ok(tailscale::OAuthCredentials::new(client_id, client_secret)?) } -fn ssh_command(args: &SecretDeliverArgs) -> Command { - let mut command = Command::new("ssh"); - command - .args([ - "-T", - "-o", - "BatchMode=yes", - "-o", - "IdentitiesOnly=yes", - "-o", - "StrictHostKeyChecking=yes", - "-o", - "ConnectTimeout=10", - "-o", - ]) - .arg(format!("UserKnownHostsFile={}", args.known_hosts.display())) - .arg("-i") - .arg(&args.identity) - .arg("-p") - .arg(args.port.to_string()) - .arg(&args.target); - command -} - fn credential_transport(args: &SecretDeliverArgs) -> Result { let (user, address) = args .target @@ -353,8 +335,8 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let identity = run_dir.join("ssh/id_ed25519"); let known_hosts = run_dir.join("ssh/known_hosts"); let port = read_vm_port(&run_dir.join("run.conf"))?; - let mut ssh = VmSsh::new(&args.hostname, &identity, &known_hosts, port); - validate_vm_tailscale_delivery(&mut ssh)?; + let ssh = VmSsh::new(&args.hostname, &identity, &known_hosts, port)?; + validate_vm_tailscale_delivery(&ssh)?; let vault_path = match &args.database { Some(path) => path.clone(), None => default_database_path()?, @@ -365,7 +347,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let policy = UiEnvironmentName::Test.policy(); write_pending_tailscale(&run_dir, expected_hostname)?; - let mut addresses = vm_tailscale_addresses(&mut ssh).unwrap_or_default(); + let mut addresses = vm_tailscale_addresses(&ssh).unwrap_or_default(); if addresses.is_empty() { let auth_key = tailscale::create_auth_key( &credentials, @@ -377,15 +359,18 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { } let pihole_credential = "/etc/credstore.encrypted/skillet/pihole_web_password.cred"; - let has_pihole_credential = vm_credential_present(&mut ssh, pihole_credential)?; + let has_pihole_credential = vm_credential_present(&ssh, pihole_credential)?; if args.rotate || !has_pihole_credential { let secret = random_password()?; ssh.install("pihole_web_password", &secret)?; } else { - ssh.run("sudo -n systemctl start skillet-full-apply.service")?; + ssh.run( + "/usr/bin/sudo", + &["-n", "systemctl", "start", "skillet-full-apply.service"], + )?; } - addresses = wait_for_vm_tailscale(&mut ssh)?; + addresses = wait_for_vm_tailscale(&ssh)?; let record = tailscale::find_device( &credentials, expected_hostname, @@ -394,7 +379,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { )?; save_vm_tailscale_record(&run_dir, &record)?; if args.with_ui { - provision_vm_ui(args, &run_dir, &mut ssh, &mut vault, &record)?; + provision_vm_ui(args, &run_dir, &ssh, &mut vault, &record)?; } remove_pending_tailscale(&run_dir)?; println!("Tailscale connected VM {expected_hostname}"); @@ -461,7 +446,7 @@ struct CloudflareVmOwnership { fn provision_vm_ui( args: &VmProvisionArgs, run_dir: &Path, - ssh: &mut VmSsh<'_>, + ssh: &VmSsh, vault: &mut Vault, device: &tailscale::DeviceRecord, ) -> Result<()> { @@ -547,7 +532,10 @@ fn provision_vm_ui( "skillet-caddy-apply.service", &issued.value, )?; - ssh.run("sudo -n systemctl start skillet-caddy-apply.service")?; + ssh.run( + "/usr/bin/sudo", + &["-n", "systemctl", "start", "skillet-caddy-apply.service"], + )?; verify_caddy_denies_non_tailnet_probe(ssh, &sites)?; for old_id in api.token_ids_by_name(&creator, &account_id, &ownership.token_name)? { if old_id != issued.id { @@ -563,17 +551,30 @@ fn provision_vm_ui( } fn verify_caddy_denies_non_tailnet_probe( - ssh: &mut VmSsh<'_>, + ssh: &VmSsh, sites: &skillet_caddy::CaddySites, ) -> Result<()> { for site in &sites.services { for hostname in std::iter::once(&site.hostname).chain(&site.aliases) { - let probe = format!( - "curl --insecure --silent --show-error --max-time 8 --resolve '{hostname}:443:127.0.0.1' --write-out '\n%{{http_code}}' 'https://{hostname}/'" - ); + let resolve = format!("{hostname}:443:127.0.0.1"); + let url = format!("https://{hostname}/"); let mut denied = false; for _ in 0..30 { - let output = ssh.output(&probe)?; + let output = ssh.output( + "/usr/bin/curl", + &[ + "--insecure", + "--silent", + "--show-error", + "--max-time", + "8", + "--resolve", + &resolve, + "--write-out", + "\n%{http_code}", + &url, + ], + )?; let response = String::from_utf8_lossy(&output.stdout); if output.status.success() && response.trim() == "Access denied by Skillet tailnet policy\n403" @@ -696,110 +697,88 @@ fn write_cloudflare_ownership(path: &Path, ownership: &CloudflareVmOwnership) -> Ok(()) } -struct VmSsh<'a> { - host: &'a str, - identity: &'a Path, - known_hosts: &'a Path, - port: u16, -} - -impl VmSsh<'_> { - fn command(&self, remote: &str) -> Command { - let mut command = Command::new("ssh"); - command - .args([ - "-T", - "-o", - "BatchMode=yes", - "-o", - "IdentitiesOnly=yes", - "-o", - "StrictHostKeyChecking=yes", - "-o", - "ConnectTimeout=10", - "-o", - ]) - .arg(format!("UserKnownHostsFile={}", self.known_hosts.display())) - .arg("-i") - .arg(self.identity) - .arg("-p") - .arg(self.port.to_string()) - .arg("giacomo@127.0.0.1") - .arg(remote); - command - } - - fn run(&mut self, remote: &str) -> Result<()> { - let output = self - .command(remote) - .output() - .context("running command over VM SSH")?; +struct VmSsh { + host: String, + transport: skillet_vm::transport::SshTransport, +} + +impl VmSsh { + fn new(host: &str, identity: &Path, known_hosts: &Path, port: u16) -> Result { + Ok(Self { + host: host.to_string(), + transport: skillet_vm::transport::SshTransport::new( + skillet_vm::SshTarget { + user: "giacomo".to_string(), + address: "127.0.0.1".to_string(), + port, + identity: identity.to_path_buf(), + known_hosts: known_hosts.to_path_buf(), + }, + skillet_vm::transport::HostKeyPolicy::Verify, + )?, + }) + } + + fn output(&self, program: &str, arguments: &[&str]) -> Result { + use skillet_vm::transport::GuestTransport as _; + self.transport + .execute( + &skillet_vm::transport::GuestCommand { program, arguments }, + None, + ) + .context("running command over recorded VM SSH transport") + } + + fn run(&self, program: &str, arguments: &[&str]) -> Result<()> { + let output = self.output(program, arguments)?; if !output.status.success() { - return Err(anyhow!("VM command failed with status {}", output.status)); + return Err(anyhow!( + "VM command {program} failed with status {}", + output.status + )); } Ok(()) } - fn output(&mut self, remote: &str) -> Result { - self.command(remote) - .output() - .context("running command over VM SSH") - } - - fn install(&mut self, name: &str, secret: &str) -> Result<()> { - self.install_for_unit(name, "skillet-full-apply.service", secret, false) + fn install(&self, name: &str, secret: &str) -> Result<()> { + self.install_for_unit( + name, + "skillet-full-apply.service", + secret, + skillet_vm::credential::ActivationPolicy::StartConsumer, + ) } - fn install_deferred(&mut self, name: &str, unit: &str, secret: &str) -> Result<()> { - self.install_for_unit(name, unit, secret, true) + fn install_deferred(&self, name: &str, unit: &str, secret: &str) -> Result<()> { + self.install_for_unit( + name, + unit, + secret, + skillet_vm::credential::ActivationPolicy::DeferConsumer, + ) } fn install_for_unit( - &mut self, + &self, name: &str, unit: &str, secret: &str, - defer_start: bool, + activation: skillet_vm::credential::ActivationPolicy, ) -> Result<()> { - let transport = skillet_vm::transport::SshTransport::new( - skillet_vm::SshTarget { - user: "giacomo".to_string(), - address: "127.0.0.1".to_string(), - port: self.port, - identity: self.identity.to_path_buf(), - known_hosts: self.known_hosts.to_path_buf(), - }, - skillet_vm::transport::HostKeyPolicy::Verify, - )?; skillet_vm::credential::install( - &transport, - self.host, + &self.transport, + &self.host, name, unit, - if defer_start { - skillet_vm::credential::ActivationPolicy::DeferConsumer - } else { - skillet_vm::credential::ActivationPolicy::StartConsumer - }, + activation, secret.as_bytes(), )?; Ok(()) } } -impl<'a> VmSsh<'a> { - fn new(host: &'a str, identity: &'a Path, known_hosts: &'a Path, port: u16) -> Self { - Self { - host, - identity, - known_hosts, - port, - } - } -} - -fn vm_credential_present(ssh: &mut VmSsh<'_>, path: &str) -> Result { - let output = ssh.output(&format!("sudo -n test -s {path}"))?; +fn vm_credential_present(ssh: &VmSsh, path: &str) -> Result { + let output = ssh.output("/usr/bin/sudo", &["-n", "test", "-s", path])?; match output.status.code() { Some(0) => Ok(true), Some(1) => Ok(false), @@ -807,8 +786,19 @@ fn vm_credential_present(ssh: &mut VmSsh<'_>, path: &str) -> Result { } } -fn vm_tailscale_addresses(ssh: &mut VmSsh<'_>) -> Result> { - let output = ssh.output("sudo -n podman exec tailscale tailscale status --json")?; +fn vm_tailscale_addresses(ssh: &VmSsh) -> Result> { + let output = ssh.output( + "/usr/bin/sudo", + &[ + "-n", + "podman", + "exec", + "tailscale", + "tailscale", + "status", + "--json", + ], + )?; if !output.status.success() { return Ok(std::collections::BTreeSet::new()); } @@ -838,7 +828,7 @@ fn parse_tailscale_addresses(output: &[u8]) -> Result) -> Result> { +fn wait_for_vm_tailscale(ssh: &VmSsh) -> Result> { for _ in 0..60 { if let Ok(addresses) = vm_tailscale_addresses(ssh) { if !addresses.is_empty() { @@ -909,9 +899,12 @@ fn read_vm_port(manifest: &Path) -> Result { Ok(port) } -fn validate_vm_tailscale_delivery(ssh: &mut VmSsh<'_>) -> Result<()> { +fn validate_vm_tailscale_delivery(ssh: &VmSsh) -> Result<()> { let output = ssh - .output("sudo -n systemctl cat skillet-full-apply.service") + .output( + "/usr/bin/sudo", + &["-n", "systemctl", "cat", "skillet-full-apply.service"], + ) .context("checking the VM's full-apply credential configuration")?; if !output.status.success() { return Err(anyhow!( diff --git a/ublue/skillet/crates/vm/src/provisioning.rs b/ublue/skillet/crates/vm/src/provisioning.rs index aabb5da4..599049f5 100644 --- a/ublue/skillet/crates/vm/src/provisioning.rs +++ b/ublue/skillet/crates/vm/src/provisioning.rs @@ -15,16 +15,19 @@ use std::{ /// Fail early when another local listener already owns the VM's loopback SSH /// forwarding port. pub fn validate_ssh_port(port: u16) -> Result<()> { + validate_ssh_port_with(port, |port| { + TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).map(drop) + }) +} + +fn validate_ssh_port_with(port: u16, bind: impl FnOnce(u16) -> std::io::Result<()>) -> Result<()> { if !(2200..=2299).contains(&port) { return Err(Error::Invalid( "VM SSH port must be between 2200 and 2299".into(), )); } - let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).map_err(|error| { - Error::Preparation(format!("VM SSH port {port} is unavailable: {error}")) - })?; - drop(listener); - Ok(()) + bind(port) + .map_err(|error| Error::Preparation(format!("VM SSH port {port} is unavailable: {error}"))) } /// Resolve one supported Fedora `CoreOS` QEMU image, downloading it through the diff --git a/ublue/skillet/crates/vm/src/provisioning_tests.rs b/ublue/skillet/crates/vm/src/provisioning_tests.rs index cbea17aa..93189027 100644 --- a/ublue/skillet/crates/vm/src/provisioning_tests.rs +++ b/ublue/skillet/crates/vm/src/provisioning_tests.rs @@ -1,20 +1,20 @@ use super::*; use crate::{Backend, Connection}; -use std::{fs, net::TcpListener, os::unix::fs::symlink, path::PathBuf}; +use std::{fs, os::unix::fs::symlink, path::PathBuf}; #[test] fn ssh_forward_port_preflight_rejects_invalid_and_occupied_ports() { assert!(validate_ssh_port(2199).is_err()); assert!(validate_ssh_port(2300).is_err()); - let (port, listener) = (2200..=2299) - .find_map(|port| { - TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) - .ok() - .map(|listener| (port, listener)) - }) - .expect("at least one test SSH port should be available"); - assert!(validate_ssh_port(port).is_err()); - drop(listener); + assert!(validate_ssh_port_with(2200, |_| Ok(())).is_ok()); + assert!(validate_ssh_port_with(2200, |_| { + Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "simulated occupied port", + )) + }) + .is_err()); + assert!(validate_ssh_port_with(2199, |_| panic!("invalid port must not bind")).is_err()); } #[test] From c24cc9dfdd22673866e57c75a457526c421d1675 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 03:13:59 +0200 Subject: [PATCH 159/252] test(skillet-core): share file adapter contracts --- ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 8 ++- ublue/skillet/crates/core/src/lib.rs | 6 +- .../core/src/resource_contract_tests.rs | 57 +++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 ublue/skillet/crates/core/src/resource_contract_tests.rs diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md index c150ac29..54f5cf0a 100644 --- a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -86,5 +86,9 @@ ownership use separate APIs; mocks do not enforce important metadata contracts. service, and backup crates request their required capabilities; host composition retains the aggregate during migration. Recorder and mock adapters implement the narrow contracts. -- Pending: shared adapter contract tests still need review, and live - ownership/mount acceptance is outstanding. This workstream is not complete. +- Implemented: a shared file mutation contract runs against both + `LocalFileResource` and `MockFiles`, checking creation, unchanged repeat, + update, deletion, and file/directory type conflicts. This catches behavioral + drift without relying on root privileges or Btrfs. +- Pending: system/service adapter contract coverage and live ownership/mount + acceptance remain outstanding. This workstream is not complete. diff --git a/ublue/skillet/crates/core/src/lib.rs b/ublue/skillet/crates/core/src/lib.rs index e53c4ddd..020e1554 100644 --- a/ublue/skillet/crates/core/src/lib.rs +++ b/ublue/skillet/crates/core/src/lib.rs @@ -6,5 +6,9 @@ pub mod recorder; pub mod resource_op; pub mod system; pub mod templates; -#[cfg(feature = "test-utils")] +#[cfg(any(test, feature = "test-utils"))] pub mod test_utils; + +#[cfg(test)] +#[path = "resource_contract_tests.rs"] +mod resource_contract_tests; diff --git a/ublue/skillet/crates/core/src/resource_contract_tests.rs b/ublue/skillet/crates/core/src/resource_contract_tests.rs new file mode 100644 index 00000000..45a3e395 --- /dev/null +++ b/ublue/skillet/crates/core/src/resource_contract_tests.rs @@ -0,0 +1,57 @@ +use crate::{ + files::{FileMutationResource, FileReadResource, LocalFileResource}, + test_utils::MockFiles, +}; +use std::path::Path; + +fn file_mutation_contract(resource: &(impl FileMutationResource + FileReadResource), path: &Path) { + assert_eq!(resource.read_file(path).unwrap(), None); + assert!(resource + .ensure_file(path, b"initial", Some(0o640), None, None) + .unwrap()); + assert_eq!(resource.read_file(path).unwrap(), Some(b"initial".to_vec())); + assert!(!resource + .ensure_file(path, b"initial", Some(0o640), None, None) + .unwrap()); + assert!(resource + .ensure_file(path, b"updated", Some(0o640), None, None) + .unwrap()); + assert_eq!(resource.read_file(path).unwrap(), Some(b"updated".to_vec())); + assert!(resource.delete_file(path).unwrap()); + assert_eq!(resource.read_file(path).unwrap(), None); + assert!(!resource.delete_file(path).unwrap()); +} + +#[test] +fn local_file_resource_obeys_shared_mutation_contract() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("managed.conf"); + file_mutation_contract(&LocalFileResource::new(), &path); +} + +#[test] +fn mock_file_resource_obeys_shared_mutation_contract() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("managed.conf"); + file_mutation_contract(&MockFiles::new(), &path); +} + +#[test] +fn local_and_mock_file_resources_reject_file_directory_conflicts() { + let directory = tempfile::tempdir().unwrap(); + let local_path = directory.path().join("local-conflict"); + let mock_path = directory.path().join("mock-conflict"); + let local = LocalFileResource::new(); + let mock = MockFiles::new(); + + local + .ensure_file(&local_path, b"file", None, None, None) + .unwrap(); + mock.ensure_file(&mock_path, b"file", None, None, None) + .unwrap(); + + assert!(local + .ensure_directory(&local_path, None, None, None) + .is_err()); + assert!(mock.ensure_directory(&mock_path, None, None, None).is_err()); +} From 6700e1b914db739598317cf3f70845bb991067af Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 03:16:03 +0200 Subject: [PATCH 160/252] test(skillet-core): verify account lookup adapters --- ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 9 +++- ublue/skillet/crates/core/src/system/tests.rs | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md index 54f5cf0a..e66ead6b 100644 --- a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -90,5 +90,10 @@ ownership use separate APIs; mocks do not enforce important metadata contracts. `LocalFileResource` and `MockFiles`, checking creation, unchanged repeat, update, deletion, and file/directory type conflicts. This catches behavioral drift without relying on root privileges or Btrfs. -- Pending: system/service adapter contract coverage and live ownership/mount - acceptance remain outstanding. This workstream is not complete. +- Implemented: account lookup contract assertions run against both + `LinuxSystemResource` and `MockSystem`, checking name, numeric UID, and group + results against the current test account. This read-only adapter check avoids + mutating workstation users or services. +- Pending: service and Podman-secret adapter contract coverage and live + ownership/mount acceptance remain outstanding. This workstream is not + complete. diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 1eb32a5b..0392c9c8 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -72,3 +72,51 @@ fn mock_account_boundary_preserves_and_checks_numeric_identity() { .is_err()); assert!(system.group_by_name("service").unwrap().is_some()); } + +fn account_lookup_contract( + resource: &impl super::AccountLookupResource, + expected_user: &super::UserIdentity, + expected_group: &super::GroupIdentity, +) { + assert_eq!( + resource.user_by_name(&expected_user.name).unwrap(), + Some(expected_user.clone()) + ); + assert_eq!( + resource.user_by_uid(expected_user.uid).unwrap(), + Some(expected_user.clone()) + ); + assert_eq!( + resource.group_by_name(&expected_group.name).unwrap(), + Some(expected_group.clone()) + ); +} + +#[test] +fn linux_and_mock_account_lookup_adapters_follow_shared_contract() { + use super::{GroupIdentity, LinuxSystemResource, UserIdentity}; + use crate::{system::AccountResource, test_utils::MockSystem}; + + let host_user = users::get_user_by_uid(users::get_current_uid()) + .expect("the test process must map to an account"); + let host_group = users::get_group_by_gid(host_user.primary_group_id()) + .expect("the test process primary group must exist"); + let user = UserIdentity { + name: host_user.name().to_string_lossy().into_owned(), + uid: host_user.uid(), + primary_gid: host_user.primary_group_id(), + }; + let group = GroupIdentity { + name: host_group.name().to_string_lossy().into_owned(), + gid: host_group.gid(), + }; + + let linux = LinuxSystemResource::new(); + account_lookup_contract(&linux, &user, &group); + + let mock = MockSystem::new(); + mock.ensure_group(&group.name, Some(group.gid)).unwrap(); + mock.ensure_user(&user.name, Some(user.uid), Some(user.primary_gid)) + .unwrap(); + account_lookup_contract(&mock, &user, &group); +} From 2fb6f6418665d0b93c05bb6b7a6306fc335d47c4 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 03:18:00 +0200 Subject: [PATCH 161/252] test(skillet-core): verify system effect contracts --- ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 8 +++-- ublue/skillet/crates/core/src/system/tests.rs | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md index e66ead6b..8c6eb8b8 100644 --- a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -94,6 +94,8 @@ ownership use separate APIs; mocks do not enforce important metadata contracts. `LinuxSystemResource` and `MockSystem`, checking name, numeric UID, and group results against the current test account. This read-only adapter check avoids mutating workstation users or services. -- Pending: service and Podman-secret adapter contract coverage and live - ownership/mount acceptance remain outstanding. This workstream is not - complete. +- Implemented: service state and Podman-secret idempotency/rotation contracts + run against both `MockSystem` and its `Recorder` decorator. Unit tests do not + mutate the workstation's real services or secrets. +- Pending: live ownership/mount acceptance remains outstanding. This + workstream is not complete until the named VM checks pass. diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 0392c9c8..89a1588e 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -92,6 +92,40 @@ fn account_lookup_contract( ); } +fn service_resource_contract(resource: &impl super::ServiceResource) { + assert!(!resource.service_is_active("contract.service").unwrap()); + resource.service_start("contract.service").unwrap(); + assert!(resource.service_is_active("contract.service").unwrap()); + resource.service_restart("contract.service").unwrap(); + assert!(resource.service_is_active("contract.service").unwrap()); + resource.service_stop("contract.service").unwrap(); + assert!(!resource.service_is_active("contract.service").unwrap()); +} + +fn podman_secret_resource_contract(resource: &impl super::PodmanSecretResource) { + assert!(resource.ensure_podman_secret("contract", "first").unwrap()); + let first = resource.podman_secret_id("contract").unwrap(); + assert!(!resource.ensure_podman_secret("contract", "first").unwrap()); + assert_eq!(resource.podman_secret_id("contract").unwrap(), first); + assert!(resource + .ensure_podman_secret("contract", "rotated") + .unwrap()); + assert_ne!(resource.podman_secret_id("contract").unwrap(), first); +} + +#[test] +fn mock_and_recorded_system_effects_follow_shared_contracts() { + use crate::{recorder::Recorder, test_utils::MockSystem}; + + let mock = MockSystem::new(); + service_resource_contract(&mock); + podman_secret_resource_contract(&mock); + + let recorder = Recorder::new(MockSystem::new()); + service_resource_contract(&recorder); + podman_secret_resource_contract(&recorder); +} + #[test] fn linux_and_mock_account_lookup_adapters_follow_shared_contract() { use super::{GroupIdentity, LinuxSystemResource, UserIdentity}; From fd423f98bdbcd39e501baa72e70fed15da32ede9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 03:25:23 +0200 Subject: [PATCH 162/252] refactor(skillet-podman): type volume root ownership --- ublue/butane/ACCEPTANCE.md | 15 +++ ublue/design/podman-runtime.md | 11 ++- ublue/design/unifi.md | 6 +- ublue/plan/REFACTOR-05-PODMAN-MODEL.md | 15 ++- ublue/skillet/crates/caddy/src/lib.rs | 6 ++ ublue/skillet/crates/host-profiles/src/lib.rs | 2 + ublue/skillet/crates/pihole/src/lib.rs | 6 ++ ublue/skillet/crates/podman/src/lib.rs | 17 +++- ublue/skillet/crates/podman/src/tests.rs | 96 +++++++++++++++++++ ublue/skillet/crates/syncthing/src/lib.rs | 2 + ublue/skillet/crates/test-fixture/src/lib.rs | 4 + ublue/skillet/crates/unifi/src/lib.rs | 16 +--- 12 files changed, 170 insertions(+), 26 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index db6c663d..7a4cc4fe 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1087,3 +1087,18 @@ host tool; it still uses normal command approvals. timeout during cleanup; this did not fail the test. - Fresh VM activation/reboot, host-profile acceptance, and retained-run recovery are unverified because this workstation has no `/dev/kvm`. + +### Typed Podman volume-root ownership, 2026-10-04 + +- Added optional typed host mode and ownership to each bind-volume declaration. + Podman applies these only to the mount root. UniFi now declares mode `0750` + and numeric UID/GID `999` as part of its volume; other existing volumes leave + ownership unspecified. Descendant data is not recursively changed. +- The focused Podman and UniFi tests passed. The full offline workspace test + suite, strict all-target Clippy, formatting, and the documented beezelbot + Fedora container integration passed. The integration applied twice; the + second apply did not restart services. Podman needed SIGKILL after its + 10-second stop timeout while removing the test container. +- A regression test confirms the typed metadata does not change the existing + Quadlet bytes. VM verification of actual volume ownership, descendants, and + service restart behavior remains unverified because `/dev/kvm` is absent. diff --git a/ublue/design/podman-runtime.md b/ublue/design/podman-runtime.md index fdc06f74..5481db44 100644 --- a/ublue/design/podman-runtime.md +++ b/ublue/design/podman-runtime.md @@ -2,10 +2,11 @@ Container process identity, host volume ownership, and user namespace mapping are separate inputs. Recipes use the image's declared user explicitly when -appropriate, or declare a named/numeric process identity. Host ownership of -data directories is configured by the owning application. A namespace mapping -is opt-in, maps an existing host account through its subordinate-ID ranges, -and never creates host accounts as a side effect. +appropriate, or declare a named/numeric process identity. The owning +application declares any desired volume-root mode and ownership as typed data; +Podman applies it only to that root and does not recurse into existing data. A +namespace mapping is opt-in, maps an existing host account through its +subordinate-ID ranges, and never creates host accounts as a side effect. Host-wide Podman settings are selected and applied once by host composition. Host composition also creates and records each shared network definition once. @@ -16,5 +17,5 @@ or change global daemon policy. Persistent service containers use the shared `/var/lib/data` mount dependency: their units require its preparation service, bind to the mount, and assert the mountpoint exists. Ignition creates and prepares the Btrfs filesystem and -Podman graphroot; application recipes validate mounts and manage their own +Podman graphroot; application recipes validate mounts and declare their service directories and ownership. diff --git a/ublue/design/unifi.md b/ublue/design/unifi.md index ce003346..6e5c0dee 100644 --- a/ublue/design/unifi.md +++ b/ublue/design/unifi.md @@ -5,8 +5,10 @@ with host networking, required for device discovery/adoption, and persistent data under `/var/lib/data/unifi`. Preserve the current controller state when migrating; ordinary Skillet apply must not overwrite or import a database. The container runs as image user `unifi` (UID/GID 999). Skillet sets only the -data subvolume root to those numeric IDs and preserves ownership of existing -descendants; the host does not need matching account names. +declared bind-mount root to those numeric IDs and preserves ownership of +existing descendants; the host does not need matching account names. The +ownership and mode are typed volume inputs, so generic Podman code does not +infer identities from container configuration or recursively alter data. Use UniFi Site Manager at `unifi.ui.com` for remote access. The cloud service brokers access to the local controller; configuration and device management diff --git a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md index da65c365..4e957aa3 100644 --- a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md +++ b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md @@ -37,9 +37,15 @@ apply also owns a global DNS-port policy and converges shared networks repeatedl - Implemented: added a reusable typed mount dependency for persistent service containers. Pi-hole, Syncthing, UniFi, Caddy, and Tailscale use the shared data mount helper; invalid mount inputs fail before container effects. -- Pending: typed host volume ownership, old/new Quadlet comparison, and - named-VM validation for process identity, DNS, service discovery, and - persistence. +- Implemented: typed bind volumes can declare root mode and numeric or named + host ownership. Podman converges those fields only on the mount root; omitted + metadata preserves the application's current ownership. UniFi declares its + `999:999` root and `0750` mode in its volume. A test preserves a child file's + bytes while checking the root metadata. +- Implemented: a representative comparison proves that adding root ownership + metadata leaves the existing Quadlet bytes unchanged. +- Pending: named-VM validation for process identity, DNS, service discovery, + and persistence. ## Implementation sequence @@ -61,7 +67,8 @@ apply also owns a global DNS-port policy and converges shared networks repeatedl 4. Add reusable storage dependency helpers for application units and shared data validation. Preserve the installation/runtime split: Ignition prepares the filesystem and graphroot; recipes validate and prepare application data. - Keep application-owned volume metadata out of generic volume creation. + Carry caller-selected root metadata as typed volume data; never recursively + change descendants or infer host ownership from container process identity. 5. Migrate every existing service, including Tailscale and the smoke fixture, preserving images, ports, capabilities, SELinux labels, identities, paths, secret directives, auto-update and startup dependencies. Explain intentional diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index 903fe570..dc27d75f 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -328,16 +328,22 @@ where host_path: "/etc/skillet/caddy".to_string(), container_path: "/etc/caddy".to_string(), options: Some("ro,Z".to_string()), + host_mode: None, + host_ownership: None, }, Volume { host_path: "/var/lib/data/caddy/data".to_string(), container_path: "/data".to_string(), options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, }, Volume { host_path: "/var/lib/data/caddy/config".to_string(), container_path: "/config".to_string(), options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, }, ], secrets: vec![QuadletSecret { diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index bdd978be..03cf2322 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -155,6 +155,8 @@ fn tailscale_config(hostname: &str, auth_key: String, state_path: &str) -> Podma host_path: state_path.to_string(), container_path: "/var/lib/tailscale".to_string(), options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, }], secrets: vec![QuadletSecret { secret_name: TAILSCALE_AUTH_KEY_CREDENTIAL.to_string(), diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index ed67ee0c..83af1051 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -93,16 +93,22 @@ where host_path: etc.to_string(), container_path: "/etc/pihole".to_string(), options: Some("z".to_string()), + host_mode: None, + host_ownership: None, }, Volume { host_path: format!("{root}/dnsmasq.d"), container_path: "/etc/dnsmasq.d".to_string(), options: Some("z".to_string()), + host_mode: None, + host_ownership: None, }, Volume { host_path: logs.to_string(), container_path: "/var/log/pihole".to_string(), options: Some("z".to_string()), + host_mode: None, + host_ownership: None, }, ]; diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index e4fbce07..15541b00 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -1,7 +1,7 @@ use askama::Template; use sha2::{Digest, Sha256}; use skillet_core::activation::{self, ActivationOutcome, ActivationRequest, ConsumerKind}; -use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource, Ownership}; use skillet_core::system::{ AccountLookupResource, PodmanSecretResource, ServiceResource, SystemError, }; @@ -75,6 +75,10 @@ pub struct Volume { pub host_path: String, pub container_path: String, pub options: Option, + pub host_mode: Option, + /// Metadata applied only to the bind-mount root. Descendant data is never + /// recursively changed by generic volume convergence. + pub host_ownership: Option, } pub enum SecretTarget { @@ -352,9 +356,14 @@ where container_section.push(format!("Image={}", config.image)); for vol in config.volumes { - // The application owns volume metadata. Ensure only existence here so - // repeated applies cannot alternate ownership with its resource. - files.ensure_directory(Path::new(&vol.host_path), None, None, None)?; + // Ownership is part of this typed volume declaration and affects only + // the root directory. `None` preserves application-managed metadata. + let ownership = vol.host_ownership.unwrap_or_default(); + files.ensure_directory_with_ownership( + Path::new(&vol.host_path), + vol.host_mode, + &ownership, + )?; let mut vol_line = format!("Volume={}:{}", vol.host_path, vol.container_path); if let Some(opt) = vol.options { diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index 6ce5c446..fba0a47e 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -194,6 +194,102 @@ fn shared_data_mount_dependency_is_rendered_and_invalid_dependency_fails_early() assert!(empty_files.files.lock().unwrap().is_empty()); } +#[test] +fn typed_volume_ownership_changes_only_the_mount_root_metadata() { + use skillet_core::files::{OwnerIdentity, Ownership}; + + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let path = "/var/lib/data/owned-volume"; + files + .files + .lock() + .unwrap() + .insert(format!("{path}/existing.db"), b"preserve".to_vec()); + + let mut config = fixture(); + config.volumes.push(Volume { + host_path: path.to_string(), + container_path: "/data".to_string(), + options: None, + host_mode: Some(0o750), + host_ownership: Some(Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + }), + }); + container(&system, &files, config).unwrap(); + + assert_eq!( + files.directory_metadata.lock().unwrap().get(path), + Some(&( + Some(0o750), + Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + } + )) + ); + assert_eq!( + files + .files + .lock() + .unwrap() + .get(&format!("{path}/existing.db")), + Some(&b"preserve".to_vec()) + ); +} + +#[test] +fn typed_host_root_metadata_does_not_change_existing_quadlet_definition() { + use skillet_core::files::{OwnerIdentity, Ownership}; + + let mut previous = fixture(); + previous.volumes.push(Volume { + host_path: "/var/lib/data/owned-volume".to_string(), + container_path: "/data".to_string(), + options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, + }); + let previous_system = MockSystem::new(); + previous_system + .ensure_podman_secret("dummy", "first") + .unwrap(); + let previous_files = MockFiles::new(); + container(&previous_system, &previous_files, previous).unwrap(); + + let mut typed = fixture(); + typed.volumes.push(Volume { + host_path: "/var/lib/data/owned-volume".to_string(), + container_path: "/data".to_string(), + options: Some("Z".to_string()), + host_mode: Some(0o750), + host_ownership: Some(Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + }), + }); + let typed_system = MockSystem::new(); + typed_system.ensure_podman_secret("dummy", "first").unwrap(); + let typed_files = MockFiles::new(); + container(&typed_system, &typed_files, typed).unwrap(); + + assert_eq!( + previous_files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/unit-fixture.container"), + typed_files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/unit-fixture.container") + ); +} + #[test] fn changing_a_created_network_fails_before_replacing_its_quadlet() { let system = MockSystem::new(); diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index 12454001..0ab0e089 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -50,6 +50,8 @@ where host_path: config.data_path, container_path: "/var/syncthing".to_string(), options: Some("z".to_string()), + host_mode: None, + host_ownership: None, }]; let mut extra_config = BTreeMap::new(); diff --git a/ublue/skillet/crates/test-fixture/src/lib.rs b/ublue/skillet/crates/test-fixture/src/lib.rs index 016dfd3f..c701f4fd 100644 --- a/ublue/skillet/crates/test-fixture/src/lib.rs +++ b/ublue/skillet/crates/test-fixture/src/lib.rs @@ -100,11 +100,15 @@ pub fn apply(system: &dyn SystemResource, files: &dyn FileResource) -> Result<() host_path: "/etc/skillet-smoke".to_string(), container_path: "/fixture".to_string(), options: Some("ro,Z".to_string()), + host_mode: None, + host_ownership: None, }, Volume { host_path: "/var/lib/skillet-smoke/data".to_string(), container_path: "/data".to_string(), options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, }, ], secrets: vec![QuadletSecret { diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs index 65166ef9..fc8bc283 100644 --- a/ublue/skillet/crates/unifi/src/lib.rs +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -12,8 +12,6 @@ use thiserror::Error; use tracing::info; const DATA_PATH: &str = "/var/lib/data/unifi"; -const CONTAINER_UID: u32 = 999; -const CONTAINER_GID: u32 = 999; #[derive(Debug, Error)] pub enum UnifiError { @@ -39,15 +37,6 @@ where info!("Applying UniFi Network container..."); files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; files.ensure_btrfs_subvolume(Path::new(DATA_PATH))?; - files.ensure_directory_with_ownership( - Path::new(DATA_PATH), - Some(0o750), - &Ownership { - uid: Some(OwnerIdentity::Id(CONTAINER_UID)), - gid: Some(OwnerIdentity::Id(CONTAINER_GID)), - }, - )?; - let mut extra_config = BTreeMap::new(); extra_config.insert( "Container".to_string(), @@ -89,6 +78,11 @@ where host_path: DATA_PATH.to_string(), container_path: "/unifi".to_string(), options: Some("Z".to_string()), + host_mode: Some(0o750), + host_ownership: Some(Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + }), }], secrets: Vec::new(), config_revisions: Vec::new(), From 72397e21a99598136a7467dd6720bed5fd94b47b Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 03:41:06 +0200 Subject: [PATCH 163/252] refactor(workstation): own provider cleanup journals --- ublue/butane/ACCEPTANCE.md | 18 ++ ublue/design/smoke-vms.md | 10 + ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 2 + ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 16 +- ublue/skillet/AGENTS.md | 4 + .../skillet/crates/cli/src/secret_delivery.rs | 152 +++------ ublue/skillet/crates/workstation/src/lib.rs | 1 + .../workstation/src/provisioning_state.rs | 296 ++++++++++++++++++ .../src/provisioning_state/tests.rs | 160 ++++++++++ 9 files changed, 555 insertions(+), 104 deletions(-) create mode 100644 ublue/skillet/crates/workstation/src/provisioning_state.rs create mode 100644 ublue/skillet/crates/workstation/src/provisioning_state/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 7a4cc4fe..a93eda89 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1102,3 +1102,21 @@ host tool; it still uses normal command approvals. - A regression test confirms the typed metadata does not change the existing Quadlet bytes. VM verification of actual volume ownership, descendants, and service restart behavior remains unverified because `/dev/kvm` is absent. + +### Workstation-owned disposable provisioning state, 2026-10-04 + +- Moved Cloudflare ownership JSON, Tailscale pending-enrollment markers, and + enrolled-device records from the CLI into + `skillet_workstation::provisioning_state`. Filenames are unchanged. New + journals include separate host/environment/instance identity fields; readers + accept the prior JSON and hostname-only marker formats with exact identity + checks. Writes are atomic and mode `0600`; reads and writes reject + symlinks/non-regular files. Retrying with another VM identity fails closed. +- Workspace all-target tests, strict Clippy, and formatting passed. State tests + cover private file mode, state round trips, symlink refusal, idempotent marker + writes, typed identity conflict refusal, legacy state compatibility, and + marker cleanup. The documented + beezelbot Fedora container integration passed twice without a restart on the + second apply; cleanup escalated to SIGKILL after its 10-second timeout. +- No provider resources or live VMs were changed. Full enrollment/cleanup + acceptance remains pending; `/dev/kvm` is absent. diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index eb2cc5ec..de5b3fcc 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -58,6 +58,16 @@ defined domain for inspection and retry. Native domain XML renders through the Rust XML library, preserving disk ownership, fw_cfg and passt networking. Unit and adapter checks pass; live migration acceptance remains deferred. +Tailscale enrollment intent, enrolled-device identity, and Cloudflare DNS/token +ownership metadata are persisted by `skillet_workstation::provisioning_state`. +Writes are atomic and mode `0600`; readers reject symlinks and non-regular +files. An existing pending enrollment marker must match the same VM identity, +so retry cannot silently claim another VM's cleanup journal. +New journals keep host, environment, and instance as separate fields. Legacy +smoke-run records remain readable and are checked against the exact expected +guest hostname, ownership marker, and token name rather than parsed into a new +identity. + ## States and recovery Planned encrypted-root tests add a software TPM2 and UEFI/Secure Boot profile. diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 7ebc3d3c..2851a6cd 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -49,6 +49,8 @@ remaining script owns VM creation, reboot, access, inspection, or disposal. Workstream 4 has now moved production and disposable-VM guest commands onto the shared verified SSH transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability declaration. +Workstation provisioning state now owns provider cleanup journals separately +from the VM manifest and local artifact ownership. Native and Flatpak adapter contract tests and workstation checks remain required; live create/ready/update/reboot/destroy and retained-run acceptance have not yet passed. See diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 34fb097d..b8b7ac9c 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -116,8 +116,16 @@ and VM delivery have duplicate implementations with different validation. HTTP probe no longer build remote shell command strings. Both targets install credentials through the shared typed installer, with activation deferred until all related Caddy credentials are present. +- Implemented: Cloudflare ownership JSON, Tailscale enrollment markers, and + enrolled-device records are persisted through + `skillet_workstation::provisioning_state`. The library writes atomically with + mode `0600`, rejects symlinked/non-regular state files, and binds a repeated + enrollment marker to the same typed host/environment/instance identity. New + journals retain those fields separately; old journal formats remain readable + with exact expected-hostname/marker/token-name checks. No token value is + persisted. - Pending: environment/host credential-delivery orchestration, durable versus - disposable policy, provider/VM cleanup ownership persistence, and production - plus named-VM acceptance remain in CLI modules. Provider and VM lifecycle - orchestration still has mixed CLI/workstation ownership. Finish the remaining - sequence items before closing this workstream. + disposable provider sequencing, and production plus named-VM acceptance + remain in CLI modules. Provider and VM lifecycle orchestration still has + mixed CLI/workstation ownership. Finish the remaining sequence items before + closing this workstream. diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index deac9f97..1fcd79e9 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -118,6 +118,10 @@ tracks existing violations and their migration, not completed implementation. continue with later operations when an ownership check fails. Address destructive runtime actions by the recorded immutable identity, not only a reusable name. +- Persist external cleanup intent before provider mutations. Bind each + ownership journal to the complete typed host, environment, and instance + identity; reject mismatches on retry and preserve journals when cleanup is + incomplete. - Verify deployed artifact bytes and required metadata before recording delivery success. Keep original capture evidence distinct from subsequent deployments; a partially successful transfer must remain safely retryable. diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 5ad549e8..ed4acc91 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -2,14 +2,13 @@ use super::{ butane_root, vm_name, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, VmProvisionArgs, }; use anyhow::{anyhow, Context, Result}; -use serde::{Deserialize, Serialize}; use skillet_vm::transport::GuestTransport as _; +use skillet_workstation::provisioning_state::{self, CloudflareVmOwnership, ProvisioningIdentity}; use skillet_workstation::tailscale; use skillet_workstation::vault::Vault; use std::{ fs, - io::{Read as _, Write as _}, - os::unix::fs::PermissionsExt as _, + io::Read as _, path::Path, process::{Command, Stdio}, }; @@ -345,8 +344,14 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let credentials = tailscale_credentials(&vault)?; let expected_hostname = name.as_str(); let policy = UiEnvironmentName::Test.policy(); + let provisioning_identity = + ProvisioningIdentity::new(&args.hostname, policy.name(), &args.instance); - write_pending_tailscale(&run_dir, expected_hostname)?; + provisioning_state::mark_tailscale_pending( + &run_dir, + &provisioning_identity, + expected_hostname, + )?; let mut addresses = vm_tailscale_addresses(&ssh).unwrap_or_default(); if addresses.is_empty() { let auth_key = tailscale::create_auth_key( @@ -377,11 +382,11 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { policy.tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::DisposableVm), &addresses, )?; - save_vm_tailscale_record(&run_dir, &record)?; + provisioning_state::save_tailscale_record(&run_dir, &provisioning_identity, &record)?; if args.with_ui { provision_vm_ui(args, &run_dir, &ssh, &mut vault, &record)?; } - remove_pending_tailscale(&run_dir)?; + provisioning_state::remove_tailscale_pending(&run_dir)?; println!("Tailscale connected VM {expected_hostname}"); Ok(()) } @@ -402,8 +407,15 @@ pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { if !pending.exists() && !record_path.exists() { return Ok(()); } + let policy = UiEnvironmentName::Test.policy(); + let provisioning_identity = + ProvisioningIdentity::new(&args.hostname, policy.name(), &args.instance); let expected = if record_path.exists() { - Some(read_vm_tailscale_record(&record_path)?) + Some(provisioning_state::load_tailscale_record( + &record_path, + &provisioning_identity, + &name, + )?) } else { None }; @@ -413,7 +425,6 @@ pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { }; let vault = Vault::open(&vault_path, args.key_file.as_deref())?; let credentials = tailscale_credentials(&vault)?; - let policy = UiEnvironmentName::Test.policy(); if tailscale::remove_device_for_hostname( &credentials, &name, @@ -424,25 +435,13 @@ pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { { println!("Removed Tailscale device for {name}"); } - remove_pending_tailscale(&run_dir)?; + provisioning_state::remove_tailscale_pending(&run_dir)?; if record_path.exists() { fs::remove_file(record_path).context("removing Tailscale VM metadata")?; } Ok(()) } -#[derive(Clone, Debug, Deserialize, Serialize)] -struct CloudflareVmOwnership { - environment: String, - marker: String, - zone_id: String, - ui_domain: String, - token_name: String, - token_id: Option, - expires_on: Option, - record_ids: Vec, -} - fn provision_vm_ui( args: &VmProvisionArgs, run_dir: &Path, @@ -471,16 +470,7 @@ fn provision_vm_ui( let zone = api.zone(&creator, &zone_id)?; let ui_domain = skillet_caddy::resolve_ui_domain(&zone.name, domain_prefix.as_deref()) .context("resolving test relative UI domain beneath its Cloudflare zone")?; - let host_ui = skillet_hosts::ui_config_for_host(&args.hostname) - .ok_or_else(|| anyhow!("host {} has no declared UI services", args.hostname))?; - let sites = skillet_caddy::CaddySites::from_host( - &args.hostname, - &skillet_caddy::UiEnvironment { - ui_domain: ui_domain.clone(), - acme_staging: policy.acme_staging(), - }, - &host_ui.services, - )?; + let sites = test_vm_caddy_sites(&args.hostname, &ui_domain, policy.acme_staging())?; let dns = skillet_workstation::cloudflare::desired_records( &sites.machine_hostname, &device.addresses, @@ -490,6 +480,11 @@ fn provision_vm_ui( let token_name = format!("skillet:test:{}-{}", args.hostname, args.instance); let metadata_path = run_dir.join("cloudflare.json"); let mut ownership = CloudflareVmOwnership { + identity: Some(ProvisioningIdentity::new( + &args.hostname, + environment, + &args.instance, + )), environment: environment.to_string(), marker, zone_id, @@ -500,7 +495,7 @@ fn provision_vm_ui( record_ids: Vec::new(), }; let account_id = skillet_workstation::cloudflare::Cloudflare::account_id(&zone)?.to_string(); - write_cloudflare_ownership(&metadata_path, &ownership)?; + provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; ensure_vault_unchanged(vault)?; let issued = api.create_zone_token( &creator, @@ -511,7 +506,7 @@ fn provision_vm_ui( )?; ownership.token_id = Some(issued.id.clone()); ownership.expires_on.clone_from(&issued.expires_on); - write_cloudflare_ownership(&metadata_path, &ownership)?; + provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; let work = (|| { let owned = api.reconcile_dns( &issued.value, @@ -521,7 +516,7 @@ fn provision_vm_ui( &dns, )?; ownership.record_ids = owned.records.into_iter().map(|record| record.id).collect(); - write_cloudflare_ownership(&metadata_path, &ownership)?; + provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; ssh.install_deferred( "caddy_sites", "skillet-caddy-apply.service", @@ -550,6 +545,23 @@ fn provision_vm_ui( Ok(()) } +fn test_vm_caddy_sites( + hostname: &str, + ui_domain: &str, + acme_staging: bool, +) -> Result { + let host_ui = skillet_hosts::ui_config_for_host(hostname) + .ok_or_else(|| anyhow!("host {hostname} has no declared UI services"))?; + Ok(skillet_caddy::CaddySites::from_host( + hostname, + &skillet_caddy::UiEnvironment { + ui_domain: ui_domain.to_string(), + acme_staging, + }, + &host_ui.services, + )?) +} + fn verify_caddy_denies_non_tailnet_probe( ssh: &VmSsh, sites: &skillet_caddy::CaddySites, @@ -593,10 +605,13 @@ fn verify_caddy_denies_non_tailnet_probe( } fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<()> { - let bytes = fs::read(metadata_path).context("reading Cloudflare VM ownership metadata")?; - let ownership: CloudflareVmOwnership = - serde_json::from_slice(&bytes).context("decoding Cloudflare VM ownership metadata")?; + let ownership = provisioning_state::load_cloudflare_ownership(metadata_path) + .context("reading Cloudflare VM ownership metadata")?; let policy = UiEnvironmentName::Test.policy(); + let expected_identity = + ProvisioningIdentity::new(&args.hostname, policy.name(), &args.instance); + provisioning_state::validate_cloudflare_identity(&ownership, &expected_identity) + .context("Cloudflare VM cleanup identity mismatch")?; if ownership.environment != policy.name() { return Err(anyhow!( "refusing VM cleanup for a non-test Cloudflare environment" @@ -680,23 +695,6 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( Ok(()) } -fn write_cloudflare_ownership(path: &Path, ownership: &CloudflareVmOwnership) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| anyhow!("Cloudflare metadata has no parent directory"))?; - let mut file = tempfile::NamedTempFile::new_in(parent) - .context("creating Cloudflare ownership metadata")?; - file.as_file_mut() - .write_all(&serde_json::to_vec(ownership)?)?; - file.as_file() - .set_permissions(fs::Permissions::from_mode(0o600))?; - file.as_file().sync_all()?; - file.persist(path) - .context("recording Cloudflare ownership metadata")?; - fs::File::open(parent)?.sync_all()?; - Ok(()) -} - struct VmSsh { host: String, transport: skillet_vm::transport::SshTransport, @@ -840,52 +838,6 @@ fn wait_for_vm_tailscale(ssh: &VmSsh) -> Result Result<()> { - let path = run_dir.join("tailscale-pending"); - if path.exists() || path.is_symlink() { - return Ok(()); - } - let mut file = tempfile::NamedTempFile::new_in(run_dir)?; - file.as_file_mut().write_all(hostname.as_bytes())?; - file.as_file() - .set_permissions(fs::Permissions::from_mode(0o600))?; - file.as_file().sync_all()?; - file.persist_noclobber(path) - .context("recording pending Tailscale cleanup")?; - Ok(()) -} - -fn save_vm_tailscale_record(run_dir: &Path, record: &tailscale::DeviceRecord) -> Result<()> { - let path = run_dir.join("tailscale.json"); - if path.is_symlink() { - return Err(anyhow!("refusing symlinked Tailscale VM metadata")); - } - let mut file = tempfile::NamedTempFile::new_in(run_dir)?; - serde_json::to_writer(file.as_file_mut(), record)?; - file.as_file() - .set_permissions(fs::Permissions::from_mode(0o600))?; - file.as_file().sync_all()?; - file.persist(&path) - .context("saving Tailscale VM identity")?; - Ok(()) -} - -fn read_vm_tailscale_record(path: &Path) -> Result { - let metadata = fs::symlink_metadata(path)?; - if !metadata.is_file() || metadata.file_type().is_symlink() { - return Err(anyhow!("Tailscale VM metadata is not a regular file")); - } - serde_json::from_slice(&fs::read(path)?).context("reading Tailscale VM metadata") -} - -fn remove_pending_tailscale(run_dir: &Path) -> Result<()> { - let pending = run_dir.join("tailscale-pending"); - if pending.exists() { - fs::remove_file(pending).context("removing pending Tailscale metadata")?; - } - Ok(()) -} - fn read_vm_port(manifest: &Path) -> Result { let contents = fs::read_to_string(manifest)?; let value = contents diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index e199ebe9..5e2df33d 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -2,5 +2,6 @@ pub mod cloudflare; pub mod provisioning_policy; +pub mod provisioning_state; pub mod tailscale; pub mod vault; diff --git a/ublue/skillet/crates/workstation/src/provisioning_state.rs b/ublue/skillet/crates/workstation/src/provisioning_state.rs new file mode 100644 index 00000000..451177fe --- /dev/null +++ b/ublue/skillet/crates/workstation/src/provisioning_state.rs @@ -0,0 +1,296 @@ +//! Durable ownership records for disposable provider resources. + +use crate::tailscale::DeviceRecord; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File, Permissions}, + io, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, +}; +use tempfile::NamedTempFile; +use thiserror::Error; + +const PRIVATE_FILE_MODE: u32 = 0o600; + +#[derive(Debug, Error)] +pub enum ProvisioningStateError { + #[error("provisioning state I/O failed: {0}")] + Io(#[from] io::Error), + #[error("provisioning state JSON is invalid: {0}")] + Json(#[from] serde_json::Error), + #[error("refusing invalid provisioning state path: {0}")] + InvalidPath(&'static str), + #[error("could not atomically persist provisioning state at {path}: {source}")] + Persist { path: PathBuf, source: io::Error }, + #[error("pending Tailscale identity does not match the recorded VM")] + PendingIdentityMismatch, + #[error( + "provider ownership identity does not match the requested host, environment, and instance" + )] + IdentityMismatch, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProvisioningIdentity { + pub host: String, + pub environment: String, + pub instance: String, +} + +impl ProvisioningIdentity { + pub fn new( + host: impl Into, + environment: impl Into, + instance: impl Into, + ) -> Self { + Self { + host: host.into(), + environment: environment.into(), + instance: instance.into(), + } + } +} + +/// External DNS/token ownership needed to recover or clean up a test VM. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudflareVmOwnership { + /// Present in newly written journals; absent only in the legacy JSON shape. + #[serde(default)] + pub identity: Option, + pub environment: String, + pub marker: String, + pub zone_id: String, + pub ui_domain: String, + pub token_name: String, + pub token_id: Option, + pub expires_on: Option, + pub record_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct TailscaleVmOwnership { + identity: ProvisioningIdentity, + device: DeviceRecord, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct PendingTailscaleIdentity { + identity: ProvisioningIdentity, + hostname: String, +} + +pub fn save_cloudflare_ownership( + path: &Path, + ownership: &CloudflareVmOwnership, +) -> Result<(), ProvisioningStateError> { + write_json_atomically(path, ownership) +} + +pub fn load_cloudflare_ownership( + path: &Path, +) -> Result { + read_json(path) +} + +/// Confirm that a Cloudflare journal belongs to the requested typed identity. +/// Legacy journals are still validated by their exact marker and token name at +/// the call site; new journals must match every identity field here. +pub fn validate_cloudflare_identity( + ownership: &CloudflareVmOwnership, + expected: &ProvisioningIdentity, +) -> Result<(), ProvisioningStateError> { + if ownership.environment != expected.environment + || ownership + .identity + .as_ref() + .is_some_and(|identity| identity != expected) + { + return Err(ProvisioningStateError::IdentityMismatch); + } + Ok(()) +} + +pub fn save_tailscale_record( + run_directory: &Path, + identity: &ProvisioningIdentity, + record: &DeviceRecord, +) -> Result<(), ProvisioningStateError> { + write_json_atomically( + &run_directory.join("tailscale.json"), + &TailscaleVmOwnership { + identity: identity.clone(), + device: record.clone(), + }, + ) +} + +pub fn load_tailscale_record( + path: &Path, + expected_identity: &ProvisioningIdentity, + expected_hostname: &str, +) -> Result { + let bytes = read_bytes(path)?; + if let Ok(state) = serde_json::from_slice::(&bytes) { + if &state.identity != expected_identity || state.device.hostname != expected_hostname { + return Err(ProvisioningStateError::IdentityMismatch); + } + return Ok(state.device); + } + let legacy: DeviceRecord = serde_json::from_slice(&bytes)?; + if legacy.hostname != expected_hostname { + return Err(ProvisioningStateError::IdentityMismatch); + } + Ok(legacy) +} + +/// Persist cleanup intent before creating a disposable Tailscale device. +/// Repeated calls must describe the same VM identity. +pub fn mark_tailscale_pending( + run_directory: &Path, + identity: &ProvisioningIdentity, + hostname: &str, +) -> Result<(), ProvisioningStateError> { + let path = run_directory.join("tailscale-pending"); + ensure_parent(&path)?; + match fs::symlink_metadata(&path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ProvisioningStateError::InvalidPath( + "pending Tailscale state must be a regular file", + )); + } + let contents = fs::read(&path)?; + if let Ok(pending) = serde_json::from_slice::(&contents) { + if pending.identity == *identity && pending.hostname == hostname { + return Ok(()); + } + return Err(ProvisioningStateError::PendingIdentityMismatch); + } + if String::from_utf8_lossy(&contents) == hostname { + // Legacy markers only contain the deterministic VM hostname. + // The caller also validates host/environment/instance from + // the VM manifest before acting on this retained run. + return Ok(()); + } + return Err(ProvisioningStateError::PendingIdentityMismatch); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + + let mut file = NamedTempFile::new_in(run_directory)?; + serde_json::to_writer( + file.as_file_mut(), + &PendingTailscaleIdentity { + identity: identity.clone(), + hostname: hostname.to_string(), + }, + )?; + file.as_file() + .set_permissions(Permissions::from_mode(PRIVATE_FILE_MODE))?; + file.as_file().sync_all()?; + match file.persist_noclobber(&path) { + Ok(_) => {} + Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ProvisioningStateError::PendingIdentityMismatch); + } + let contents = fs::read(&path)?; + let matches_new = serde_json::from_slice::(&contents) + .is_ok_and(|pending| pending.identity == *identity && pending.hostname == hostname); + let matches_legacy = String::from_utf8_lossy(&contents) == hostname; + if !matches_new && !matches_legacy { + return Err(ProvisioningStateError::PendingIdentityMismatch); + } + } + Err(error) => { + return Err(ProvisioningStateError::Persist { + path, + source: error.error, + }); + } + } + File::open(run_directory)?.sync_all()?; + Ok(()) +} + +pub fn remove_tailscale_pending(run_directory: &Path) -> Result<(), ProvisioningStateError> { + let path = run_directory.join("tailscale-pending"); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err( + ProvisioningStateError::InvalidPath("pending Tailscale state must be a regular file"), + ), + Ok(_) => { + fs::remove_file(path)?; + File::open(run_directory)?.sync_all()?; + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn write_json_atomically( + path: &Path, + value: &T, +) -> Result<(), ProvisioningStateError> { + let parent = path.parent().ok_or(ProvisioningStateError::InvalidPath( + "metadata file has no parent", + ))?; + ensure_parent(path)?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(ProvisioningStateError::InvalidPath( + "metadata destination must be a regular file", + )); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let mut file = NamedTempFile::new_in(parent)?; + serde_json::to_writer(file.as_file_mut(), value)?; + file.as_file() + .set_permissions(Permissions::from_mode(PRIVATE_FILE_MODE))?; + file.as_file().sync_all()?; + file.persist(path) + .map_err(|error| ProvisioningStateError::Persist { + path: path.to_path_buf(), + source: error.error, + })?; + File::open(parent)?.sync_all()?; + Ok(()) +} + +fn read_json Deserialize<'de>>(path: &Path) -> Result { + Ok(serde_json::from_slice(&read_bytes(path)?)?) +} + +fn read_bytes(path: &Path) -> Result, ProvisioningStateError> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ProvisioningStateError::InvalidPath( + "metadata source must be a regular file", + )); + } + Ok(fs::read(path)?) +} + +fn ensure_parent(path: &Path) -> Result<(), ProvisioningStateError> { + let parent = path.parent().ok_or(ProvisioningStateError::InvalidPath( + "metadata file has no parent", + ))?; + let metadata = fs::symlink_metadata(parent)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(ProvisioningStateError::InvalidPath( + "metadata parent must be a real directory", + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "provisioning_state/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs b/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs new file mode 100644 index 00000000..fdc265b8 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs @@ -0,0 +1,160 @@ +use super::*; +use crate::tailscale::DeviceRecord; +use std::{ + collections::BTreeSet, + fs, + os::unix::fs::{symlink, PermissionsExt}, +}; + +fn ownership() -> CloudflareVmOwnership { + CloudflareVmOwnership { + environment: "test".to_string(), + identity: Some(identity()), + marker: "skillet:test:host:instance".to_string(), + zone_id: "zone-id".to_string(), + ui_domain: "ui.example.invalid".to_string(), + token_name: "skillet:test:host-instance".to_string(), + token_id: Some("token-id".to_string()), + expires_on: Some("2030-01-01T00:00:00Z".to_string()), + record_ids: vec!["record-id".to_string()], + } +} + +fn identity() -> ProvisioningIdentity { + ProvisioningIdentity { + host: "host".to_string(), + environment: "test".to_string(), + instance: "instance".to_string(), + } +} + +#[test] +fn cloudflare_ownership_is_private_atomic_json_state() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cloudflare.json"); + let value = ownership(); + + save_cloudflare_ownership(&path, &value).unwrap(); + + assert_eq!(load_cloudflare_ownership(&path).unwrap(), value); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + PRIVATE_FILE_MODE + ); + validate_cloudflare_identity(&value, &identity()).unwrap(); + let mut wrong = identity(); + wrong.instance = "other".to_string(); + assert!(matches!( + validate_cloudflare_identity(&value, &wrong), + Err(ProvisioningStateError::IdentityMismatch) + )); +} + +#[test] +fn legacy_cloudflare_journal_remains_readable_but_new_identity_mismatches_fail() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cloudflare.json"); + let mut legacy = ownership(); + legacy.identity = None; + fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + + let loaded = load_cloudflare_ownership(&path).unwrap(); + assert_eq!(loaded, legacy); + validate_cloudflare_identity(&loaded, &identity()).unwrap(); +} + +#[test] +fn tailscale_record_is_private_and_symlinks_are_rejected() { + let directory = tempfile::tempdir().unwrap(); + let record = DeviceRecord { + id: "device-id".to_string(), + hostname: "host-instance".to_string(), + addresses: BTreeSet::from(["100.64.0.10".to_string()]), + }; + save_tailscale_record(directory.path(), &identity(), &record).unwrap(); + assert_eq!( + load_tailscale_record( + &directory.path().join("tailscale.json"), + &identity(), + "host-instance" + ) + .unwrap(), + record + ); + let mut wrong = identity(); + wrong.host = "other".to_string(); + assert!(matches!( + load_tailscale_record( + &directory.path().join("tailscale.json"), + &wrong, + "host-instance" + ), + Err(ProvisioningStateError::IdentityMismatch) + )); + + let target = directory.path().join("target.json"); + fs::write(&target, b"{}").unwrap(); + let link = directory.path().join("linked.json"); + symlink(target, &link).unwrap(); + assert!(matches!( + load_tailscale_record(&link, &identity(), "host-instance"), + Err(ProvisioningStateError::InvalidPath(_)) + )); + assert!(matches!( + save_cloudflare_ownership(&link, &ownership()), + Err(ProvisioningStateError::InvalidPath(_)) + )); +} + +#[test] +fn legacy_tailscale_record_is_accepted_only_for_its_expected_hostname() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("tailscale.json"); + let legacy = DeviceRecord { + id: "legacy-device-id".to_string(), + hostname: "host-instance".to_string(), + addresses: BTreeSet::new(), + }; + fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + + assert_eq!( + load_tailscale_record(&path, &identity(), "host-instance").unwrap(), + legacy + ); + assert!(matches!( + load_tailscale_record(&path, &identity(), "other-instance"), + Err(ProvisioningStateError::IdentityMismatch) + )); +} + +#[test] +fn pending_tailscale_marker_is_repeatable_and_identity_bound() { + let directory = tempfile::tempdir().unwrap(); + mark_tailscale_pending(directory.path(), &identity(), "host-instance").unwrap(); + mark_tailscale_pending(directory.path(), &identity(), "host-instance").unwrap(); + assert!(matches!( + mark_tailscale_pending(directory.path(), &identity(), "another-instance"), + Err(ProvisioningStateError::PendingIdentityMismatch) + )); + let mut wrong = identity(); + wrong.instance = "other".to_string(); + assert!(matches!( + mark_tailscale_pending(directory.path(), &wrong, "host-instance"), + Err(ProvisioningStateError::PendingIdentityMismatch) + )); + remove_tailscale_pending(directory.path()).unwrap(); + remove_tailscale_pending(directory.path()).unwrap(); + assert!(!directory.path().join("tailscale-pending").exists()); +} + +#[test] +fn legacy_pending_marker_remains_usable_when_hostname_matches() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("tailscale-pending"), "host-instance").unwrap(); + + mark_tailscale_pending(directory.path(), &identity(), "host-instance").unwrap(); + assert!(matches!( + mark_tailscale_pending(directory.path(), &identity(), "other-instance"), + Err(ProvisioningStateError::PendingIdentityMismatch) + )); +} From 5bfbb1c44e04dd4591d0b06dde05a425d6096194 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 03:50:11 +0200 Subject: [PATCH 164/252] refactor(workstation): share private UI provisioning plan --- ublue/butane/ACCEPTANCE.md | 17 +++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 5 + ublue/plan/SKILLET-REFACTOR.md | 2 +- ublue/skillet/Cargo.lock | 1 + .../skillet/crates/cli/src/secret_delivery.rs | 75 ++++--------- ublue/skillet/crates/workstation/Cargo.toml | 1 + ublue/skillet/crates/workstation/src/lib.rs | 1 + .../crates/workstation/src/ui_provisioning.rs | 56 ++++++++++ .../workstation/src/ui_provisioning/tests.rs | 105 ++++++++++++++++++ 9 files changed, 211 insertions(+), 52 deletions(-) create mode 100644 ublue/skillet/crates/workstation/src/ui_provisioning.rs create mode 100644 ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index a93eda89..3275026d 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1120,3 +1120,20 @@ host tool; it still uses normal command approvals. second apply; cleanup escalated to SIGKILL after its 10-second timeout. - No provider resources or live VMs were changed. Full enrollment/cleanup acceptance remains pending; `/dev/kvm` is absent. + +### Shared private UI provisioning plan, 2026-10-04 + +- Added `skillet_workstation::ui_provisioning` as the shared derivation for + profile-declared Caddy sites and Cloudflare A/AAAA/CNAME records. Production + and disposable VM delivery now use the same plan and explicit environment + policy. It reads the services from the canonical host profile, including + rendered aliases, without host-specific UI code in the delivery path. +- The three focused UI plan tests passed, followed by all workspace tests + (including 33 workstation tests), strict all-target Clippy, and format check. +- `cargo run --offline --manifest-path skillet/Cargo.toml --bin skillet -- + test run beezelbot --phase base --image fedora:latest` exited 0. Both applies + succeeded; the repeat issued no service start/restart. Podman needed SIGKILL + after its 10-second stop timeout during test-container cleanup. +- This exercised derivation and the existing base container fixture only. No + Cloudflare mutation, credential delivery, or live VM check was performed. + `/dev/kvm` is unavailable on this workstation. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index b8b7ac9c..057bead3 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -124,6 +124,11 @@ and VM delivery have duplicate implementations with different validation. journals retain those fields separately; old journal formats remain readable with exact expected-hostname/marker/token-name checks. No token value is persisted. +- Implemented: `skillet_workstation::ui_provisioning` derives Caddy sites and + machine/service/alias DNS records from the canonical host profile, explicit + environment policy, Cloudflare zone domain, optional relative UI prefix, and + Tailscale addresses. Production delivery and disposable VM provisioning use + this shared plan, removing duplicate derivation and host-specific UI logic. - Pending: environment/host credential-delivery orchestration, durable versus disposable provider sequencing, and production plus named-VM acceptance remain in CLI modules. Provider and VM lifecycle orchestration still has diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index d6db130e..298a76e1 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -23,7 +23,7 @@ assignments to execute concurrently against the same interfaces. | 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; live acceptance pending | -| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress; production and VM credential delivery now share verified transport and installation | +| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress; production and VM credential delivery share transport, installation, and UI/DNS planning | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | In progress | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Implemented; VM acceptance pending | | 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Implemented; VM acceptance pending | diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index f9a3d56e..a34f599b 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2488,6 +2488,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "skillet_caddy", + "skillet_hosts", "tempfile", "thiserror 1.0.69", ] diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index ed4acc91..71f60411 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -15,11 +15,6 @@ use std::{ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { validate_delivery_service(&args.hostname, &args.service)?; - let ui_config = if args.service == "caddy" { - skillet_hosts::ui_config_for_host(&args.hostname) - } else { - None - }; let database = match &args.database { Some(path) => path.clone(), None => default_database_path()?, @@ -79,7 +74,7 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { )?; Ok(()) } - "caddy" => deliver_caddy_from_vault(args, &mut vault, ui_config.as_ref()), + "caddy" => deliver_caddy_from_vault(args, &mut vault), _ => Err(anyhow!("unsupported secret service {}", args.service)), } } @@ -99,12 +94,7 @@ fn validate_delivery_service(hostname: &str, service: &str) -> Result, -) -> Result<()> { - let ui_config = ui_config.ok_or_else(|| anyhow!("missing host UI declaration"))?; +fn deliver_caddy_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Result<()> { let policy = args.environment.policy(); let environment = policy.name(); let domain_path = policy.ui_domain_entry(); @@ -124,34 +114,33 @@ fn deliver_caddy_from_vault( })?; let cloudflare = skillet_workstation::cloudflare::Cloudflare::new(); let zone = cloudflare.zone(&creator, zone_id)?; - let domain = skillet_caddy::resolve_ui_domain(&zone.name, domain_prefix.as_deref()) - .context("resolving KeePassXC relative UI domain beneath its Cloudflare zone")?; - let sites = skillet_caddy::CaddySites::from_host( - &args.hostname, - &skillet_caddy::UiEnvironment { - ui_domain: domain.clone(), - acme_staging: policy.acme_staging(), - }, - &ui_config.services, - )?; let tailnet = tailscale_credentials(vault)?; let device = tailscale::find_device_by_hostname( &tailnet, &args.hostname, policy.tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::ProductionHost), )?; - let dns_marker = format!("skillet:{environment}:{}", args.hostname); - let dns = skillet_workstation::cloudflare::desired_records( - &sites.machine_hostname, + let ui_plan = skillet_workstation::ui_provisioning::build_ui_provisioning_plan( + &args.hostname, + policy, + &zone.name, + domain_prefix.as_deref(), &device.addresses, - &sites, )?; + let sites = ui_plan.sites; + let dns_marker = format!("skillet:{environment}:{}", args.hostname); ensure_vault_unchanged(vault)?; let account_id = skillet_workstation::cloudflare::Cloudflare::account_id(&zone)?; let token = host_acme_token(args, vault, &cloudflare, &creator, zone_id, account_id)?; cloudflare.zone(&token, zone_id)?; ensure_vault_unchanged(vault)?; - cloudflare.reconcile_dns(&token, zone_id, &dns_marker, &domain, &dns)?; + cloudflare.reconcile_dns( + &token, + zone_id, + &dns_marker, + &sites.ui_domain, + &ui_plan.dns_records, + )?; ensure_vault_unchanged(vault)?; let sites = serde_json::to_string(&sites)?; for (credential, value) in [ @@ -468,14 +457,15 @@ fn provision_vm_ui( skillet_workstation::cloudflare::validate_zone_id(&zone_id)?; let api = skillet_workstation::cloudflare::Cloudflare::new(); let zone = api.zone(&creator, &zone_id)?; - let ui_domain = skillet_caddy::resolve_ui_domain(&zone.name, domain_prefix.as_deref()) - .context("resolving test relative UI domain beneath its Cloudflare zone")?; - let sites = test_vm_caddy_sites(&args.hostname, &ui_domain, policy.acme_staging())?; - let dns = skillet_workstation::cloudflare::desired_records( - &sites.machine_hostname, + let ui_plan = skillet_workstation::ui_provisioning::build_ui_provisioning_plan( + &args.hostname, + policy, + &zone.name, + domain_prefix.as_deref(), &device.addresses, - &sites, )?; + let sites = ui_plan.sites; + let ui_domain = sites.ui_domain.clone(); let marker = format!("skillet:test:{}:{}", args.hostname, args.instance); let token_name = format!("skillet:test:{}-{}", args.hostname, args.instance); let metadata_path = run_dir.join("cloudflare.json"); @@ -513,7 +503,7 @@ fn provision_vm_ui( &ownership.zone_id, &ownership.marker, &ownership.ui_domain, - &dns, + &ui_plan.dns_records, )?; ownership.record_ids = owned.records.into_iter().map(|record| record.id).collect(); provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; @@ -545,23 +535,6 @@ fn provision_vm_ui( Ok(()) } -fn test_vm_caddy_sites( - hostname: &str, - ui_domain: &str, - acme_staging: bool, -) -> Result { - let host_ui = skillet_hosts::ui_config_for_host(hostname) - .ok_or_else(|| anyhow!("host {hostname} has no declared UI services"))?; - Ok(skillet_caddy::CaddySites::from_host( - hostname, - &skillet_caddy::UiEnvironment { - ui_domain: ui_domain.to_string(), - acme_staging, - }, - &host_ui.services, - )?) -} - fn verify_caddy_denies_non_tailnet_probe( ssh: &VmSsh, sites: &skillet_caddy::CaddySites, diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml index 565403e2..859e58e5 100644 --- a/ublue/skillet/crates/workstation/Cargo.toml +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -17,5 +17,6 @@ serde.workspace = true serde_json.workspace = true sha2.workspace = true skillet_caddy.workspace = true +skillet_hosts.workspace = true tempfile.workspace = true thiserror.workspace = true diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index 5e2df33d..e9e68526 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -4,4 +4,5 @@ pub mod cloudflare; pub mod provisioning_policy; pub mod provisioning_state; pub mod tailscale; +pub mod ui_provisioning; pub mod vault; diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs new file mode 100644 index 00000000..3ba241d4 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -0,0 +1,56 @@ +//! Shared host/environment UI hostname, Caddy, and DNS planning. + +use crate::{ + cloudflare::{self, CloudflareError, DesiredRecord}, + provisioning_policy::ProvisioningPolicy, +}; +use skillet_caddy::{CaddyError, CaddySites, UiEnvironment}; +use std::collections::BTreeSet; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum UiProvisioningError { + #[error("host profile {0} declares no private UI services")] + NoUiServices(String), + #[error(transparent)] + Caddy(#[from] CaddyError), + #[error(transparent)] + Cloudflare(#[from] CloudflareError), +} + +/// Desired public DNS and Caddy configuration for one declared host profile. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiProvisioningPlan { + pub sites: CaddySites, + pub dns_records: Vec, +} + +pub fn build_ui_provisioning_plan( + host: &str, + policy: ProvisioningPolicy, + zone_domain: &str, + relative_ui_domain: Option<&str>, + addresses: &BTreeSet, +) -> Result { + let profile = skillet_hosts::profile_for_name(host) + .ok_or_else(|| UiProvisioningError::NoUiServices(host.to_string()))?; + let services = profile.ui_services(); + if services.is_empty() { + return Err(UiProvisioningError::NoUiServices(host.to_string())); + } + let ui_domain = skillet_caddy::resolve_ui_domain(zone_domain, relative_ui_domain)?; + let sites = CaddySites::from_host( + host, + &UiEnvironment { + ui_domain, + acme_staging: policy.acme_staging(), + }, + &services, + )?; + let dns_records = cloudflare::desired_records(&sites.machine_hostname, addresses, &sites)?; + Ok(UiProvisioningPlan { sites, dns_records }) +} + +#[cfg(test)] +#[path = "ui_provisioning/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs new file mode 100644 index 00000000..a8085dd5 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -0,0 +1,105 @@ +use super::*; +use crate::provisioning_policy::{Environment, ProvisioningPolicy}; +use std::collections::BTreeSet; + +fn addresses() -> BTreeSet { + BTreeSet::from(["100.64.0.10".to_string(), "fd7a:115c:a1e0::10".to_string()]) +} + +#[test] +fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { + let production = build_ui_provisioning_plan( + "beezelbot", + ProvisioningPolicy::new(Environment::Production), + "example.invalid", + Some("ui"), + &addresses(), + ) + .unwrap(); + let test = build_ui_provisioning_plan( + "beezelbot", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + Some("smoke.ui"), + &addresses(), + ) + .unwrap(); + + assert_eq!( + production.sites.machine_hostname, + "beezelbot.ui.example.invalid" + ); + assert_eq!( + test.sites.machine_hostname, + "beezelbot.smoke.ui.example.invalid" + ); + assert!(!production.sites.acme_staging); + assert!(test.sites.acme_staging); + assert_eq!(production.sites.services.len(), test.sites.services.len()); + assert!(production.dns_records.iter().any(|record| record.name + == production.sites.machine_hostname + && record.record_type == "A")); + assert!(production.dns_records.iter().any(|record| { + record.name == production.sites.machine_hostname && record.record_type == "AAAA" + })); +} + +#[test] +fn each_plan_uses_only_the_calling_profile_services_and_aliases() { + let plan = build_ui_provisioning_plan( + "clamps", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + None, + &addresses(), + ) + .unwrap(); + + let declared = skillet_hosts::profile_for_name("clamps") + .unwrap() + .ui_services(); + let declared_names = declared + .iter() + .map(|service| service.name.as_str()) + .collect::>(); + let planned_names = plan + .sites + .services + .iter() + .map(|service| service.name.as_str()) + .collect::>(); + assert_eq!(planned_names, declared_names); + for alias in plan + .sites + .services + .iter() + .flat_map(|service| &service.aliases) + { + assert!(plan.dns_records.iter().any(|record| record.name == *alias)); + } +} + +#[test] +fn profiles_without_ui_and_incomplete_address_families_fail_closed() { + assert!(matches!( + build_ui_provisioning_plan( + "agent", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + None, + &addresses() + ), + Err(UiProvisioningError::NoUiServices(_)) + )); + let only_v4 = BTreeSet::from(["100.64.0.10".to_string()]); + assert!(matches!( + build_ui_provisioning_plan( + "beezelbot", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + None, + &only_v4 + ), + Err(UiProvisioningError::Cloudflare(_)) + )); +} From b442dfce4f8f3ecc276634cd3dad41f91ca23929 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:04:00 +0200 Subject: [PATCH 165/252] refactor(tailscale): parse guest status in provider --- ublue/butane/ACCEPTANCE.md | 15 +++++++++++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 4 +++ .../skillet/crates/cli/src/secret_delivery.rs | 25 +------------------ .../crates/cli/src/secret_delivery/tests.rs | 14 +---------- .../crates/workstation/src/tailscale/mod.rs | 21 ++++++++++++++++ .../crates/workstation/src/tailscale/tests.rs | 25 ++++++++++++++++++- 6 files changed, 66 insertions(+), 38 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 3275026d..c3d44bb4 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1137,3 +1137,18 @@ host tool; it still uses normal command approvals. - This exercised derivation and the existing base container fixture only. No Cloudflare mutation, credential delivery, or live VM check was performed. `/dev/kvm` is unavailable on this workstation. + +### Tailscale guest status parsing boundary, 2026-10-04 + +- Moved guest `tailscale status --json` interpretation from CLI orchestration + into `skillet_workstation::tailscale`. The provider returns no addresses + until its backend is running and rejects malformed or incomplete running + responses; the CLI retains only remote invocation and bounded retry policy. +- All seven Tailscale provider tests passed, including running/non-running, + invalid-address filtering, malformed JSON, and missing address arrays. The + full workspace tests, strict all-target Clippy, and formatter check passed. +- The documented beezelbot container integration built its target executable + but produced no runtime output for over 45 seconds. No matching Skillet or + Podman worker appeared in the process listing; the command was interrupted. + Treat this integration as unverified for this slice. +- No VM or live provider API was contacted. `/dev/kvm` remains unavailable. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 057bead3..912f9d57 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -129,6 +129,10 @@ and VM delivery have duplicate implementations with different validation. environment policy, Cloudflare zone domain, optional relative UI prefix, and Tailscale addresses. Production delivery and disposable VM provisioning use this shared plan, removing duplicate derivation and host-specific UI logic. +- Implemented: the Tailscale provider parses guest status JSON and validates + running state and IP addresses. VM orchestration now supplies the response + bytes and handles retry timing; provider protocol interpretation and its + malformed-response tests live with the Tailscale adapter. - Pending: environment/host credential-delivery orchestration, durable versus disposable provider sequencing, and production plus named-VM acceptance remain in CLI modules. Provider and VM lifecycle orchestration still has diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 71f60411..37790365 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -773,30 +773,7 @@ fn vm_tailscale_addresses(ssh: &VmSsh) -> Result Result> { - let status: serde_json::Value = - serde_json::from_slice(output).context("decoding Tailscale status returned by the VM")?; - if status - .get("BackendState") - .and_then(serde_json::Value::as_str) - != Some("Running") - { - return Ok(std::collections::BTreeSet::new()); - } - let addresses = status - .get("Self") - .and_then(|value| value.get("TailscaleIPs")) - .and_then(serde_json::Value::as_array) - .ok_or_else(|| anyhow!("Tailscale status has no self addresses"))? - .iter() - .filter_map(serde_json::Value::as_str) - .filter(|address| address.parse::().is_ok()) - .map(ToOwned::to_owned) - .collect::>(); - Ok(addresses) + tailscale::status_addresses(&output.stdout).map_err(anyhow::Error::from) } fn wait_for_vm_tailscale(ssh: &VmSsh) -> Result> { diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 5cb8a5ca..591295aa 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,7 +1,4 @@ -use super::{ - parse_tailscale_addresses, read_vm_port, validate_delivery_service, - validate_tailscale_unit_config, -}; +use super::{read_vm_port, validate_delivery_service, validate_tailscale_unit_config}; #[test] fn vm_port_requires_manifest_range() { @@ -13,15 +10,6 @@ fn vm_port_requires_manifest_range() { assert!(read_vm_port(&file).is_err()); } -#[test] -fn tailscale_status_returns_only_running_valid_ip_addresses() { - let output = br#"{"BackendState":"Running","Self":{"TailscaleIPs":["100.64.0.5","fd7a:115c:a1e0::5","bad"]}}"#; - let addresses = parse_tailscale_addresses(output).unwrap(); - assert_eq!(addresses.len(), 2); - let offline = br#"{"BackendState":"NeedsLogin","Self":{"TailscaleIPs":[]}}"#; - assert!(parse_tailscale_addresses(offline).unwrap().is_empty()); -} - #[test] fn rejects_vm_without_tailscale_systemd_credential() { let old_unit = "LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred"; diff --git a/ublue/skillet/crates/workstation/src/tailscale/mod.rs b/ublue/skillet/crates/workstation/src/tailscale/mod.rs index 16b88fc4..1486a700 100644 --- a/ublue/skillet/crates/workstation/src/tailscale/mod.rs +++ b/ublue/skillet/crates/workstation/src/tailscale/mod.rs @@ -196,6 +196,27 @@ pub struct DeviceRecord { pub addresses: BTreeSet, } +/// Parse the local `tailscale status --json` response used by the guest +/// enrollment probe. A non-running backend has no usable addresses yet. +pub fn status_addresses(output: &[u8]) -> Result> { + let status: Value = serde_json::from_slice(output) + .map_err(|error| source("decoding Tailscale status", error))?; + if status.get("BackendState").and_then(Value::as_str) != Some("Running") { + return Ok(BTreeSet::new()); + } + let addresses = status + .get("Self") + .and_then(|value| value.get("TailscaleIPs")) + .and_then(Value::as_array) + .ok_or_else(|| TailscaleError::Invalid("Tailscale status has no self addresses".into()))? + .iter() + .filter_map(Value::as_str) + .filter(|address| address.parse::().is_ok()) + .map(ToOwned::to_owned) + .collect(); + Ok(addresses) +} + pub struct AuthKey { pub key: String, } diff --git a/ublue/skillet/crates/workstation/src/tailscale/tests.rs b/ublue/skillet/crates/workstation/src/tailscale/tests.rs index 59635cd2..7a762250 100644 --- a/ublue/skillet/crates/workstation/src/tailscale/tests.rs +++ b/ublue/skillet/crates/workstation/src/tailscale/tests.rs @@ -1,7 +1,30 @@ use super::{ create_auth_key, device_record, find_device_by_hostname, form_encode, - remove_device_for_hostname, OAuthCredentials, SMOKE_TAG, + remove_device_for_hostname, status_addresses, OAuthCredentials, SMOKE_TAG, }; + +#[test] +fn guest_status_addresses_require_running_backend_and_filter_invalid_values() { + assert!(status_addresses(br#"{"BackendState":"NeedsLogin"}"#) + .expect("waiting state") + .is_empty()); + assert_eq!( + status_addresses( + br#"{"BackendState":"Running","Self":{"TailscaleIPs":["100.64.0.10","fd7a:115c:a1e0::10","bad"]}}"# + ) + .expect("running status"), + std::collections::BTreeSet::from([ + "100.64.0.10".to_string(), + "fd7a:115c:a1e0::10".to_string() + ]) + ); +} + +#[test] +fn guest_status_addresses_reject_malformed_or_incomplete_running_responses() { + assert!(status_addresses(b"not json").is_err()); + assert!(status_addresses(br#"{"BackendState":"Running"}"#).is_err()); +} use serde_json::json; use std::{ io::{Read, Write}, From 9d007482bac0acf21c01f9296df6cd29086c99c6 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:09:15 +0200 Subject: [PATCH 166/252] refactor(tailscale): share enrollment retry policy --- ublue/butane/ACCEPTANCE.md | 14 +++++++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 3 ++ .../skillet/crates/cli/src/secret_delivery.rs | 15 +++---- .../crates/workstation/src/tailscale/mod.rs | 32 ++++++++++++++- .../crates/workstation/src/tailscale/tests.rs | 40 ++++++++++++++++++- 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index c3d44bb4..8537b989 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1152,3 +1152,17 @@ host tool; it still uses normal command approvals. Podman worker appeared in the process listing; the command was interrupted. Treat this integration as unverified for this slice. - No VM or live provider API was contacted. `/dev/kvm` remains unavailable. + +### Shared Tailscale guest enrollment retry, 2026-10-04 + +- Moved the bounded retry loop into `skillet_workstation::tailscale` while + keeping the guest SSH probe in the CLI. Empty status and transient probe + errors retry until the supplied timeout; tests cover delayed success and + timeout without sleeping. +- All 9 Tailscale provider tests passed. Workspace format, all-target tests, + and strict all-target Clippy passed. +- The beezelbot Fedora base integration exited 0: both applies succeeded and + the repeated apply issued no service start/restart. The disposable container + needed SIGKILL after Podman's 10-second stop timeout during cleanup. This + successful retry supersedes the stalled integration attempt recorded above. +- No VM or provider API was contacted. `/dev/kvm` remains unavailable. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 912f9d57..f6e5a3f9 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -133,6 +133,9 @@ and VM delivery have duplicate implementations with different validation. running state and IP addresses. VM orchestration now supplies the response bytes and handles retry timing; provider protocol interpretation and its malformed-response tests live with the Tailscale adapter. +- Implemented: bounded enrollment polling is also in the Tailscale provider. + The CLI supplies a guest probe and explicit timeout/interval; retry behavior + is covered with deterministic success, transient-error, and timeout tests. - Pending: environment/host credential-delivery orchestration, durable versus disposable provider sequencing, and production plus named-VM acceptance remain in CLI modules. Provider and VM lifecycle orchestration still has diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 37790365..6303113d 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -777,15 +777,12 @@ fn vm_tailscale_addresses(ssh: &VmSsh) -> Result Result> { - for _ in 0..60 { - if let Ok(addresses) = vm_tailscale_addresses(ssh) { - if !addresses.is_empty() { - return Ok(addresses); - } - } - std::thread::sleep(std::time::Duration::from_secs(2)); - } - Err(anyhow!("Tailscale did not connect on the VM within 120 seconds; inspect tailscale.service and its journal")) + tailscale::wait_for_addresses( + || vm_tailscale_addresses(ssh), + std::time::Duration::from_mins(2), + std::time::Duration::from_secs(2), + ) + .map_err(|error| anyhow!("{error}; inspect tailscale.service and its journal")) } fn read_vm_port(manifest: &Path) -> Result { diff --git a/ublue/skillet/crates/workstation/src/tailscale/mod.rs b/ublue/skillet/crates/workstation/src/tailscale/mod.rs index 1486a700..acb3ec94 100644 --- a/ublue/skillet/crates/workstation/src/tailscale/mod.rs +++ b/ublue/skillet/crates/workstation/src/tailscale/mod.rs @@ -1,7 +1,12 @@ use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::{collections::BTreeSet, error::Error as StdError, fmt::Write as _, time::Duration}; +use std::{ + collections::BTreeSet, + error::Error as StdError, + fmt::Write as _, + time::{Duration, Instant}, +}; use thiserror::Error; const API_BASE: &str = "https://api.tailscale.com/api/v2"; @@ -217,6 +222,31 @@ pub fn status_addresses(output: &[u8]) -> Result> { Ok(addresses) } +/// Poll a guest status probe until it reports at least one address or times +/// out. Probe errors and empty status are transient during enrollment. +pub fn wait_for_addresses( + mut probe: impl FnMut() -> std::result::Result, E>, + timeout: Duration, + interval: Duration, +) -> Result> { + let started = Instant::now(); + loop { + if let Ok(addresses) = probe() { + if !addresses.is_empty() { + return Ok(addresses); + } + } + let elapsed = started.elapsed(); + if elapsed >= timeout { + break; + } + std::thread::sleep(interval.min(timeout.saturating_sub(elapsed))); + } + Err(TailscaleError::Invalid( + "Tailscale did not connect on the VM before the enrollment timeout".into(), + )) +} + pub struct AuthKey { pub key: String, } diff --git a/ublue/skillet/crates/workstation/src/tailscale/tests.rs b/ublue/skillet/crates/workstation/src/tailscale/tests.rs index 7a762250..0fe157a0 100644 --- a/ublue/skillet/crates/workstation/src/tailscale/tests.rs +++ b/ublue/skillet/crates/workstation/src/tailscale/tests.rs @@ -1,6 +1,6 @@ use super::{ create_auth_key, device_record, find_device_by_hostname, form_encode, - remove_device_for_hostname, status_addresses, OAuthCredentials, SMOKE_TAG, + remove_device_for_hostname, status_addresses, wait_for_addresses, OAuthCredentials, SMOKE_TAG, }; #[test] @@ -25,6 +25,44 @@ fn guest_status_addresses_reject_malformed_or_incomplete_running_responses() { assert!(status_addresses(b"not json").is_err()); assert!(status_addresses(br#"{"BackendState":"Running"}"#).is_err()); } + +#[test] +fn guest_enrollment_wait_retries_transient_probe_errors_and_empty_status() { + let mut probes = 0; + let addresses = wait_for_addresses( + || { + probes += 1; + match probes { + 1 => Err("SSH is not ready"), + 2 => Ok(std::collections::BTreeSet::new()), + _ => Ok(std::collections::BTreeSet::from( + ["100.64.0.10".to_string()], + )), + } + }, + std::time::Duration::from_secs(1), + std::time::Duration::ZERO, + ) + .expect("eventually connected"); + assert_eq!(probes, 3); + assert_eq!(addresses, ["100.64.0.10".to_string()].into()); +} + +#[test] +fn guest_enrollment_wait_reports_timeout_after_a_final_probe() { + let probes = std::cell::Cell::new(0); + let error = wait_for_addresses( + || { + probes.set(probes.get() + 1); + Ok::<_, &str>(std::collections::BTreeSet::new()) + }, + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ) + .unwrap_err(); + assert_eq!(probes.get(), 1); + assert!(error.to_string().contains("enrollment timeout")); +} use serde_json::json; use std::{ io::{Read, Write}, From 430f2f6be593ae83507b4a9daa2e8600ea5d3150 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:13:14 +0200 Subject: [PATCH 167/252] fix(skillet): fail closed on Tailscale probe errors --- ublue/butane/ACCEPTANCE.md | 13 ++++++++++++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 3 +++ .../skillet/crates/cli/src/secret_delivery.rs | 13 ++++++++++-- .../crates/cli/src/secret_delivery/tests.rs | 20 ++++++++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 8537b989..98b68c66 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1166,3 +1166,16 @@ host tool; it still uses normal command approvals. needed SIGKILL after Podman's 10-second stop timeout during cleanup. This successful retry supersedes the stalled integration attempt recorded above. - No VM or provider API was contacted. `/dev/kvm` remains unavailable. + +### Fail-closed Tailscale enrollment probe, 2026-10-04 + +- Initial enrollment now propagates SSH/status command errors rather than + interpreting them as an empty device state. The new regression test confirms + a failed guest status command refuses enrollment even when stdout resembles + a valid `NeedsLogin` response. +- The focused regression test, full offline workspace tests, strict all-target + Clippy, and format check passed. The beezelbot Fedora base integration exited + 0; both applies succeeded, and the repeat issued no service start/restart. + Podman needed SIGKILL after its 10-second stop timeout while cleaning up the + disposable container. +- No live VM or provider API was used. `/dev/kvm` remains unavailable. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index f6e5a3f9..507b197e 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -136,6 +136,9 @@ and VM delivery have duplicate implementations with different validation. - Implemented: bounded enrollment polling is also in the Tailscale provider. The CLI supplies a guest probe and explicit timeout/interval; retry behavior is covered with deterministic success, transient-error, and timeout tests. +- Implemented: a failed initial guest status command now aborts enrollment + before issuing an auth key. Only a successful response with no addresses is + treated as an unenrolled VM; transport and command failures remain errors. - Pending: environment/host credential-delivery orchestration, durable versus disposable provider sequencing, and production plus named-VM acceptance remain in CLI modules. Provider and VM lifecycle orchestration still has diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 6303113d..a46d7c7d 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -341,7 +341,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { &provisioning_identity, expected_hostname, )?; - let mut addresses = vm_tailscale_addresses(&ssh).unwrap_or_default(); + let mut addresses = vm_tailscale_addresses(&ssh)?; if addresses.is_empty() { let auth_key = tailscale::create_auth_key( &credentials, @@ -770,8 +770,17 @@ fn vm_tailscale_addresses(ssh: &VmSsh) -> Result Result> { if !output.status.success() { - return Ok(std::collections::BTreeSet::new()); + return Err(anyhow!( + "Tailscale guest status command failed with status {}; refusing to treat probe failure as an unenrolled VM", + output.status + )); } tailscale::status_addresses(&output.stdout).map_err(anyhow::Error::from) } diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 591295aa..2404fa1e 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,4 +1,7 @@ -use super::{read_vm_port, validate_delivery_service, validate_tailscale_unit_config}; +use super::{ + parse_vm_tailscale_status, read_vm_port, validate_delivery_service, + validate_tailscale_unit_config, +}; #[test] fn vm_port_requires_manifest_range() { @@ -20,6 +23,21 @@ fn rejects_vm_without_tailscale_systemd_credential() { assert!(validate_tailscale_unit_config(current_unit).is_ok()); } +#[test] +fn failed_tailscale_status_command_does_not_mean_no_existing_enrollment() { + use std::os::unix::process::ExitStatusExt; + + let output = std::process::Output { + status: std::process::ExitStatus::from_raw(1 << 8), + stdout: br#"{"BackendState":"NeedsLogin"}"#.to_vec(), + stderr: b"permission denied".to_vec(), + }; + let error = parse_vm_tailscale_status(&output).unwrap_err(); + assert!(error + .to_string() + .contains("refusing to treat probe failure")); +} + #[test] fn delivery_eligibility_uses_declared_service_capabilities() { assert!(validate_delivery_service("beezelbot", "pihole").is_err()); From 3fe5e1a1f3e56cfdc33dca03352b17467488d3f5 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:16:55 +0200 Subject: [PATCH 168/252] refactor(vm): install deferred credentials as a set --- ublue/butane/ACCEPTANCE.md | 16 ++++++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 4 ++ .../skillet/crates/cli/src/secret_delivery.rs | 52 +++++++----------- ublue/skillet/crates/vm/src/credential.rs | 55 ++++++++++++++++--- .../skillet/crates/vm/src/credential/tests.rs | 47 +++++++++++++++- 5 files changed, 133 insertions(+), 41 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 98b68c66..97765400 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1179,3 +1179,19 @@ host tool; it still uses normal command approvals. Podman needed SIGKILL after its 10-second stop timeout while cleaning up the disposable container. - No live VM or provider API was used. `/dev/kvm` remains unavailable. + +### Shared deferred credential-set delivery, 2026-10-04 + +- Added `skillet_vm::credential::install_set`, which validates the full + duplicate-free credential set before any guest mutation and applies a single + consumer/activation policy. Production and disposable Caddy flows install + `caddy_sites` and `cloudflare_acme_token` as one deferred set, then activate + Caddy after successful delivery. +- Credential delivery tests passed for stdin-only payloads, deferred + activation, complete-set validation, duplicate refusal, and guest failure. + Workspace formatting, all-target tests, and strict all-target Clippy passed. +- The beezelbot Fedora base integration exited 0: two applies succeeded, the + repeat issued no service start/restart, and the disposable container was + removed. Podman needed SIGKILL after its 10-second stop timeout. +- No VM credential delivery or live provider API was run; `/dev/kvm` is + unavailable. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 507b197e..3dfaf6ba 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -139,6 +139,10 @@ and VM delivery have duplicate implementations with different validation. - Implemented: a failed initial guest status command now aborts enrollment before issuing an auth key. Only a successful response with no addresses is treated as an unenrolled VM; transport and command failures remain errors. +- Implemented: `skillet_vm::credential::install_set` validates a complete, + duplicate-free credential set before remote effects and applies one shared + consumer/activation policy. Production and disposable Caddy provisioning use + it to install both the sites payload and ACME token before activation. - Pending: environment/host credential-delivery orchestration, durable versus disposable provider sequencing, and production plus named-VM acceptance remain in CLI modules. Provider and VM lifecycle orchestration still has diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index a46d7c7d..5207ec93 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -143,20 +143,18 @@ fn deliver_caddy_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Resu )?; ensure_vault_unchanged(vault)?; let sites = serde_json::to_string(&sites)?; - for (credential, value) in [ - ("caddy_sites", sites.as_str()), - ("cloudflare_acme_token", token.as_str()), - ] { - skillet_vm::credential::install( - &credential_transport(args)?, - &args.hostname, - credential, - "skillet-caddy-apply.service", - skillet_vm::credential::ActivationPolicy::DeferConsumer, - value.as_bytes(), - )?; - } - let output = credential_transport(args)? + let transport = credential_transport(args)?; + skillet_vm::credential::install_set( + &transport, + &args.hostname, + "skillet-caddy-apply.service", + skillet_vm::credential::ActivationPolicy::DeferConsumer, + &[ + ("caddy_sites", sites.as_bytes()), + ("cloudflare_acme_token", token.as_bytes()), + ], + )?; + let output = transport .execute( &skillet_vm::transport::GuestCommand { program: "/usr/bin/sudo", @@ -507,15 +505,16 @@ fn provision_vm_ui( )?; ownership.record_ids = owned.records.into_iter().map(|record| record.id).collect(); provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; - ssh.install_deferred( - "caddy_sites", - "skillet-caddy-apply.service", - &serde_json::to_string(&sites)?, - )?; - ssh.install_deferred( - "cloudflare_acme_token", + let sites_payload = serde_json::to_string(&sites)?; + skillet_vm::credential::install_set( + &ssh.transport, + &args.hostname, "skillet-caddy-apply.service", - &issued.value, + skillet_vm::credential::ActivationPolicy::DeferConsumer, + &[ + ("caddy_sites", sites_payload.as_bytes()), + ("cloudflare_acme_token", issued.value.as_bytes()), + ], )?; ssh.run( "/usr/bin/sudo", @@ -720,15 +719,6 @@ impl VmSsh { ) } - fn install_deferred(&self, name: &str, unit: &str, secret: &str) -> Result<()> { - self.install_for_unit( - name, - unit, - secret, - skillet_vm::credential::ActivationPolicy::DeferConsumer, - ) - } - fn install_for_unit( &self, name: &str, diff --git a/ublue/skillet/crates/vm/src/credential.rs b/ublue/skillet/crates/vm/src/credential.rs index 8f97d07c..d7e1252b 100644 --- a/ublue/skillet/crates/vm/src/credential.rs +++ b/ublue/skillet/crates/vm/src/credential.rs @@ -19,15 +19,7 @@ pub fn install( activation: ActivationPolicy, payload: &[u8], ) -> Result<()> { - if !valid_component(host) - || !valid_component(credential) - || !valid_unit(consumer_unit) - || payload.is_empty() - { - return Err(Error::Invalid( - "invalid guest credential delivery input".into(), - )); - } + validate_input(host, credential, consumer_unit, payload)?; let binary = format!("/var/usrlocal/bin/skillet-{host}"); let mut arguments = vec!["-n", binary.as_str(), "credential", "install"]; @@ -52,6 +44,51 @@ pub fn install( Ok(()) } +/// Install a related credential set using one activation policy. Every input +/// is validated before any guest mutation; deferred callers can start the +/// consumer only after this operation succeeds. +pub fn install_set( + transport: &impl GuestTransport, + host: &str, + consumer_unit: &str, + activation: ActivationPolicy, + credentials: &[(&str, &[u8])], +) -> Result<()> { + if credentials.is_empty() { + return Err(Error::Invalid( + "guest credential delivery set is empty".into(), + )); + } + for (index, (name, payload)) in credentials.iter().enumerate() { + validate_input(host, name, consumer_unit, payload)?; + if credentials[..index] + .iter() + .any(|(previous, _)| previous == name) + { + return Err(Error::Invalid( + "guest credential delivery set contains duplicate names".into(), + )); + } + } + for (name, payload) in credentials { + install(transport, host, name, consumer_unit, activation, payload)?; + } + Ok(()) +} + +fn validate_input(host: &str, credential: &str, consumer_unit: &str, payload: &[u8]) -> Result<()> { + if !valid_component(host) + || !valid_component(credential) + || !valid_unit(consumer_unit) + || payload.is_empty() + { + return Err(Error::Invalid( + "invalid guest credential delivery input".into(), + )); + } + Ok(()) +} + fn valid_component(value: &str) -> bool { !value.is_empty() && value diff --git a/ublue/skillet/crates/vm/src/credential/tests.rs b/ublue/skillet/crates/vm/src/credential/tests.rs index 031c8c05..6384d9ba 100644 --- a/ublue/skillet/crates/vm/src/credential/tests.rs +++ b/ublue/skillet/crates/vm/src/credential/tests.rs @@ -1,4 +1,4 @@ -use super::{install, ActivationPolicy}; +use super::{install, install_set, ActivationPolicy}; use crate::{ transport::{GuestCommand, GuestTransport}, Error, Result, @@ -83,6 +83,51 @@ fn rejects_invalid_names_and_empty_payload_before_transport() { assert!(transport.calls.lock().unwrap().is_empty()); } +#[test] +fn credential_set_validates_all_entries_then_defers_each_install() { + let transport = FakeTransport::default(); + install_set( + &transport, + "clamps", + "skillet-caddy-apply.service", + ActivationPolicy::DeferConsumer, + &[ + ("caddy_sites", b"sites payload"), + ("cloudflare_acme_token", b"token payload"), + ], + ) + .unwrap(); + let calls = transport.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls + .iter() + .all(|call| call.1.last().map(String::as_str) == Some("--no-start"))); + assert_eq!(calls[0].2.as_deref(), Some(b"sites payload".as_slice())); + assert_eq!(calls[1].2.as_deref(), Some(b"token payload".as_slice())); +} + +#[test] +fn credential_set_rejects_invalid_or_duplicate_entries_before_any_mutation() { + let transport = FakeTransport::default(); + for credentials in [ + vec![("caddy_sites", b"sites".as_slice()), ("bad;name", b"token")], + vec![ + ("caddy_sites", b"sites".as_slice()), + ("caddy_sites", b"again"), + ], + ] { + assert!(install_set( + &transport, + "clamps", + "skillet-caddy-apply.service", + ActivationPolicy::DeferConsumer, + &credentials, + ) + .is_err()); + } + assert!(transport.calls.lock().unwrap().is_empty()); +} + #[test] fn reports_guest_failure_without_exposing_payload() { let transport = FakeTransport { From f74119b97ec5c4cdbad5864930a2a4b9ce6f495e Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:26:33 +0200 Subject: [PATCH 169/252] refactor(workstation): own disposable Tailscale enrollment --- ublue/butane/ACCEPTANCE.md | 18 ++ ublue/design/skillet-architecture.md | 4 +- ublue/design/smoke-vms.md | 18 +- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 16 +- .../skillet/crates/cli/src/secret_delivery.rs | 72 +------ .../crates/cli/src/secret_delivery/tests.rs | 20 +- ublue/skillet/crates/workstation/Cargo.toml | 1 + ublue/skillet/crates/workstation/src/lib.rs | 1 + .../workstation/src/tailscale_enrollment.rs | 147 +++++++++++++ .../src/tailscale_enrollment/tests.rs | 193 ++++++++++++++++++ 10 files changed, 397 insertions(+), 93 deletions(-) create mode 100644 ublue/skillet/crates/workstation/src/tailscale_enrollment.rs create mode 100644 ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 97765400..5044c4bf 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1195,3 +1195,21 @@ host tool; it still uses normal command approvals. removed. Podman needed SIGKILL after its 10-second stop timeout. - No VM credential delivery or live provider API was run; `/dev/kvm` is unavailable. + +### Workstation-owned disposable Tailscale enrollment, 2026-10-04 + +- Moved the test VM enrollment transaction into + `skillet_workstation::tailscale_enrollment`. The CLI supplies typed host, + environment, instance, VM hostname, run directory, provider credentials, and + verified guest transport. The workflow persists intent, reuses an existing + enrollment, refuses failed status probes before auth-key creation, delivers a + one-use key only after a successful empty observation, verifies the tagged + device, and saves the ownership record before removing pending state. +- Four orchestration tests passed with fake guest transport and provider: + existing enrollment, new enrollment/key delivery, failed-probe recovery + state, and refusal of production policy. Full workspace tests, strict + all-target Clippy, and formatting passed. +- The beezelbot Fedora base integration exited 0: both applies succeeded, the + repeat issued no service start/restart, and the disposable container was + removed. Podman needed SIGKILL after its 10-second stop timeout. +- No VM or live Tailscale API was contacted. `/dev/kvm` is unavailable. diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index f92ef485..b2200d54 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -27,7 +27,9 @@ Workstream 2 adds `skillet_hosts` as the single host capability declaration. It owns host composition and supplies boot, service, network, UI, storage, and credential policy to entry points. The explicit `agent` baseline is the generic no-service apply profile. Synthetic smoke fixture composition lives in a -separate test command and is not dispatched as a host identity. Retained-VM +separate test command and is not dispatched as a host identity. Workstation +Tailscale enrollment now accepts provider, profile/environment, VM identity, +state path, and guest transport as explicit inputs. Retained-VM runtime acceptance remains pending. Workstream 3 is in progress. Account observations and subordinate-ID files diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index de5b3fcc..34f41e17 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -61,8 +61,11 @@ and adapter checks pass; live migration acceptance remains deferred. Tailscale enrollment intent, enrolled-device identity, and Cloudflare DNS/token ownership metadata are persisted by `skillet_workstation::provisioning_state`. Writes are atomic and mode `0600`; readers reject symlinks and non-regular -files. An existing pending enrollment marker must match the same VM identity, -so retry cannot silently claim another VM's cleanup journal. +files. `skillet_workstation::tailscale_enrollment` owns enrollment sequencing: +it persists intent before provider or guest mutations, treats a failed guest +probe as an error, and removes intent only after the tagged device identity is +recorded. An existing pending marker must match the same VM identity, so retry +cannot silently claim another VM's cleanup journal. New journals keep host, environment, and instance as separate fields. Legacy smoke-run records remain readable and are checked against the exact expected guest hostname, ownership marker, and token name rather than parsed into a new @@ -94,10 +97,13 @@ required. See the [TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). builds and installs current shared and host code on a retained VM, recording deployed hashes separately while preserving the original creation snapshot. 3. **Provision applications:** `test vm provision` unlocks the workstation - KeePassXC database, reads the Tailscale OAuth client, mints a one-use smoke - tagged key, and delivers it through the [shared secret mechanism](secrets.md). - It waits for the VM to join and records its device identity before delivering - a generated Pi-hole credential and running full apply. Repeated provisioning + KeePassXC database and supplies the Tailscale OAuth client, test policy, + VM identity, and verified guest transport to + `skillet_workstation::tailscale_enrollment`. That workflow reuses an existing + enrollment or mints and delivers a one-use smoke-tagged key through the + [shared secret mechanism](secrets.md), waits for the VM to join, and records + its device identity. The CLI then delivers a generated Pi-hole credential + and runs full apply. Repeated provisioning reuses the Tailscale identity and installed Pi-hole credential; `--rotate` replaces the latter. `--with-ui` also creates a 12-hour zone-scoped Cloudflare token, reconciles tailnet A/AAAA and UI/alias CNAME records, diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 3dfaf6ba..47b3ba75 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -143,8 +143,14 @@ and VM delivery have duplicate implementations with different validation. duplicate-free credential set before remote effects and applies one shared consumer/activation policy. Production and disposable Caddy provisioning use it to install both the sites payload and ACME token before activation. -- Pending: environment/host credential-delivery orchestration, durable versus - disposable provider sequencing, and production plus named-VM acceptance - remain in CLI modules. Provider and VM lifecycle orchestration still has - mixed CLI/workstation ownership. Finish the remaining sequence items before - closing this workstream. +- Implemented: disposable Tailscale enrollment sequencing now lives in + `skillet_workstation::tailscale_enrollment`. The typed operation receives + host, environment policy, VM instance/hostname, state directory, provider, + and verified guest transport separately. It persists intent before effects, + reuses an already-joined VM, installs a one-use key only after a successful + empty status observation, verifies the tagged device, and records/removes + recovery state. Cleanup and Cloudflare issuance/reconciliation remain in CLI. +- Pending: Cloudflare token/DNS issuance and disposable cleanup sequencing, + production plus named-VM acceptance, and the remaining environment/host + delivery orchestration still span CLI and workstation modules. Complete the + remaining sequence items before closing this workstream. diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 5207ec93..cf105ea4 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -331,24 +331,17 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let credentials = tailscale_credentials(&vault)?; let expected_hostname = name.as_str(); let policy = UiEnvironmentName::Test.policy(); - let provisioning_identity = - ProvisioningIdentity::new(&args.hostname, policy.name(), &args.instance); - - provisioning_state::mark_tailscale_pending( - &run_dir, - &provisioning_identity, - expected_hostname, + let record = skillet_workstation::tailscale_enrollment::enroll_disposable_vm( + &skillet_workstation::tailscale_enrollment::DisposableEnrollment { + host: &args.hostname, + instance: &args.instance, + vm_hostname: expected_hostname, + run_directory: &run_dir, + policy, + }, + &credentials, + &ssh.transport, )?; - let mut addresses = vm_tailscale_addresses(&ssh)?; - if addresses.is_empty() { - let auth_key = tailscale::create_auth_key( - &credentials, - policy - .tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::DisposableVm), - &format!("Skillet disposable VM {expected_hostname}"), - )?; - ssh.install("tailscale_auth_key", &auth_key.key)?; - } let pihole_credential = "/etc/credstore.encrypted/skillet/pihole_web_password.cred"; let has_pihole_credential = vm_credential_present(&ssh, pihole_credential)?; @@ -362,14 +355,6 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { )?; } - addresses = wait_for_vm_tailscale(&ssh)?; - let record = tailscale::find_device( - &credentials, - expected_hostname, - policy.tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::DisposableVm), - &addresses, - )?; - provisioning_state::save_tailscale_record(&run_dir, &provisioning_identity, &record)?; if args.with_ui { provision_vm_ui(args, &run_dir, &ssh, &mut vault, &record)?; } @@ -747,43 +732,6 @@ fn vm_credential_present(ssh: &VmSsh, path: &str) -> Result { } } -fn vm_tailscale_addresses(ssh: &VmSsh) -> Result> { - let output = ssh.output( - "/usr/bin/sudo", - &[ - "-n", - "podman", - "exec", - "tailscale", - "tailscale", - "status", - "--json", - ], - )?; - parse_vm_tailscale_status(&output) -} - -fn parse_vm_tailscale_status( - output: &std::process::Output, -) -> Result> { - if !output.status.success() { - return Err(anyhow!( - "Tailscale guest status command failed with status {}; refusing to treat probe failure as an unenrolled VM", - output.status - )); - } - tailscale::status_addresses(&output.stdout).map_err(anyhow::Error::from) -} - -fn wait_for_vm_tailscale(ssh: &VmSsh) -> Result> { - tailscale::wait_for_addresses( - || vm_tailscale_addresses(ssh), - std::time::Duration::from_mins(2), - std::time::Duration::from_secs(2), - ) - .map_err(|error| anyhow!("{error}; inspect tailscale.service and its journal")) -} - fn read_vm_port(manifest: &Path) -> Result { let contents = fs::read_to_string(manifest)?; let value = contents diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 2404fa1e..591295aa 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,7 +1,4 @@ -use super::{ - parse_vm_tailscale_status, read_vm_port, validate_delivery_service, - validate_tailscale_unit_config, -}; +use super::{read_vm_port, validate_delivery_service, validate_tailscale_unit_config}; #[test] fn vm_port_requires_manifest_range() { @@ -23,21 +20,6 @@ fn rejects_vm_without_tailscale_systemd_credential() { assert!(validate_tailscale_unit_config(current_unit).is_ok()); } -#[test] -fn failed_tailscale_status_command_does_not_mean_no_existing_enrollment() { - use std::os::unix::process::ExitStatusExt; - - let output = std::process::Output { - status: std::process::ExitStatus::from_raw(1 << 8), - stdout: br#"{"BackendState":"NeedsLogin"}"#.to_vec(), - stderr: b"permission denied".to_vec(), - }; - let error = parse_vm_tailscale_status(&output).unwrap_err(); - assert!(error - .to_string() - .contains("refusing to treat probe failure")); -} - #[test] fn delivery_eligibility_uses_declared_service_capabilities() { assert!(validate_delivery_service("beezelbot", "pihole").is_err()); diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml index 859e58e5..dc280546 100644 --- a/ublue/skillet/crates/workstation/Cargo.toml +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -18,5 +18,6 @@ serde_json.workspace = true sha2.workspace = true skillet_caddy.workspace = true skillet_hosts.workspace = true +skillet_vm.workspace = true tempfile.workspace = true thiserror.workspace = true diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index e9e68526..c1896206 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -4,5 +4,6 @@ pub mod cloudflare; pub mod provisioning_policy; pub mod provisioning_state; pub mod tailscale; +pub mod tailscale_enrollment; pub mod ui_provisioning; pub mod vault; diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs new file mode 100644 index 00000000..e05919ba --- /dev/null +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs @@ -0,0 +1,147 @@ +//! Recoverable Tailscale enrollment for disposable guest machines. + +use crate::{ + provisioning_policy::{DeviceClass, Environment, ProvisioningPolicy}, + provisioning_state::{self, ProvisioningIdentity}, + tailscale::{self, AuthKey, DeviceRecord, OAuthCredentials, TailscaleError}, +}; +use skillet_vm::{credential, transport::GuestTransport}; +use std::{collections::BTreeSet, path::Path, time::Duration}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum EnrollmentError { + #[error("invalid disposable Tailscale enrollment: {0}")] + Invalid(String), + #[error(transparent)] + Provider(#[from] TailscaleError), + #[error(transparent)] + State(#[from] provisioning_state::ProvisioningStateError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), +} + +/// Narrow provider capability needed by the enrollment workflow. +pub trait EnrollmentProvider { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result; + fn find_device( + &self, + expected_hostname: &str, + expected_tag: &str, + addresses: &BTreeSet, + ) -> tailscale::Result; +} + +impl EnrollmentProvider for OAuthCredentials { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result { + tailscale::create_auth_key(self, tag, description) + } + + fn find_device( + &self, + expected_hostname: &str, + expected_tag: &str, + addresses: &BTreeSet, + ) -> tailscale::Result { + tailscale::find_device(self, expected_hostname, expected_tag, addresses) + } +} + +/// Separate host, environment, instance, runtime hostname and transport inputs +/// so the caller's VM identity is never reconstructed from a composite name. +pub struct DisposableEnrollment<'a> { + pub host: &'a str, + pub instance: &'a str, + pub vm_hostname: &'a str, + pub run_directory: &'a Path, + pub policy: ProvisioningPolicy, +} + +pub fn enroll_disposable_vm( + request: &DisposableEnrollment<'_>, + provider: &impl EnrollmentProvider, + guest: &impl GuestTransport, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(EnrollmentError::Invalid( + "disposable enrollment requires the test environment policy".into(), + )); + } + let profile = skillet_hosts::profile_for_name(request.host).ok_or_else(|| { + EnrollmentError::Invalid(format!("unknown host profile {}", request.host)) + })?; + if !profile.supports_service("tailscale") { + return Err(EnrollmentError::Invalid(format!( + "host {} does not declare Tailscale", + request.host + ))); + } + if request.vm_hostname.is_empty() || request.instance.is_empty() { + return Err(EnrollmentError::Invalid( + "VM hostname and instance must not be empty".into(), + )); + } + + let identity = ProvisioningIdentity::new(request.host, request.policy.name(), request.instance); + provisioning_state::mark_tailscale_pending( + request.run_directory, + &identity, + request.vm_hostname, + )?; + + let mut addresses = guest_addresses(guest)?; + let tag = request.policy.tailscale_tag(DeviceClass::DisposableVm); + if addresses.is_empty() { + let auth_key = provider.create_auth_key( + tag, + &format!("Skillet disposable VM {}", request.vm_hostname), + )?; + credential::install( + guest, + request.host, + "tailscale_auth_key", + "skillet-full-apply.service", + credential::ActivationPolicy::StartConsumer, + auth_key.key.as_bytes(), + )?; + addresses = tailscale::wait_for_addresses( + || guest_addresses(guest), + Duration::from_mins(2), + Duration::from_secs(2), + )?; + } + + let record = provider.find_device(request.vm_hostname, tag, &addresses)?; + provisioning_state::save_tailscale_record(request.run_directory, &identity, &record)?; + provisioning_state::remove_tailscale_pending(request.run_directory)?; + Ok(record) +} + +fn guest_addresses(guest: &impl GuestTransport) -> Result, EnrollmentError> { + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &[ + "-n", + "podman", + "exec", + "tailscale", + "tailscale", + "status", + "--json", + ], + }, + None, + )?; + if !output.status.success() { + return Err(EnrollmentError::Invalid(format!( + "Tailscale guest status command failed with status {}; refusing to treat probe failure as an unenrolled VM", + output.status + ))); + } + Ok(tailscale::status_addresses(&output.stdout)?) +} + +#[cfg(test)] +#[path = "tailscale_enrollment/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs new file mode 100644 index 00000000..22654b72 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs @@ -0,0 +1,193 @@ +use super::*; +use std::{ + cell::Cell, + collections::VecDeque, + process::{Command, Output}, + sync::Mutex, +}; + +#[derive(Default)] +struct FakeProvider { + keys_created: Cell, + devices_found: Cell, +} + +impl EnrollmentProvider for FakeProvider { + fn create_auth_key(&self, _tag: &str, _description: &str) -> tailscale::Result { + self.keys_created.set(self.keys_created.get() + 1); + Ok(AuthKey { + key: "dummy-auth-key".to_string(), + }) + } + + fn find_device( + &self, + expected_hostname: &str, + _expected_tag: &str, + _addresses: &BTreeSet, + ) -> tailscale::Result { + self.devices_found.set(self.devices_found.get() + 1); + Ok(DeviceRecord { + id: "device-id".to_string(), + hostname: expected_hostname.to_string(), + addresses: BTreeSet::from(["100.64.0.10".to_string()]), + }) + } +} + +struct FakeGuest { + statuses: Mutex>, + installs: Mutex>, +} + +type CapturedInstall = (Vec, Option>); + +impl FakeGuest { + fn new(statuses: impl IntoIterator) -> Self { + Self { + statuses: Mutex::new(statuses.into_iter().collect()), + installs: Mutex::new(Vec::new()), + } + } +} + +impl GuestTransport for FakeGuest { + fn execute( + &self, + command: &skillet_vm::transport::GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + if command.arguments.contains(&"credential") { + self.installs.lock().unwrap().push(( + command + .arguments + .iter() + .map(|argument| (*argument).to_string()) + .collect(), + input.map(<[u8]>::to_vec), + )); + return Ok(output(true, Vec::new())); + } + Ok(self + .statuses + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| status("Running", &["100.64.0.10"]))) + } + + fn upload(&self, _source: &std::path::Path, _destination: &str) -> skillet_vm::Result<()> { + Ok(()) + } +} + +fn output(success: bool, stdout: Vec) -> Output { + Output { + status: Command::new(if success { "true" } else { "false" }) + .status() + .expect("create local exit status"), + stdout, + stderr: Vec::new(), + } +} + +fn status(state: &str, addresses: &[&str]) -> Output { + output( + true, + serde_json::json!({ + "BackendState": state, + "Self": {"TailscaleIPs": addresses} + }) + .to_string() + .into_bytes(), + ) +} + +fn request(run_directory: &std::path::Path) -> DisposableEnrollment<'_> { + DisposableEnrollment { + host: "clamps", + instance: "smoke", + vm_hostname: "clamps-test-smoke", + run_directory, + policy: ProvisioningPolicy::new(Environment::Test), + } +} + +#[test] +fn already_enrolled_vm_is_recorded_without_creating_an_auth_key() { + let run = tempfile::tempdir().unwrap(); + let guest = FakeGuest::new([status("Running", &["100.64.0.10"])]); + let provider = FakeProvider::default(); + let identity = ProvisioningIdentity::new("clamps", "test", "smoke"); + + let device = enroll_disposable_vm(&request(run.path()), &provider, &guest).unwrap(); + + assert_eq!(device.hostname, "clamps-test-smoke"); + assert_eq!(provider.keys_created.get(), 0); + assert_eq!(provider.devices_found.get(), 1); + assert!(guest.installs.lock().unwrap().is_empty()); + assert!(!run.path().join("tailscale-pending").exists()); + assert_eq!( + provisioning_state::load_tailscale_record( + &run.path().join("tailscale.json"), + &identity, + "clamps-test-smoke" + ) + .unwrap(), + device + ); +} + +#[test] +fn unenrolled_vm_receives_auth_key_before_device_lookup_and_state_is_saved() { + let run = tempfile::tempdir().unwrap(); + let guest = FakeGuest::new([ + status("NeedsLogin", &[]), + status("Running", &["100.64.0.10"]), + ]); + let provider = FakeProvider::default(); + + enroll_disposable_vm(&request(run.path()), &provider, &guest).unwrap(); + + assert_eq!(provider.keys_created.get(), 1); + assert_eq!(provider.devices_found.get(), 1); + let installs = guest.installs.lock().unwrap(); + assert_eq!(installs.len(), 1); + assert_eq!(installs[0].1.as_deref(), Some(b"dummy-auth-key".as_slice())); + assert!(installs[0] + .0 + .iter() + .any(|argument| argument == "skillet-full-apply.service")); + assert!(!run.path().join("tailscale-pending").exists()); + assert!(run.path().join("tailscale.json").is_file()); +} + +#[test] +fn failed_guest_status_keeps_recovery_marker_and_does_not_issue_key() { + let run = tempfile::tempdir().unwrap(); + let guest = FakeGuest::new([output(false, Vec::new())]); + let provider = FakeProvider::default(); + + let error = enroll_disposable_vm(&request(run.path()), &provider, &guest).unwrap_err(); + + assert!(error + .to_string() + .contains("refusing to treat probe failure")); + assert_eq!(provider.keys_created.get(), 0); + assert!(run.path().join("tailscale-pending").is_file()); + assert!(!run.path().join("tailscale.json").exists()); +} + +#[test] +fn production_policy_is_rejected_before_guest_or_provider_effects() { + let run = tempfile::tempdir().unwrap(); + let mut request = request(run.path()); + request.policy = ProvisioningPolicy::new(Environment::Production); + let guest = FakeGuest::new([]); + let provider = FakeProvider::default(); + + assert!(enroll_disposable_vm(&request, &provider, &guest).is_err()); + assert_eq!(provider.keys_created.get(), 0); + assert!(guest.installs.lock().unwrap().is_empty()); + assert!(!run.path().join("tailscale-pending").exists()); +} From 0023a3c1dbc8e59f902befd1e03edb0f8ab12c7d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:35:17 +0200 Subject: [PATCH 170/252] refactor(workstation): own disposable UI DNS provisioning --- ublue/butane/ACCEPTANCE.md | 16 ++ ublue/design/cloudflare-ui-lifecycle.md | 15 +- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 9 +- .../skillet/crates/cli/src/secret_delivery.rs | 72 ++----- .../crates/workstation/src/ui_provisioning.rs | 167 ++++++++++++++- .../workstation/src/ui_provisioning/tests.rs | 197 +++++++++++++++++- 6 files changed, 411 insertions(+), 65 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 5044c4bf..23199196 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1213,3 +1213,19 @@ host tool; it still uses normal command approvals. repeat issued no service start/restart, and the disposable container was removed. Podman needed SIGKILL after its 10-second stop timeout. - No VM or live Tailscale API was contacted. `/dev/kvm` is unavailable. + +### Workstation-owned disposable Cloudflare UI transaction, 2026-10-04 + +- Moved disposable UI token issuance and DNS reconciliation into + `skillet_workstation::ui_provisioning`. It persists intent before token + creation, checks the vault snapshot immediately before issuing, saves token + ID/expiry before DNS changes, and saves owned record IDs after reconciliation. +- Six UI provisioning tests passed, including write-ahead ordering, vault + change refusal, and retaining token ownership when DNS reconciliation fails. +- The full workspace tests, strict all-target Clippy, and formatting checks + passed (44 workstation tests in the workspace run). +- The documented beezelbot Fedora base integration exited 0: both applies + succeeded; the repeat issued no service start/restart. Podman needed SIGKILL + after its 10-second stop timeout during cleanup. +- Guest credential delivery, Caddy HTTPS acceptance, and live Cloudflare API + behavior were not exercised here. `/dev/kvm` is unavailable. diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index f979b42c..bec1894e 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -106,12 +106,15 @@ credentials and records are never disposed by test VM commands. Implementation status: `cloudflare-rs` supplies authenticated blocking HTTP transport; custom typed JSON endpoints use that transport for token APIs and DNS record comments that the crate's DNS models do not expose. Persistent -delivery use-or-creates and saves its scoped token in KeePassXC. Opt-in smoke -provisioning records token ID, expiry, DNS record IDs, and ownership metadata; -destroy mints a short-lived cleanup token, deletes marker-owned DNS records, -and revokes disposable tokens. Local tests cover the SDK transport, permission -group discovery, scoped token payloads, address-family validation, and DNS -planning. Live 2026-10-02 staging acceptance issued certificates for each +delivery use-or-creates and saves its scoped token in KeePassXC. The +workstation UI provisioner now persists disposable owner intent, issues and +journals its scoped token, reconciles DNS, and records owned record IDs. Guest +credential delivery, certificate acceptance, and disposable cleanup still run +through the CLI and are being moved behind workstation operations. Destroy +mints a short-lived cleanup token, deletes marker-owned DNS records, and revokes +disposable tokens. Local tests cover the SDK transport, permission group +discovery, scoped token payloads, address-family validation, and DNS planning. +Live 2026-10-02 staging acceptance issued certificates for each declared canonical UI and alias, verified DNS and HTTPS over IPv4/IPv6, and disposed the VM, DNS records, and child tokens. CI tests inject an ambiguous token-create response, a DNS-create failure after a partial reconciliation, diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 47b3ba75..02ac46ab 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -149,8 +149,13 @@ and VM delivery have duplicate implementations with different validation. and verified guest transport separately. It persists intent before effects, reuses an already-joined VM, installs a one-use key only after a successful empty status observation, verifies the tagged device, and records/removes - recovery state. Cleanup and Cloudflare issuance/reconciliation remain in CLI. -- Pending: Cloudflare token/DNS issuance and disposable cleanup sequencing, + recovery state. Tailscale cleanup remains in CLI. +- Implemented: disposable Cloudflare UI issuance and DNS reconciliation now + live in `skillet_workstation::ui_provisioning`. The operation writes its + owner journal before token creation, verifies the vault snapshot immediately + before mutation, saves token ownership before DNS reconciliation, and records + owned DNS IDs afterward. Fake-provider tests cover partial failure recovery. +- Pending: Cloudflare cleanup, guest delivery/activation and its retry policy, production plus named-VM acceptance, and the remaining environment/host delivery orchestration still span CLI and workstation modules. Complete the remaining sequence items before closing this workstream. diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index cf105ea4..f824865c 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -3,7 +3,7 @@ use super::{ }; use anyhow::{anyhow, Context, Result}; use skillet_vm::transport::GuestTransport as _; -use skillet_workstation::provisioning_state::{self, CloudflareVmOwnership, ProvisioningIdentity}; +use skillet_workstation::provisioning_state::{self, ProvisioningIdentity}; use skillet_workstation::tailscale; use skillet_workstation::vault::Vault; use std::{ @@ -422,7 +422,6 @@ fn provision_vm_ui( device: &tailscale::DeviceRecord, ) -> Result<()> { let policy = UiEnvironmentName::Test.policy(); - let environment = policy.name(); let domain_path = policy.ui_domain_entry(); let zone_path = policy.cloudflare_zone_entry(); let domain_prefix = vault.get(&domain_path)?; @@ -437,59 +436,26 @@ fn provision_vm_ui( ) })?; let zone_id = zone_id.trim().to_string(); - skillet_workstation::cloudflare::validate_zone_id(&zone_id)?; let api = skillet_workstation::cloudflare::Cloudflare::new(); - let zone = api.zone(&creator, &zone_id)?; - let ui_plan = skillet_workstation::ui_provisioning::build_ui_provisioning_plan( - &args.hostname, - policy, - &zone.name, - domain_prefix.as_deref(), - &device.addresses, - )?; - let sites = ui_plan.sites; - let ui_domain = sites.ui_domain.clone(); - let marker = format!("skillet:test:{}:{}", args.hostname, args.instance); - let token_name = format!("skillet:test:{}-{}", args.hostname, args.instance); - let metadata_path = run_dir.join("cloudflare.json"); - let mut ownership = CloudflareVmOwnership { - identity: Some(ProvisioningIdentity::new( - &args.hostname, - environment, - &args.instance, - )), - environment: environment.to_string(), - marker, - zone_id, - ui_domain, - token_name, - token_id: None, - expires_on: None, - record_ids: Vec::new(), - }; - let account_id = skillet_workstation::cloudflare::Cloudflare::account_id(&zone)?.to_string(); - provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; - ensure_vault_unchanged(vault)?; - let issued = api.create_zone_token( - &creator, - &ownership.zone_id, - &account_id, - &ownership.token_name, - policy.cloudflare_token_lifetime(), + let provisioned = skillet_workstation::ui_provisioning::provision_disposable_ui( + &skillet_workstation::ui_provisioning::DisposableUiRequest { + host: &args.hostname, + instance: &args.instance, + policy, + zone_id: &zone_id, + relative_ui_domain: domain_prefix.as_deref(), + creator_token: &creator, + run_directory: run_dir, + addresses: &device.addresses, + }, + &api, + || vault.ensure_unchanged(), )?; - ownership.token_id = Some(issued.id.clone()); - ownership.expires_on.clone_from(&issued.expires_on); - provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; + let sites = provisioned.sites; + let issued = provisioned.token; + let account_id = provisioned.account_id; + let token_name = provisioned.token_name; let work = (|| { - let owned = api.reconcile_dns( - &issued.value, - &ownership.zone_id, - &ownership.marker, - &ownership.ui_domain, - &ui_plan.dns_records, - )?; - ownership.record_ids = owned.records.into_iter().map(|record| record.id).collect(); - provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; let sites_payload = serde_json::to_string(&sites)?; skillet_vm::credential::install_set( &ssh.transport, @@ -506,7 +472,7 @@ fn provision_vm_ui( &["-n", "systemctl", "start", "skillet-caddy-apply.service"], )?; verify_caddy_denies_non_tailnet_probe(ssh, &sites)?; - for old_id in api.token_ids_by_name(&creator, &account_id, &ownership.token_name)? { + for old_id in api.token_ids_by_name(&creator, &account_id, &token_name)? { if old_id != issued.id { api.revoke_token(&creator, &account_id, &old_id)?; } diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs index 3ba241d4..a323c82f 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -1,11 +1,13 @@ //! Shared host/environment UI hostname, Caddy, and DNS planning. use crate::{ - cloudflare::{self, CloudflareError, DesiredRecord}, - provisioning_policy::ProvisioningPolicy, + cloudflare::{self, CloudflareError, DesiredRecord, IssuedToken, OwnedDns, Zone}, + provisioning_policy::{Environment, ProvisioningPolicy}, + provisioning_state::{self, CloudflareVmOwnership, ProvisioningIdentity}, + vault::VaultError, }; use skillet_caddy::{CaddyError, CaddySites, UiEnvironment}; -use std::collections::BTreeSet; +use std::{collections::BTreeSet, path::Path, time::Duration}; use thiserror::Error; #[derive(Debug, Error)] @@ -16,6 +18,12 @@ pub enum UiProvisioningError { Caddy(#[from] CaddyError), #[error(transparent)] Cloudflare(#[from] CloudflareError), + #[error(transparent)] + State(#[from] provisioning_state::ProvisioningStateError), + #[error(transparent)] + Vault(#[from] VaultError), + #[error("invalid disposable UI provisioning request: {0}")] + Invalid(String), } /// Desired public DNS and Caddy configuration for one declared host profile. @@ -25,6 +33,159 @@ pub struct UiProvisioningPlan { pub dns_records: Vec, } +/// The narrow Cloudflare operations used by disposable UI provisioning. +pub trait UiCloudflareProvider { + fn zone(&self, token: &str, zone_id: &str) -> Result; + fn create_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result; + fn reconcile_dns( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + desired: &[DesiredRecord], + ) -> Result; +} + +impl UiCloudflareProvider for cloudflare::Cloudflare { + fn zone(&self, token: &str, zone_id: &str) -> Result { + cloudflare::Cloudflare::zone(self, token, zone_id) + } + + fn create_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + cloudflare::Cloudflare::create_zone_token( + self, + creator_token, + zone_id, + account_id, + name, + lifetime, + ) + } + + fn reconcile_dns( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + desired: &[DesiredRecord], + ) -> Result { + cloudflare::Cloudflare::reconcile_dns(self, token, zone_id, marker, ui_domain, desired) + } +} + +pub struct DisposableUiRequest<'a> { + pub host: &'a str, + pub instance: &'a str, + pub policy: ProvisioningPolicy, + pub zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub creator_token: &'a str, + pub run_directory: &'a Path, + pub addresses: &'a BTreeSet, +} + +pub struct DisposableUiCredentials { + pub sites: CaddySites, + pub token: IssuedToken, + pub account_id: String, + pub token_name: String, +} + +/// Persist each ownership transition around disposable token and DNS +/// mutations. The vault guard runs immediately before token issuance. +pub fn provision_disposable_ui( + request: &DisposableUiRequest<'_>, + provider: &impl UiCloudflareProvider, + ensure_vault_unchanged: impl FnOnce() -> Result<(), VaultError>, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(UiProvisioningError::Invalid( + "disposable UI provisioning requires the test environment policy".into(), + )); + } + cloudflare::validate_zone_id(request.zone_id)?; + let zone = provider.zone(request.creator_token, request.zone_id)?; + let plan = build_ui_provisioning_plan( + request.host, + request.policy, + &zone.name, + request.relative_ui_domain, + request.addresses, + )?; + let account_id = cloudflare::Cloudflare::account_id(&zone)?.to_string(); + let marker = format!( + "skillet:{}:{}:{}", + request.policy.name(), + request.host, + request.instance + ); + let token_name = format!( + "skillet:{}:{}-{}", + request.policy.name(), + request.host, + request.instance + ); + let metadata_path = request.run_directory.join("cloudflare.json"); + let mut ownership = CloudflareVmOwnership { + identity: Some(ProvisioningIdentity::new( + request.host, + request.policy.name(), + request.instance, + )), + environment: request.policy.name().to_string(), + marker, + zone_id: request.zone_id.to_string(), + ui_domain: plan.sites.ui_domain.clone(), + token_name: token_name.clone(), + token_id: None, + expires_on: None, + record_ids: Vec::new(), + }; + provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; + ensure_vault_unchanged()?; + let token = provider.create_zone_token( + request.creator_token, + request.zone_id, + &account_id, + &token_name, + request.policy.cloudflare_token_lifetime(), + )?; + ownership.token_id = Some(token.id.clone()); + ownership.expires_on.clone_from(&token.expires_on); + provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; + let owned = provider.reconcile_dns( + &token.value, + request.zone_id, + &ownership.marker, + &ownership.ui_domain, + &plan.dns_records, + )?; + ownership.record_ids = owned.records.into_iter().map(|record| record.id).collect(); + provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; + Ok(DisposableUiCredentials { + sites: plan.sites, + token, + account_id, + token_name, + }) +} + pub fn build_ui_provisioning_plan( host: &str, policy: ProvisioningPolicy, diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index a8085dd5..d9bd0d23 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -1,11 +1,108 @@ use super::*; use crate::provisioning_policy::{Environment, ProvisioningPolicy}; -use std::collections::BTreeSet; +use crate::{ + cloudflare::{ + CloudflareError, DesiredRecord, IssuedToken, OwnedDns, RecordRef, Zone, ZoneAccount, + }, + provisioning_state::CloudflareVmOwnership, +}; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, +}; fn addresses() -> BTreeSet { BTreeSet::from(["100.64.0.10".to_string(), "fd7a:115c:a1e0::10".to_string()]) } +struct FakeCloudflare { + run_directory: PathBuf, + fail_dns: bool, + issued: std::cell::Cell, +} + +impl UiCloudflareProvider for FakeCloudflare { + fn zone(&self, _token: &str, zone_id: &str) -> Result { + Ok(Zone { + id: zone_id.to_string(), + name: "example.invalid".to_string(), + account: Some(ZoneAccount { + id: "11111111111111111111111111111111".to_string(), + }), + }) + } + + fn create_zone_token( + &self, + _creator_token: &str, + _zone_id: &str, + _account_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + let ownership = read_ownership(&self.run_directory); + assert_eq!(ownership.token_name, name); + assert!(ownership.token_id.is_none()); + assert_eq!(lifetime, Some(std::time::Duration::from_hours(12))); + self.issued.set(self.issued.get() + 1); + Ok(IssuedToken { + id: "22222222222222222222222222222222".to_string(), + value: "dummy token value".to_string(), + expires_on: Some("2030-01-01T00:00:00Z".to_string()), + }) + } + + fn reconcile_dns( + &self, + _token: &str, + _zone_id: &str, + marker: &str, + _ui_domain: &str, + desired: &[DesiredRecord], + ) -> Result { + let ownership = read_ownership(&self.run_directory); + assert!(ownership.token_id.is_some()); + assert_eq!(ownership.marker, marker); + if self.fail_dns { + return Err(CloudflareError::Invalid("fixture DNS failure".into())); + } + Ok(OwnedDns { + marker: marker.to_string(), + records: desired + .iter() + .enumerate() + .map(|(index, desired)| RecordRef { + id: format!("{index:032x}"), + name: desired.name.clone(), + record_type: desired.record_type.clone(), + content: desired.content.clone(), + comment: Some(marker.to_string()), + }) + .collect(), + }) + } +} + +fn read_ownership(run_directory: &Path) -> CloudflareVmOwnership { + serde_json::from_slice(&std::fs::read(run_directory.join("cloudflare.json")).unwrap()).unwrap() +} + +fn disposable_request<'a>( + run_directory: &'a Path, + addresses: &'a BTreeSet, +) -> DisposableUiRequest<'a> { + DisposableUiRequest { + host: "clamps", + instance: "smoke", + policy: ProvisioningPolicy::new(Environment::Test), + zone_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + relative_ui_domain: Some("smoke.ui"), + creator_token: "dummy creator token", + run_directory, + addresses, + } +} + #[test] fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { let production = build_ui_provisioning_plan( @@ -103,3 +200,101 @@ fn profiles_without_ui_and_incomplete_address_families_fail_closed() { Err(UiProvisioningError::Cloudflare(_)) )); } + +#[test] +fn disposable_cloudflare_ownership_precedes_token_and_dns_mutations() { + let run = tempfile::tempdir().unwrap(); + let addresses = addresses(); + let expected_record_count = build_ui_provisioning_plan( + "clamps", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + Some("smoke.ui"), + &addresses, + ) + .unwrap() + .dns_records + .len(); + let provider = FakeCloudflare { + run_directory: run.path().to_path_buf(), + fail_dns: false, + issued: std::cell::Cell::new(0), + }; + let guard_calls = std::cell::Cell::new(0); + + let provisioned = provision_disposable_ui( + &disposable_request(run.path(), &addresses), + &provider, + || { + guard_calls.set(guard_calls.get() + 1); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(guard_calls.get(), 1); + assert_eq!(provider.issued.get(), 1); + assert_eq!(provisioned.sites.host, "clamps"); + assert_eq!(provisioned.token.value, "dummy token value"); + assert_eq!(provisioned.token_name, "skillet:test:clamps-smoke"); + let ownership = read_ownership(run.path()); + assert_eq!( + ownership.identity, + Some(ProvisioningIdentity::new("clamps", "test", "smoke")) + ); + assert_eq!( + ownership.token_id.as_deref(), + Some("22222222222222222222222222222222") + ); + assert_eq!(ownership.record_ids.len(), expected_record_count); +} + +#[test] +fn disposable_cloudflare_journal_keeps_issued_token_after_dns_failure() { + let run = tempfile::tempdir().unwrap(); + let addresses = addresses(); + let provider = FakeCloudflare { + run_directory: run.path().to_path_buf(), + fail_dns: true, + issued: std::cell::Cell::new(0), + }; + + let result = provision_disposable_ui( + &disposable_request(run.path(), &addresses), + &provider, + || Ok(()), + ); + let Err(error) = result else { + panic!("fixture DNS failure unexpectedly succeeded") + }; + + assert!(error.to_string().contains("fixture DNS failure")); + assert_eq!(provider.issued.get(), 1); + let ownership = read_ownership(run.path()); + assert!(ownership.token_id.is_some()); + assert!(ownership.record_ids.is_empty()); +} + +#[test] +fn disposable_cloudflare_checks_vault_before_issuing_token() { + let run = tempfile::tempdir().unwrap(); + let addresses = addresses(); + let provider = FakeCloudflare { + run_directory: run.path().to_path_buf(), + fail_dns: false, + issued: std::cell::Cell::new(0), + }; + + let result = provision_disposable_ui( + &disposable_request(run.path(), &addresses), + &provider, + || Err(VaultError::Invalid("vault changed".into())), + ); + let Err(error) = result else { + panic!("changed vault unexpectedly issued a token") + }; + + assert!(error.to_string().contains("vault changed")); + assert_eq!(provider.issued.get(), 0); + assert!(read_ownership(run.path()).token_id.is_none()); +} From dc987a3679f9dd5e81612dbbb807b3da3efbccaa Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:37:16 +0200 Subject: [PATCH 171/252] build(skillet): record workstation VM dependency --- ublue/skillet/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index a34f599b..31649f0c 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2489,6 +2489,7 @@ dependencies = [ "sha2 0.10.9", "skillet_caddy", "skillet_hosts", + "skillet_vm", "tempfile", "thiserror 1.0.69", ] From 980af4a0da2947968ed6bb1f7ceebaffcc997101 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:45:16 +0200 Subject: [PATCH 172/252] refactor(workstation): own disposable Cloudflare cleanup --- ublue/butane/ACCEPTANCE.md | 18 +++ ublue/design/cloudflare-ui-lifecycle.md | 12 +- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 15 +- .../skillet/crates/cli/src/secret_delivery.rs | 83 ++-------- .../workstation/src/provisioning_state.rs | 32 ++++ .../src/provisioning_state/tests.rs | 25 +++ .../crates/workstation/src/ui_provisioning.rs | 148 +++++++++++++++++ .../workstation/src/ui_provisioning/tests.rs | 150 +++++++++++++++--- 8 files changed, 388 insertions(+), 95 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 23199196..85d688cf 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1229,3 +1229,21 @@ host tool; it still uses normal command approvals. after its 10-second stop timeout during cleanup. - Guest credential delivery, Caddy HTTPS acceptance, and live Cloudflare API behavior were not exercised here. `/dev/kvm` is unavailable. + +### Workstation-owned disposable Cloudflare cleanup, 2026-10-04 + +- Moved disposable UI cleanup into `skillet_workstation::ui_provisioning`. + Before provider mutations, it checks the test policy, typed VM identity, + deterministic token/marker names, configured zone, and current relative UI + namespace. It removes marker-owned DNS, revokes disposable and cleanup + tokens, then removes the journal. Failures leave ownership state retryable; + malformed and symlinked state paths are refused. +- Cleanup tests cover successful revocation and journal removal, repeated + cleanup with no journal, identity mismatch before mutation, and DNS failure + retaining the journal. State tests cover regular-file presence/removal and + symlink refusal. The full workspace tests, strict all-target Clippy, and + formatting checks passed (48 workstation tests). +- The beezelbot Fedora base integration exited 0: both applies succeeded; the + repeat issued no service start/restart and the container was removed. Podman + needed SIGKILL after its 10-second stop timeout during cleanup. +- No VM or live Cloudflare API was contacted. `/dev/kvm` is unavailable. diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index bec1894e..ac8b541a 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -108,11 +108,13 @@ transport; custom typed JSON endpoints use that transport for token APIs and DNS record comments that the crate's DNS models do not expose. Persistent delivery use-or-creates and saves its scoped token in KeePassXC. The workstation UI provisioner now persists disposable owner intent, issues and -journals its scoped token, reconciles DNS, and records owned record IDs. Guest -credential delivery, certificate acceptance, and disposable cleanup still run -through the CLI and are being moved behind workstation operations. Destroy -mints a short-lived cleanup token, deletes marker-owned DNS records, and revokes -disposable tokens. Local tests cover the SDK transport, permission group +journals its scoped token, reconciles DNS, records owned record IDs, and owns +disposable cleanup. Cleanup validates the recorded identity and current +environment configuration before mutation, removes marker-owned DNS, revokes +the disposable and temporary cleanup tokens, and removes its journal last. +Failures retain the journal for retry. Guest credential delivery and +certificate acceptance still run through the CLI and are being moved behind +workstation operations. Local tests cover the SDK transport, permission group discovery, scoped token payloads, address-family validation, and DNS planning. Live 2026-10-02 staging acceptance issued certificates for each declared canonical UI and alias, verified DNS and HTTPS over IPv4/IPv6, and diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 02ac46ab..ec5bdc48 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -155,7 +155,14 @@ and VM delivery have duplicate implementations with different validation. owner journal before token creation, verifies the vault snapshot immediately before mutation, saves token ownership before DNS reconciliation, and records owned DNS IDs afterward. Fake-provider tests cover partial failure recovery. -- Pending: Cloudflare cleanup, guest delivery/activation and its retry policy, - production plus named-VM acceptance, and the remaining environment/host - delivery orchestration still span CLI and workstation modules. Complete the - remaining sequence items before closing this workstream. +- Implemented: disposable Cloudflare cleanup now lives in + `skillet_workstation::ui_provisioning`. It validates the test environment, + recorded host/environment/instance, deterministic marker/token names, zone, + and resolved UI namespace before provider mutations. It removes only + marker-owned DNS, revokes disposable and temporary cleanup tokens, and only + then removes the journal. Provider failures retain the journal for retry; + symlinked or non-regular state paths are rejected. +- Pending: guest delivery/activation and its retry policy, production plus + named-VM acceptance, and remaining environment/host delivery orchestration + still span CLI and workstation modules. Complete the remaining sequence + items before closing this workstream. diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index f824865c..9b476c40 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -370,10 +370,11 @@ pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { let pending = run_dir.join("tailscale-pending"); let record_path = run_dir.join("tailscale.json"); let cloudflare_path = run_dir.join("cloudflare.json"); - if !pending.exists() && !record_path.exists() && !cloudflare_path.exists() { + let cloudflare_exists = provisioning_state::cloudflare_ownership_exists(&cloudflare_path)?; + if !pending.exists() && !record_path.exists() && !cloudflare_exists { return Ok(()); } - if cloudflare_path.exists() { + if cloudflare_exists { cleanup_vm_cloudflare(args, &cloudflare_path)?; } if !pending.exists() && !record_path.exists() { @@ -528,28 +529,7 @@ fn verify_caddy_denies_non_tailnet_probe( } fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<()> { - let ownership = provisioning_state::load_cloudflare_ownership(metadata_path) - .context("reading Cloudflare VM ownership metadata")?; let policy = UiEnvironmentName::Test.policy(); - let expected_identity = - ProvisioningIdentity::new(&args.hostname, policy.name(), &args.instance); - provisioning_state::validate_cloudflare_identity(&ownership, &expected_identity) - .context("Cloudflare VM cleanup identity mismatch")?; - if ownership.environment != policy.name() { - return Err(anyhow!( - "refusing VM cleanup for a non-test Cloudflare environment" - )); - } - let expected_marker = format!("skillet:test:{}:{}", args.hostname, args.instance); - let expected_token_name = format!("skillet:test:{}-{}", args.hostname, args.instance); - if ownership.marker != expected_marker || ownership.token_name != expected_token_name { - return Err(anyhow!( - "Cloudflare metadata does not match this VM identity; refusing cleanup" - )); - } - skillet_workstation::cloudflare::validate_zone_id(&ownership.zone_id)?; - skillet_caddy::validate_domain_in_zone(&ownership.ui_domain, &ownership.ui_domain) - .context("validating Cloudflare UI domain in VM metadata")?; let vault_path = args .database .clone() @@ -568,53 +548,20 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( policy.cloudflare_zone_entry() ) })?; - let configured_prefix = vault.get(&policy.ui_domain_entry())?; - if configured_zone.trim() != ownership.zone_id { - return Err(anyhow!("test Cloudflare configuration differs from the recorded VM owner; restore the original vault values before cleanup")); - } let api = skillet_workstation::cloudflare::Cloudflare::new(); - let zone = api.zone(&creator, &ownership.zone_id)?; - let account_id = skillet_workstation::cloudflare::Cloudflare::account_id(&zone)?.to_string(); - let configured_domain = - skillet_caddy::resolve_ui_domain(&zone.name, configured_prefix.as_deref()) - .context("resolving test UI namespace before VM cleanup")?; - if configured_domain != ownership.ui_domain { - return Err(anyhow!("test Cloudflare configuration differs from the recorded VM owner; restore the original relative prefix before cleanup")); - } - skillet_caddy::validate_domain_in_zone(&ownership.ui_domain, &zone.name) - .context("validating recorded test UI domain against its Cloudflare zone")?; - let cleanup_name = format!("{}:cleanup", ownership.token_name); - let cleanup_token = api.create_zone_token( - &creator, - &ownership.zone_id, - &account_id, - &cleanup_name, - Some(policy.cleanup_token_lifetime()), + let configured_prefix = vault.get(&policy.ui_domain_entry())?; + skillet_workstation::ui_provisioning::cleanup_disposable_ui( + &skillet_workstation::ui_provisioning::DisposableUiCleanupRequest { + host: &args.hostname, + instance: &args.instance, + policy, + ownership_path: metadata_path.to_path_buf(), + configured_zone_id: &configured_zone, + relative_ui_domain: configured_prefix.as_deref(), + creator_token: &creator, + }, + &api, )?; - let cleanup = (|| { - api.zone(&cleanup_token.value, &ownership.zone_id)?; - api.remove_dns_marker( - &cleanup_token.value, - &ownership.zone_id, - &ownership.marker, - &ownership.ui_domain, - )?; - for id in api.token_ids_by_name(&creator, &account_id, &ownership.token_name)? { - api.revoke_token(&creator, &account_id, &id)?; - } - Ok::<(), anyhow::Error>(()) - })(); - let revoke_cleanup = api - .token_ids_by_name(&creator, &account_id, &cleanup_name) - .and_then(|ids| { - for id in ids { - api.revoke_token(&creator, &account_id, &id)?; - } - Ok(()) - }); - cleanup?; - revoke_cleanup.context("revoking temporary Cloudflare cleanup token")?; - fs::remove_file(metadata_path).context("removing Cloudflare VM ownership metadata")?; Ok(()) } diff --git a/ublue/skillet/crates/workstation/src/provisioning_state.rs b/ublue/skillet/crates/workstation/src/provisioning_state.rs index 451177fe..b68dcd56 100644 --- a/ublue/skillet/crates/workstation/src/provisioning_state.rs +++ b/ublue/skillet/crates/workstation/src/provisioning_state.rs @@ -93,6 +93,38 @@ pub fn load_cloudflare_ownership( read_json(path) } +pub fn cloudflare_ownership_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(ProvisioningStateError::InvalidPath( + "Cloudflare ownership state must be a regular file", + )) + } + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +pub fn remove_cloudflare_ownership(path: &Path) -> Result<(), ProvisioningStateError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(ProvisioningStateError::InvalidPath( + "Cloudflare ownership state must be a regular file", + )) + } + Ok(_) => { + fs::remove_file(path)?; + if let Some(parent) = path.parent() { + File::open(parent)?.sync_all()?; + } + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + /// Confirm that a Cloudflare journal belongs to the requested typed identity. /// Legacy journals are still validated by their exact marker and token name at /// the call site; new journals must match every identity field here. diff --git a/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs b/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs index fdc265b8..76c5dfa8 100644 --- a/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs +++ b/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs @@ -50,6 +50,31 @@ fn cloudflare_ownership_is_private_atomic_json_state() { )); } +#[test] +fn cloudflare_ownership_presence_and_removal_reject_non_regular_paths() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cloudflare.json"); + assert!(!cloudflare_ownership_exists(&path).unwrap()); + save_cloudflare_ownership(&path, &ownership()).unwrap(); + assert!(cloudflare_ownership_exists(&path).unwrap()); + remove_cloudflare_ownership(&path).unwrap(); + assert!(!cloudflare_ownership_exists(&path).unwrap()); + + let target = directory.path().join("target.json"); + fs::write(&target, b"{}").unwrap(); + let link = directory.path().join("link.json"); + symlink(&target, &link).unwrap(); + assert!(matches!( + cloudflare_ownership_exists(&link), + Err(ProvisioningStateError::InvalidPath(_)) + )); + assert!(matches!( + remove_cloudflare_ownership(&link), + Err(ProvisioningStateError::InvalidPath(_)) + )); + assert!(target.exists()); +} + #[test] fn legacy_cloudflare_journal_remains_readable_but_new_identity_mismatches_fail() { let directory = tempfile::tempdir().unwrap(); diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs index a323c82f..87385d4f 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -52,6 +52,25 @@ pub trait UiCloudflareProvider { ui_domain: &str, desired: &[DesiredRecord], ) -> Result; + fn remove_dns_marker( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + ) -> Result<(), CloudflareError>; + fn token_ids_by_name( + &self, + creator_token: &str, + account_id: &str, + name: &str, + ) -> Result, CloudflareError>; + fn revoke_token( + &self, + creator_token: &str, + account_id: &str, + token_id: &str, + ) -> Result<(), CloudflareError>; } impl UiCloudflareProvider for cloudflare::Cloudflare { @@ -87,6 +106,34 @@ impl UiCloudflareProvider for cloudflare::Cloudflare { ) -> Result { cloudflare::Cloudflare::reconcile_dns(self, token, zone_id, marker, ui_domain, desired) } + + fn remove_dns_marker( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + ) -> Result<(), CloudflareError> { + cloudflare::Cloudflare::remove_dns_marker(self, token, zone_id, marker, ui_domain) + } + + fn token_ids_by_name( + &self, + creator_token: &str, + account_id: &str, + name: &str, + ) -> Result, CloudflareError> { + cloudflare::Cloudflare::token_ids_by_name(self, creator_token, account_id, name) + } + + fn revoke_token( + &self, + creator_token: &str, + account_id: &str, + token_id: &str, + ) -> Result<(), CloudflareError> { + cloudflare::Cloudflare::revoke_token(self, creator_token, account_id, token_id) + } } pub struct DisposableUiRequest<'a> { @@ -107,6 +154,107 @@ pub struct DisposableUiCredentials { pub token_name: String, } +pub struct DisposableUiCleanupRequest<'a> { + pub host: &'a str, + pub instance: &'a str, + pub policy: ProvisioningPolicy, + pub ownership_path: std::path::PathBuf, + pub configured_zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub creator_token: &'a str, +} + +/// Remove only DNS and tokens owned by the typed disposable VM journal. The +/// journal remains until provider cleanup and temporary-token revocation pass. +pub fn cleanup_disposable_ui( + request: &DisposableUiCleanupRequest<'_>, + provider: &impl UiCloudflareProvider, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(UiProvisioningError::Invalid( + "disposable UI cleanup requires the test environment policy".into(), + )); + } + if !provisioning_state::cloudflare_ownership_exists(&request.ownership_path)? { + return Ok(false); + } + let ownership = provisioning_state::load_cloudflare_ownership(&request.ownership_path)?; + let expected_identity = + ProvisioningIdentity::new(request.host, request.policy.name(), request.instance); + provisioning_state::validate_cloudflare_identity(&ownership, &expected_identity)?; + let expected_marker = format!( + "skillet:{}:{}:{}", + request.policy.name(), + request.host, + request.instance + ); + let expected_token_name = format!( + "skillet:{}:{}-{}", + request.policy.name(), + request.host, + request.instance + ); + if ownership.environment != request.policy.name() + || ownership.marker != expected_marker + || ownership.token_name != expected_token_name + { + return Err(UiProvisioningError::Invalid( + "Cloudflare metadata does not match this VM identity; refusing cleanup".into(), + )); + } + cloudflare::validate_zone_id(&ownership.zone_id)?; + if request.configured_zone_id.trim() != ownership.zone_id { + return Err(UiProvisioningError::Invalid( + "test Cloudflare zone differs from the recorded VM owner; restore the original vault values before cleanup".into(), + )); + } + let zone = provider.zone(request.creator_token, &ownership.zone_id)?; + let configured_domain = + skillet_caddy::resolve_ui_domain(&zone.name, request.relative_ui_domain)?; + if configured_domain != ownership.ui_domain { + return Err(UiProvisioningError::Invalid( + "test UI namespace differs from the recorded VM owner; restore the original relative prefix before cleanup".into(), + )); + } + skillet_caddy::validate_domain_in_zone(&ownership.ui_domain, &zone.name)?; + let account_id = cloudflare::Cloudflare::account_id(&zone)?.to_string(); + let cleanup_name = format!("{}:cleanup", ownership.token_name); + let cleanup_token = provider.create_zone_token( + request.creator_token, + &ownership.zone_id, + &account_id, + &cleanup_name, + Some(request.policy.cleanup_token_lifetime()), + )?; + let cleanup = (|| { + provider.zone(&cleanup_token.value, &ownership.zone_id)?; + provider.remove_dns_marker( + &cleanup_token.value, + &ownership.zone_id, + &ownership.marker, + &ownership.ui_domain, + )?; + for id in + provider.token_ids_by_name(request.creator_token, &account_id, &ownership.token_name)? + { + provider.revoke_token(request.creator_token, &account_id, &id)?; + } + Ok::<(), CloudflareError>(()) + })(); + let revoke_cleanup = provider + .token_ids_by_name(request.creator_token, &account_id, &cleanup_name) + .and_then(|ids| { + for id in ids { + provider.revoke_token(request.creator_token, &account_id, &id)?; + } + Ok(()) + }); + cleanup?; + revoke_cleanup?; + provisioning_state::remove_cloudflare_ownership(&request.ownership_path)?; + Ok(true) +} + /// Persist each ownership transition around disposable token and DNS /// mutations. The vault guard runs immediately before token issuance. pub fn provision_disposable_ui( diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index d9bd0d23..46804520 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -19,6 +19,8 @@ struct FakeCloudflare { run_directory: PathBuf, fail_dns: bool, issued: std::cell::Cell, + removed: std::cell::Cell, + revoked: std::cell::RefCell>, } impl UiCloudflareProvider for FakeCloudflare { @@ -41,9 +43,15 @@ impl UiCloudflareProvider for FakeCloudflare { lifetime: Option, ) -> Result { let ownership = read_ownership(&self.run_directory); - assert_eq!(ownership.token_name, name); - assert!(ownership.token_id.is_none()); - assert_eq!(lifetime, Some(std::time::Duration::from_hours(12))); + assert!( + name == ownership.token_name || name == format!("{}:cleanup", ownership.token_name) + ); + if name == ownership.token_name { + assert!(ownership.token_id.is_none()); + assert_eq!(lifetime, Some(std::time::Duration::from_hours(12))); + } else { + assert_eq!(lifetime, Some(std::time::Duration::from_mins(15))); + } self.issued.set(self.issued.get() + 1); Ok(IssuedToken { id: "22222222222222222222222222222222".to_string(), @@ -81,6 +89,49 @@ impl UiCloudflareProvider for FakeCloudflare { .collect(), }) } + + fn remove_dns_marker( + &self, + _token: &str, + _zone_id: &str, + _marker: &str, + _ui_domain: &str, + ) -> Result<(), CloudflareError> { + if self.fail_dns { + return Err(CloudflareError::Invalid("fixture DNS failure".into())); + } + self.removed.set(self.removed.get() + 1); + Ok(()) + } + + fn token_ids_by_name( + &self, + _creator_token: &str, + _account_id: &str, + name: &str, + ) -> Result, CloudflareError> { + Ok(vec![format!("id-for:{name}")]) + } + + fn revoke_token( + &self, + _creator_token: &str, + _account_id: &str, + token_id: &str, + ) -> Result<(), CloudflareError> { + self.revoked.borrow_mut().push(token_id.to_string()); + Ok(()) + } +} + +fn fake_cloudflare(run_directory: &Path, fail_dns: bool) -> FakeCloudflare { + FakeCloudflare { + run_directory: run_directory.to_path_buf(), + fail_dns, + issued: std::cell::Cell::new(0), + removed: std::cell::Cell::new(0), + revoked: std::cell::RefCell::new(Vec::new()), + } } fn read_ownership(run_directory: &Path) -> CloudflareVmOwnership { @@ -103,6 +154,28 @@ fn disposable_request<'a>( } } +fn cleanup_request(run_directory: &Path) -> DisposableUiCleanupRequest<'_> { + DisposableUiCleanupRequest { + host: "clamps", + instance: "smoke", + policy: ProvisioningPolicy::new(Environment::Test), + ownership_path: run_directory.join("cloudflare.json"), + configured_zone_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + relative_ui_domain: Some("smoke.ui"), + creator_token: "dummy creator token", + } +} + +fn create_cleanup_fixture(run_directory: &Path, provider: &FakeCloudflare) { + let addresses = addresses(); + provision_disposable_ui( + &disposable_request(run_directory, &addresses), + provider, + || Ok(()), + ) + .unwrap(); +} + #[test] fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { let production = build_ui_provisioning_plan( @@ -215,11 +288,7 @@ fn disposable_cloudflare_ownership_precedes_token_and_dns_mutations() { .unwrap() .dns_records .len(); - let provider = FakeCloudflare { - run_directory: run.path().to_path_buf(), - fail_dns: false, - issued: std::cell::Cell::new(0), - }; + let provider = fake_cloudflare(run.path(), false); let guard_calls = std::cell::Cell::new(0); let provisioned = provision_disposable_ui( @@ -253,11 +322,7 @@ fn disposable_cloudflare_ownership_precedes_token_and_dns_mutations() { fn disposable_cloudflare_journal_keeps_issued_token_after_dns_failure() { let run = tempfile::tempdir().unwrap(); let addresses = addresses(); - let provider = FakeCloudflare { - run_directory: run.path().to_path_buf(), - fail_dns: true, - issued: std::cell::Cell::new(0), - }; + let provider = fake_cloudflare(run.path(), true); let result = provision_disposable_ui( &disposable_request(run.path(), &addresses), @@ -279,11 +344,7 @@ fn disposable_cloudflare_journal_keeps_issued_token_after_dns_failure() { fn disposable_cloudflare_checks_vault_before_issuing_token() { let run = tempfile::tempdir().unwrap(); let addresses = addresses(); - let provider = FakeCloudflare { - run_directory: run.path().to_path_buf(), - fail_dns: false, - issued: std::cell::Cell::new(0), - }; + let provider = fake_cloudflare(run.path(), false); let result = provision_disposable_ui( &disposable_request(run.path(), &addresses), @@ -298,3 +359,56 @@ fn disposable_cloudflare_checks_vault_before_issuing_token() { assert_eq!(provider.issued.get(), 0); assert!(read_ownership(run.path()).token_id.is_none()); } + +#[test] +fn disposable_cloudflare_cleanup_removes_only_journaled_owner_after_revocations() { + let run = tempfile::tempdir().unwrap(); + let provider = fake_cloudflare(run.path(), false); + create_cleanup_fixture(run.path(), &provider); + + assert!(cleanup_disposable_ui(&cleanup_request(run.path()), &provider).unwrap()); + + assert_eq!(provider.removed.get(), 1); + assert_eq!(provider.issued.get(), 2); + assert_eq!( + provider.revoked.borrow().as_slice(), + [ + "id-for:skillet:test:clamps-smoke", + "id-for:skillet:test:clamps-smoke:cleanup" + ] + ); + assert!(!run.path().join("cloudflare.json").exists()); + assert!(!cleanup_disposable_ui(&cleanup_request(run.path()), &provider).unwrap()); +} + +#[test] +fn disposable_cloudflare_cleanup_mismatch_has_no_provider_mutations() { + let run = tempfile::tempdir().unwrap(); + let provider = fake_cloudflare(run.path(), false); + create_cleanup_fixture(run.path(), &provider); + let mut request = cleanup_request(run.path()); + request.instance = "other"; + + assert!(cleanup_disposable_ui(&request, &provider).is_err()); + assert_eq!(provider.issued.get(), 1); + assert_eq!(provider.removed.get(), 0); + assert!(provider.revoked.borrow().is_empty()); + assert!(run.path().join("cloudflare.json").exists()); +} + +#[test] +fn disposable_cloudflare_cleanup_failure_retains_journal_for_retry() { + let run = tempfile::tempdir().unwrap(); + let mut provider = fake_cloudflare(run.path(), false); + create_cleanup_fixture(run.path(), &provider); + provider.fail_dns = true; + + assert!(cleanup_disposable_ui(&cleanup_request(run.path()), &provider).is_err()); + assert_eq!(provider.issued.get(), 2); + assert_eq!(provider.removed.get(), 0); + assert_eq!( + provider.revoked.borrow().as_slice(), + ["id-for:skillet:test:clamps-smoke:cleanup"] + ); + assert!(run.path().join("cloudflare.json").exists()); +} From ab7d5f3056996f209e50dc22faccc6be6bfbb897 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:49:42 +0200 Subject: [PATCH 173/252] refactor(workstation): own disposable Tailscale cleanup --- ublue/butane/ACCEPTANCE.md | 16 ++++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 7 +- .../skillet/crates/cli/src/secret_delivery.rs | 46 ++++----- .../workstation/src/provisioning_state.rs | 68 ++++++++++++- .../workstation/src/tailscale_enrollment.rs | 80 ++++++++++++++++ .../src/tailscale_enrollment/tests.rs | 95 +++++++++++++++++++ 6 files changed, 277 insertions(+), 35 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 85d688cf..eed6543a 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1247,3 +1247,19 @@ host tool; it still uses normal command approvals. repeat issued no service start/restart and the container was removed. Podman needed SIGKILL after its 10-second stop timeout during cleanup. - No VM or live Cloudflare API was contacted. `/dev/kvm` is unavailable. + +### Workstation-owned disposable Tailscale cleanup, 2026-10-04 + +- Moved test-device cleanup into `skillet_workstation::tailscale_enrollment`. + The operation validates the disposable environment and typed host/instance/ + VM hostname, verifies ownership journals, asks the provider to remove only + the recorded tagged device, and removes recovery files after provider success + or an already-absent device response. Provider errors retain the journal. +- The workstation crate tests passed (51 tests), including provider failure + retry, recorded-device identity verification, local journal cleanup, and + identity mismatch refusal before provider calls. Full workspace tests, + strict all-target Clippy, and formatting also passed. +- The beezelbot Fedora base integration exited 0: both applies succeeded; the + repeat issued no service start/restart and the container was removed. Podman + needed SIGKILL after its 10-second stop timeout during cleanup. +- No VM or live Tailscale API was contacted. `/dev/kvm` is unavailable. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index ec5bdc48..62f19495 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -149,7 +149,12 @@ and VM delivery have duplicate implementations with different validation. and verified guest transport separately. It persists intent before effects, reuses an already-joined VM, installs a one-use key only after a successful empty status observation, verifies the tagged device, and records/removes - recovery state. Tailscale cleanup remains in CLI. + recovery state. +- Implemented: disposable Tailscale cleanup now lives beside enrollment in + `skillet_workstation::tailscale_enrollment`. It validates the test policy and + typed identity, verifies both current and legacy recovery records, removes + only the expected tagged device, and clears local journals afterward. A + provider failure leaves recovery state available for retry. - Implemented: disposable Cloudflare UI issuance and DNS reconciliation now live in `skillet_workstation::ui_provisioning`. The operation writes its owner journal before token creation, verifies the vault snapshot immediately diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 9b476c40..173ecee9 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -3,7 +3,7 @@ use super::{ }; use anyhow::{anyhow, Context, Result}; use skillet_vm::transport::GuestTransport as _; -use skillet_workstation::provisioning_state::{self, ProvisioningIdentity}; +use skillet_workstation::provisioning_state; use skillet_workstation::tailscale; use skillet_workstation::vault::Vault; use std::{ @@ -367,51 +367,39 @@ pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { let name = vm_name(&args.hostname, &args.instance)?; let butane = butane_root()?; let run_dir = butane.join("runs").join(&name); - let pending = run_dir.join("tailscale-pending"); - let record_path = run_dir.join("tailscale.json"); let cloudflare_path = run_dir.join("cloudflare.json"); let cloudflare_exists = provisioning_state::cloudflare_ownership_exists(&cloudflare_path)?; - if !pending.exists() && !record_path.exists() && !cloudflare_exists { + let tailscale_pending = provisioning_state::tailscale_pending_exists(&run_dir)?; + let tailscale_record = provisioning_state::tailscale_record_exists(&run_dir)?; + if !tailscale_pending && !tailscale_record && !cloudflare_exists { return Ok(()); } if cloudflare_exists { cleanup_vm_cloudflare(args, &cloudflare_path)?; } - if !pending.exists() && !record_path.exists() { + if !provisioning_state::tailscale_pending_exists(&run_dir)? + && !provisioning_state::tailscale_record_exists(&run_dir)? + { return Ok(()); } let policy = UiEnvironmentName::Test.policy(); - let provisioning_identity = - ProvisioningIdentity::new(&args.hostname, policy.name(), &args.instance); - let expected = if record_path.exists() { - Some(provisioning_state::load_tailscale_record( - &record_path, - &provisioning_identity, - &name, - )?) - } else { - None - }; let vault_path = match &args.database { Some(path) => path.clone(), None => default_database_path()?, }; let vault = Vault::open(&vault_path, args.key_file.as_deref())?; let credentials = tailscale_credentials(&vault)?; - if tailscale::remove_device_for_hostname( + skillet_workstation::tailscale_enrollment::cleanup_disposable_vm( + &skillet_workstation::tailscale_enrollment::DisposableCleanup { + host: &args.hostname, + instance: &args.instance, + vm_hostname: &name, + run_directory: &run_dir, + policy, + }, &credentials, - &name, - policy.tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::DisposableVm), - expected.as_ref(), - )? - .is_some() - { - println!("Removed Tailscale device for {name}"); - } - provisioning_state::remove_tailscale_pending(&run_dir)?; - if record_path.exists() { - fs::remove_file(record_path).context("removing Tailscale VM metadata")?; - } + )?; + println!("Cleaned Tailscale ownership for {name}"); Ok(()) } diff --git a/ublue/skillet/crates/workstation/src/provisioning_state.rs b/ublue/skillet/crates/workstation/src/provisioning_state.rs index b68dcd56..b9d21972 100644 --- a/ublue/skillet/crates/workstation/src/provisioning_state.rs +++ b/ublue/skillet/crates/workstation/src/provisioning_state.rs @@ -176,6 +176,47 @@ pub fn load_tailscale_record( Ok(legacy) } +pub fn tailscale_record_exists(run_directory: &Path) -> Result { + regular_file_exists( + &run_directory.join("tailscale.json"), + "Tailscale ownership state", + ) +} + +pub fn remove_tailscale_record(run_directory: &Path) -> Result<(), ProvisioningStateError> { + remove_regular_file( + &run_directory.join("tailscale.json"), + "Tailscale ownership state", + ) +} + +pub fn tailscale_pending_exists(run_directory: &Path) -> Result { + regular_file_exists( + &run_directory.join("tailscale-pending"), + "pending Tailscale state", + ) +} + +pub fn validate_tailscale_pending( + run_directory: &Path, + expected_identity: &ProvisioningIdentity, + expected_hostname: &str, +) -> Result { + let path = run_directory.join("tailscale-pending"); + if !regular_file_exists(&path, "pending Tailscale state")? { + return Ok(false); + } + let contents = fs::read(path)?; + if let Ok(pending) = serde_json::from_slice::(&contents) { + if pending.identity != *expected_identity || pending.hostname != expected_hostname { + return Err(ProvisioningStateError::IdentityMismatch); + } + } else if String::from_utf8_lossy(&contents) != expected_hostname { + return Err(ProvisioningStateError::IdentityMismatch); + } + Ok(true) +} + /// Persist cleanup intent before creating a disposable Tailscale device. /// Repeated calls must describe the same VM identity. pub fn mark_tailscale_pending( @@ -250,13 +291,30 @@ pub fn mark_tailscale_pending( pub fn remove_tailscale_pending(run_directory: &Path) -> Result<(), ProvisioningStateError> { let path = run_directory.join("tailscale-pending"); - match fs::symlink_metadata(&path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Err( - ProvisioningStateError::InvalidPath("pending Tailscale state must be a regular file"), - ), + remove_regular_file(&path, "pending Tailscale state") +} + +fn regular_file_exists(path: &Path, label: &'static str) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(ProvisioningStateError::InvalidPath(label)) + } + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +fn remove_regular_file(path: &Path, label: &'static str) -> Result<(), ProvisioningStateError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(ProvisioningStateError::InvalidPath(label)) + } Ok(_) => { fs::remove_file(path)?; - File::open(run_directory)?.sync_all()?; + if let Some(parent) = path.parent() { + File::open(parent)?.sync_all()?; + } Ok(()) } Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs index e05919ba..3523a3ec 100644 --- a/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs @@ -32,6 +32,17 @@ pub trait EnrollmentProvider { ) -> tailscale::Result; } +/// Device removal is kept separate from enrollment so callers and tests only +/// provide the provider capability used by a particular lifecycle action. +pub trait DeviceCleanupProvider { + fn remove_device( + &self, + hostname: &str, + expected_tag: &str, + expected: Option<&DeviceRecord>, + ) -> tailscale::Result>; +} + impl EnrollmentProvider for OAuthCredentials { fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result { tailscale::create_auth_key(self, tag, description) @@ -47,6 +58,17 @@ impl EnrollmentProvider for OAuthCredentials { } } +impl DeviceCleanupProvider for OAuthCredentials { + fn remove_device( + &self, + hostname: &str, + expected_tag: &str, + expected: Option<&DeviceRecord>, + ) -> tailscale::Result> { + tailscale::remove_device_for_hostname(self, hostname, expected_tag, expected) + } +} + /// Separate host, environment, instance, runtime hostname and transport inputs /// so the caller's VM identity is never reconstructed from a composite name. pub struct DisposableEnrollment<'a> { @@ -117,6 +139,64 @@ pub fn enroll_disposable_vm( Ok(record) } +pub struct DisposableCleanup<'a> { + pub host: &'a str, + pub instance: &'a str, + pub vm_hostname: &'a str, + pub run_directory: &'a Path, + pub policy: ProvisioningPolicy, +} + +/// Remove the provider device verified by its ownership journal, then remove +/// local recovery records. If local cleanup fails after provider removal, a +/// retry safely observes the absent provider device and finishes journal cleanup. +pub fn cleanup_disposable_vm( + request: &DisposableCleanup<'_>, + provider: &impl DeviceCleanupProvider, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(EnrollmentError::Invalid( + "disposable Tailscale cleanup requires the test environment policy".into(), + )); + } + let profile = skillet_hosts::profile_for_name(request.host).ok_or_else(|| { + EnrollmentError::Invalid(format!("unknown host profile {}", request.host)) + })?; + if !profile.supports_service("tailscale") { + return Err(EnrollmentError::Invalid(format!( + "host {} does not declare Tailscale", + request.host + ))); + } + let identity = ProvisioningIdentity::new(request.host, request.policy.name(), request.instance); + let record_exists = provisioning_state::tailscale_record_exists(request.run_directory)?; + let pending_exists = provisioning_state::validate_tailscale_pending( + request.run_directory, + &identity, + request.vm_hostname, + )?; + if !record_exists && !pending_exists { + return Ok(false); + } + let expected = if record_exists { + Some(provisioning_state::load_tailscale_record( + &request.run_directory.join("tailscale.json"), + &identity, + request.vm_hostname, + )?) + } else { + None + }; + provider.remove_device( + request.vm_hostname, + request.policy.tailscale_tag(DeviceClass::DisposableVm), + expected.as_ref(), + )?; + provisioning_state::remove_tailscale_record(request.run_directory)?; + provisioning_state::remove_tailscale_pending(request.run_directory)?; + Ok(true) +} + fn guest_addresses(guest: &impl GuestTransport) -> Result, EnrollmentError> { let output = guest.execute( &skillet_vm::transport::GuestCommand { diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs index 22654b72..d7f983ab 100644 --- a/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs @@ -12,6 +12,35 @@ struct FakeProvider { devices_found: Cell, } +#[derive(Default)] +struct FakeCleanupProvider { + removed: Cell, + fail: Cell, + expected_id: Mutex>, +} + +impl DeviceCleanupProvider for FakeCleanupProvider { + fn remove_device( + &self, + hostname: &str, + expected_tag: &str, + expected: Option<&DeviceRecord>, + ) -> tailscale::Result> { + self.removed.set(self.removed.get() + 1); + assert_eq!(expected_tag, tailscale::SMOKE_TAG); + *self.expected_id.lock().unwrap() = expected.map(|device| device.id.clone()); + if self.fail.get() { + return Err(tailscale::TailscaleError::Invalid( + "fixture removal failure".into(), + )); + } + Ok(expected.cloned().map(|mut device| { + device.hostname = hostname.to_string(); + device + })) + } +} + impl EnrollmentProvider for FakeProvider { fn create_auth_key(&self, _tag: &str, _description: &str) -> tailscale::Result { self.keys_created.set(self.keys_created.get() + 1); @@ -113,6 +142,24 @@ fn request(run_directory: &std::path::Path) -> DisposableEnrollment<'_> { } } +fn cleanup_request(run_directory: &std::path::Path) -> DisposableCleanup<'_> { + DisposableCleanup { + host: "clamps", + instance: "smoke", + vm_hostname: "clamps-test-smoke", + run_directory, + policy: ProvisioningPolicy::new(Environment::Test), + } +} + +fn recorded_device() -> DeviceRecord { + DeviceRecord { + id: "device-id".to_string(), + hostname: "clamps-test-smoke".to_string(), + addresses: BTreeSet::from(["100.64.0.10".to_string()]), + } +} + #[test] fn already_enrolled_vm_is_recorded_without_creating_an_auth_key() { let run = tempfile::tempdir().unwrap(); @@ -191,3 +238,51 @@ fn production_policy_is_rejected_before_guest_or_provider_effects() { assert!(guest.installs.lock().unwrap().is_empty()); assert!(!run.path().join("tailscale-pending").exists()); } + +#[test] +fn disposable_cleanup_removes_verified_device_then_local_recovery_state() { + let run = tempfile::tempdir().unwrap(); + let identity = ProvisioningIdentity::new("clamps", "test", "smoke"); + provisioning_state::save_tailscale_record(run.path(), &identity, &recorded_device()).unwrap(); + provisioning_state::mark_tailscale_pending(run.path(), &identity, "clamps-test-smoke").unwrap(); + let provider = FakeCleanupProvider::default(); + + assert!(cleanup_disposable_vm(&cleanup_request(run.path()), &provider).unwrap()); + + assert_eq!(provider.removed.get(), 1); + assert_eq!( + provider.expected_id.lock().unwrap().as_deref(), + Some("device-id") + ); + assert!(!run.path().join("tailscale.json").exists()); + assert!(!run.path().join("tailscale-pending").exists()); + assert!(!cleanup_disposable_vm(&cleanup_request(run.path()), &provider).unwrap()); +} + +#[test] +fn provider_failure_preserves_tailscale_cleanup_journal_for_retry() { + let run = tempfile::tempdir().unwrap(); + let identity = ProvisioningIdentity::new("clamps", "test", "smoke"); + provisioning_state::save_tailscale_record(run.path(), &identity, &recorded_device()).unwrap(); + let provider = FakeCleanupProvider::default(); + provider.fail.set(true); + + assert!(cleanup_disposable_vm(&cleanup_request(run.path()), &provider).is_err()); + assert!(run.path().join("tailscale.json").is_file()); + + provider.fail.set(false); + assert!(cleanup_disposable_vm(&cleanup_request(run.path()), &provider).unwrap()); + assert!(!run.path().join("tailscale.json").exists()); +} + +#[test] +fn tailscale_cleanup_identity_mismatch_stops_before_provider_mutation() { + let run = tempfile::tempdir().unwrap(); + let wrong = ProvisioningIdentity::new("other-host", "test", "smoke"); + provisioning_state::save_tailscale_record(run.path(), &wrong, &recorded_device()).unwrap(); + let provider = FakeCleanupProvider::default(); + + assert!(cleanup_disposable_vm(&cleanup_request(run.path()), &provider).is_err()); + assert_eq!(provider.removed.get(), 0); + assert!(run.path().join("tailscale.json").is_file()); +} From 7055abe0d30bb3fbdce8cf1fddd4c53dd34b789c Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 04:59:52 +0200 Subject: [PATCH 174/252] refactor(workstation): own persistent UI delivery --- ublue/butane/ACCEPTANCE.md | 20 ++ ublue/design/cloudflare-ui-lifecycle.md | 11 +- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 14 +- .../skillet/crates/cli/src/secret_delivery.rs | 115 ++------ .../crates/workstation/src/ui_provisioning.rs | 206 ++++++++++++- .../workstation/src/ui_provisioning/tests.rs | 274 +++++++++++++++++- 6 files changed, 528 insertions(+), 112 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index eed6543a..5d83a33f 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1214,6 +1214,26 @@ host tool; it still uses normal command approvals. removed. Podman needed SIGKILL after its 10-second stop timeout. - No VM or live Tailscale API was contacted. `/dev/kvm` is unavailable. +### Workstation-owned persistent private UI provisioning, 2026-10-04 + +- Moved persistent Caddy provisioning into one workstation operation. It checks + the host profile, resolves the tagged Tailscale device, builds the shared UI + plan, reuses or mints a zone-scoped ACME token, migrates the prior production + KeePass entry when present, reconciles owned DNS, installs both Caddy + credentials before activation, and uses the existing verified guest + transport. Failed encrypted vault persistence revokes the newly issued + token. The CLI now supplies inputs and dispatches the operation. +- Fake store/provider/guest tests cover token reuse, issuance-before-DNS, + legacy migration, vault conflict refusal, failed-save revocation, and + deferred activation after the full credential set. Full workspace tests, + strict all-target Clippy, and formatting passed (56 workstation tests). +- The beezelbot Fedora base integration exited 0: both applies succeeded; the + repeat issued no service start/restart and the container was removed. Podman + needed SIGKILL after its 10-second stop timeout during cleanup. +- No vault or live provider credentials were used here. Caddy certificate + acceptance and Tailscale device lookup on a named production host remain + unverified; `/dev/kvm` is unavailable. + ### Workstation-owned disposable Cloudflare UI transaction, 2026-10-04 - Moved disposable UI token issuance and DNS reconciliation into diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index ac8b541a..a6f6d83c 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -109,12 +109,15 @@ DNS record comments that the crate's DNS models do not expose. Persistent delivery use-or-creates and saves its scoped token in KeePassXC. The workstation UI provisioner now persists disposable owner intent, issues and journals its scoped token, reconciles DNS, records owned record IDs, and owns -disposable cleanup. Cleanup validates the recorded identity and current +disposable cleanup. Persistent delivery also owns tailnet device lookup, +ACME-token reuse or issuance, migration of the legacy production entry, +DNS reconciliation, complete credential-set delivery, and deferred Caddy +activation. Cleanup validates the recorded identity and current environment configuration before mutation, removes marker-owned DNS, revokes the disposable and temporary cleanup tokens, and removes its journal last. -Failures retain the journal for retry. Guest credential delivery and -certificate acceptance still run through the CLI and are being moved behind -workstation operations. Local tests cover the SDK transport, permission group +Failures retain the journal for retry. Disposable guest credential delivery +and certificate acceptance still run through the CLI. Local +tests cover the SDK transport, permission group discovery, scoped token payloads, address-family validation, and DNS planning. Live 2026-10-02 staging acceptance issued certificates for each declared canonical UI and alias, verified DNS and HTTPS over IPv4/IPv6, and diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 62f19495..f398b517 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -167,7 +167,13 @@ and VM delivery have duplicate implementations with different validation. marker-owned DNS, revokes disposable and temporary cleanup tokens, and only then removes the journal. Provider failures retain the journal for retry; symlinked or non-regular state paths are rejected. -- Pending: guest delivery/activation and its retry policy, production plus - named-VM acceptance, and remaining environment/host delivery orchestration - still span CLI and workstation modules. Complete the remaining sequence - items before closing this workstream. +- Implemented: persistent UI provisioning now shares one workstation + transaction for tailnet device lookup, shared site/DNS derivation, ACME-token + reuse or scoped issuance, legacy production-token migration, DNS + reconciliation, complete Caddy credential-set delivery, and deferred + activation. The CLI supplies its parsed host/environment, vault, provider + credentials, and verified SSH transport. +- Pending: disposable Caddy guest delivery/activation and its retry policy, + production plus named-VM acceptance, and the remaining non-UI production + credential workflows still need coverage or extraction. Complete the + remaining sequence items before closing this workstream. diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 173ecee9..fa4c1b35 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -2,7 +2,6 @@ use super::{ butane_root, vm_name, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, VmProvisionArgs, }; use anyhow::{anyhow, Context, Result}; -use skillet_vm::transport::GuestTransport as _; use skillet_workstation::provisioning_state; use skillet_workstation::tailscale; use skillet_workstation::vault::Vault; @@ -96,15 +95,13 @@ fn validate_delivery_service(hostname: &str, service: &str) -> Result Result<()> { let policy = args.environment.policy(); - let environment = policy.name(); let domain_path = policy.ui_domain_entry(); let domain_prefix = vault.get(&domain_path)?; let zone_path = policy.cloudflare_zone_entry(); let zone_id = vault .get(zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; - let zone_id = zone_id.trim(); - skillet_workstation::cloudflare::validate_zone_id(zone_id)?; + let zone_id = zone_id.trim().to_string(); let creator = vault .get("skillet/cloudflare/token-creator")? .ok_or_else(|| { @@ -112,107 +109,29 @@ fn deliver_caddy_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Resu "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" ) })?; - let cloudflare = skillet_workstation::cloudflare::Cloudflare::new(); - let zone = cloudflare.zone(&creator, zone_id)?; let tailnet = tailscale_credentials(vault)?; - let device = tailscale::find_device_by_hostname( - &tailnet, - &args.hostname, - policy.tailscale_tag(skillet_workstation::provisioning_policy::DeviceClass::ProductionHost), - )?; - let ui_plan = skillet_workstation::ui_provisioning::build_ui_provisioning_plan( - &args.hostname, - policy, - &zone.name, - domain_prefix.as_deref(), - &device.addresses, - )?; - let sites = ui_plan.sites; - let dns_marker = format!("skillet:{environment}:{}", args.hostname); - ensure_vault_unchanged(vault)?; - let account_id = skillet_workstation::cloudflare::Cloudflare::account_id(&zone)?; - let token = host_acme_token(args, vault, &cloudflare, &creator, zone_id, account_id)?; - cloudflare.zone(&token, zone_id)?; - ensure_vault_unchanged(vault)?; - cloudflare.reconcile_dns( - &token, - zone_id, - &dns_marker, - &sites.ui_domain, - &ui_plan.dns_records, - )?; - ensure_vault_unchanged(vault)?; - let sites = serde_json::to_string(&sites)?; let transport = credential_transport(args)?; - skillet_vm::credential::install_set( + let cloudflare = skillet_workstation::cloudflare::Cloudflare::new(); + let mut token_store = skillet_workstation::ui_provisioning::VaultUiTokenStore::new( + vault, + args.key_file.as_deref(), + ); + skillet_workstation::ui_provisioning::deliver_persistent_ui( + &skillet_workstation::ui_provisioning::PersistentUiDelivery { + host: &args.hostname, + policy, + zone_id: &zone_id, + relative_ui_domain: domain_prefix.as_deref(), + creator_token: &creator, + }, + &mut token_store, + &cloudflare, + &tailnet, &transport, - &args.hostname, - "skillet-caddy-apply.service", - skillet_vm::credential::ActivationPolicy::DeferConsumer, - &[ - ("caddy_sites", sites.as_bytes()), - ("cloudflare_acme_token", token.as_bytes()), - ], )?; - let output = transport - .execute( - &skillet_vm::transport::GuestCommand { - program: "/usr/bin/sudo", - arguments: &["-n", "systemctl", "start", "skillet-caddy-apply.service"], - }, - None, - ) - .context("starting Caddy apply after both credentials were delivered")?; - if !output.status.success() { - return Err(anyhow!("Caddy apply failed with status {}", output.status)); - } Ok(()) } -fn host_acme_token( - args: &SecretDeliverArgs, - vault: &mut Vault, - api: &skillet_workstation::cloudflare::Cloudflare, - creator: &str, - zone_id: &str, - account_id: &str, -) -> Result { - let environment = args.environment.policy().name(); - let token_path = format!( - "skillet/environments/{environment}/hosts/{}/cloudflare/acme-token", - args.hostname - ); - if let Some(token) = vault.get(&token_path)? { - return Ok(token); - } - let legacy = if args.environment == UiEnvironmentName::Production { - vault.get(&format!( - "skillet/hosts/{}/cloudflare/acme-token", - args.hostname - ))? - } else { - None - }; - if let Some(token) = legacy { - vault.insert(&token_path, &token)?; - vault.save_verified(args.key_file.as_deref(), &token_path, &token)?; - return Ok(token); - } - let token_name = format!("skillet:{environment}:{}", args.hostname); - let issued = api.replace_named_zone_token(creator, zone_id, account_id, &token_name, None)?; - if let Err(error) = vault - .insert(&token_path, &issued.value) - .and_then(|()| vault.save_verified(args.key_file.as_deref(), &token_path, &issued.value)) - { - if let Err(revoke_error) = api.revoke_token(creator, account_id, &issued.id) { - return Err(anyhow!("saving issued Cloudflare credential failed ({error}); revoking token {} also failed ({revoke_error})", issued.id)); - } - return Err(anyhow::Error::new(error) - .context("saving new Cloudflare token into KeePassXC; issued token was revoked")); - } - Ok(issued.value) -} - pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { let path = match path { Some(path) => path.to_path_buf(), diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs index 87385d4f..e7410af8 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -4,9 +4,11 @@ use crate::{ cloudflare::{self, CloudflareError, DesiredRecord, IssuedToken, OwnedDns, Zone}, provisioning_policy::{Environment, ProvisioningPolicy}, provisioning_state::{self, CloudflareVmOwnership, ProvisioningIdentity}, - vault::VaultError, + tailscale::{self, OAuthCredentials, TailscaleError}, + vault::{Vault, VaultError}, }; use skillet_caddy::{CaddyError, CaddySites, UiEnvironment}; +use skillet_vm::transport::GuestTransport; use std::{collections::BTreeSet, path::Path, time::Duration}; use thiserror::Error; @@ -22,7 +24,11 @@ pub enum UiProvisioningError { State(#[from] provisioning_state::ProvisioningStateError), #[error(transparent)] Vault(#[from] VaultError), - #[error("invalid disposable UI provisioning request: {0}")] + #[error(transparent)] + Tailscale(#[from] TailscaleError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), + #[error("invalid UI provisioning request: {0}")] Invalid(String), } @@ -44,6 +50,14 @@ pub trait UiCloudflareProvider { name: &str, lifetime: Option, ) -> Result; + fn replace_named_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result; fn reconcile_dns( &self, token: &str, @@ -96,6 +110,24 @@ impl UiCloudflareProvider for cloudflare::Cloudflare { ) } + fn replace_named_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + cloudflare::Cloudflare::replace_named_zone_token( + self, + creator_token, + zone_id, + account_id, + name, + lifetime, + ) + } + fn reconcile_dns( &self, token: &str, @@ -136,6 +168,57 @@ impl UiCloudflareProvider for cloudflare::Cloudflare { } } +/// The vault operations required to reuse or persist a host ACME token. +pub trait UiTokenStore { + fn get(&self, path: &str) -> Result, VaultError>; + fn ensure_unchanged(&self) -> Result<(), VaultError>; + fn save_verified(&mut self, path: &str, token: &str) -> Result<(), VaultError>; +} + +pub struct VaultUiTokenStore<'a> { + vault: &'a mut Vault, + key_file: Option<&'a Path>, +} + +impl<'a> VaultUiTokenStore<'a> { + pub fn new(vault: &'a mut Vault, key_file: Option<&'a Path>) -> Self { + Self { vault, key_file } + } +} + +impl UiTokenStore for VaultUiTokenStore<'_> { + fn get(&self, path: &str) -> Result, VaultError> { + self.vault.get(path) + } + + fn ensure_unchanged(&self) -> Result<(), VaultError> { + self.vault.ensure_unchanged() + } + + fn save_verified(&mut self, path: &str, token: &str) -> Result<(), VaultError> { + self.vault.insert(path, token)?; + self.vault.save_verified(self.key_file, path, token) + } +} + +pub trait UiTailscaleProvider { + fn find_device_by_hostname( + &self, + hostname: &str, + tag: &str, + ) -> Result; +} + +impl UiTailscaleProvider for OAuthCredentials { + fn find_device_by_hostname( + &self, + hostname: &str, + tag: &str, + ) -> Result { + tailscale::find_device_by_hostname(self, hostname, tag) + } +} + pub struct DisposableUiRequest<'a> { pub host: &'a str, pub instance: &'a str, @@ -154,6 +237,125 @@ pub struct DisposableUiCredentials { pub token_name: String, } +pub struct PersistentUiDelivery<'a> { + pub host: &'a str, + pub policy: ProvisioningPolicy, + pub zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub creator_token: &'a str, +} + +/// Resolve production or named-host UI configuration, reconcile its DNS, and +/// deliver both Caddy credentials before activating the consumer. +pub fn deliver_persistent_ui( + request: &PersistentUiDelivery<'_>, + token_store: &mut impl UiTokenStore, + cloudflare: &impl UiCloudflareProvider, + tailnet: &impl UiTailscaleProvider, + guest: &impl GuestTransport, +) -> Result { + let profile = skillet_hosts::profile_for_name(request.host) + .ok_or_else(|| UiProvisioningError::NoUiServices(request.host.to_string()))?; + if profile.ui_services().is_empty() { + return Err(UiProvisioningError::NoUiServices(request.host.to_string())); + } + cloudflare::validate_zone_id(request.zone_id)?; + let zone = cloudflare.zone(request.creator_token, request.zone_id)?; + let tag = request + .policy + .tailscale_tag(crate::provisioning_policy::DeviceClass::ProductionHost); + let device = tailnet.find_device_by_hostname(request.host, tag)?; + let plan = build_ui_provisioning_plan( + request.host, + request.policy, + &zone.name, + request.relative_ui_domain, + &device.addresses, + )?; + let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?.to_string(); + let token_path = format!( + "skillet/environments/{}/hosts/{}/cloudflare/acme-token", + request.policy.name(), + request.host + ); + let token_name = format!("skillet:{}:{}", request.policy.name(), request.host); + let token = if let Some(token) = token_store.get(&token_path)? { + token + } else { + let legacy_path = format!("skillet/hosts/{}/cloudflare/acme-token", request.host); + let legacy = if request.policy.environment() == Environment::Production { + token_store.get(&legacy_path)? + } else { + None + }; + if let Some(token) = legacy { + token_store.ensure_unchanged()?; + token_store.save_verified(&token_path, &token)?; + token + } else { + token_store.ensure_unchanged()?; + let issued = cloudflare.replace_named_zone_token( + request.creator_token, + request.zone_id, + &account_id, + &token_name, + request.policy.cloudflare_token_lifetime(), + )?; + if let Err(save_error) = token_store.save_verified(&token_path, &issued.value) { + return match cloudflare.revoke_token( + request.creator_token, + &account_id, + &issued.id, + ) { + Ok(()) => Err(UiProvisioningError::Vault(save_error)), + Err(revoke_error) => Err(UiProvisioningError::Invalid(format!( + "saving issued token failed ({save_error}); revoking token {} also failed ({revoke_error})", + issued.id + ))), + }; + } + issued.value + } + }; + cloudflare.zone(&token, request.zone_id)?; + token_store.ensure_unchanged()?; + let marker = format!("skillet:{}:{}", request.policy.name(), request.host); + cloudflare.reconcile_dns( + &token, + request.zone_id, + &marker, + &plan.sites.ui_domain, + &plan.dns_records, + )?; + token_store.ensure_unchanged()?; + let sites_payload = serde_json::to_vec(&plan.sites) + .map_err(|error| UiProvisioningError::Invalid(format!("encoding Caddy sites: {error}")))?; + skillet_vm::credential::install_set( + guest, + request.host, + "skillet-caddy-apply.service", + skillet_vm::credential::ActivationPolicy::DeferConsumer, + &[ + ("caddy_sites", &sites_payload), + ("cloudflare_acme_token", token.as_bytes()), + ], + )?; + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "systemctl", "start", "skillet-caddy-apply.service"], + }, + None, + )?; + if !output.status.success() { + return Err(UiProvisioningError::Invalid(format!( + "Caddy apply failed with status {}", + output.status + ))); + } + Ok(plan.sites) +} + pub struct DisposableUiCleanupRequest<'a> { pub host: &'a str, pub instance: &'a str, diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index 46804520..aec8e9cc 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -6,11 +6,102 @@ use crate::{ }, provisioning_state::CloudflareVmOwnership, }; +use std::os::unix::process::ExitStatusExt; use std::{ - collections::BTreeSet, + cell::{Cell, RefCell}, + collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, + process::{ExitStatus, Output}, + sync::Mutex, }; +#[derive(Default)] +struct FakeTokenStore { + entries: RefCell>, + saves: Cell, + unchanged_checks: Cell, + changed: Cell, + fail_save: Cell, +} + +impl UiTokenStore for FakeTokenStore { + fn get(&self, path: &str) -> Result, VaultError> { + Ok(self.entries.borrow().get(path).cloned()) + } + + fn ensure_unchanged(&self) -> Result<(), VaultError> { + self.unchanged_checks.set(self.unchanged_checks.get() + 1); + if self.changed.get() { + Err(VaultError::Invalid("fixture vault conflict".into())) + } else { + Ok(()) + } + } + + fn save_verified(&mut self, path: &str, token: &str) -> Result<(), VaultError> { + self.saves.set(self.saves.get() + 1); + if self.fail_save.get() { + return Err(VaultError::Invalid("fixture save failure".into())); + } + self.entries + .borrow_mut() + .insert(path.to_string(), token.to_string()); + Ok(()) + } +} + +struct FakeTailnet; + +impl UiTailscaleProvider for FakeTailnet { + fn find_device_by_hostname( + &self, + hostname: &str, + _tag: &str, + ) -> Result { + Ok(crate::tailscale::DeviceRecord { + id: "device-id".into(), + hostname: hostname.to_string(), + addresses: addresses(), + }) + } +} + +#[derive(Default)] +struct FakeGuest { + calls: Mutex>, +} + +type CapturedGuestCall = (Vec, Option>); + +impl GuestTransport for FakeGuest { + fn execute( + &self, + command: &skillet_vm::transport::GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + self.calls.lock().unwrap().push(( + std::iter::once(command.program.to_string()) + .chain( + command + .arguments + .iter() + .map(std::string::ToString::to_string), + ) + .collect(), + input.map(<[u8]>::to_vec), + )); + Ok(Output { + status: ExitStatus::from_raw(0), + stdout: Vec::new(), + stderr: Vec::new(), + }) + } + + fn upload(&self, _source: &Path, _destination: &str) -> skillet_vm::Result<()> { + Ok(()) + } +} + fn addresses() -> BTreeSet { BTreeSet::from(["100.64.0.10".to_string(), "fd7a:115c:a1e0::10".to_string()]) } @@ -21,6 +112,7 @@ struct FakeCloudflare { issued: std::cell::Cell, removed: std::cell::Cell, revoked: std::cell::RefCell>, + reconciled: std::cell::Cell, } impl UiCloudflareProvider for FakeCloudflare { @@ -60,6 +152,22 @@ impl UiCloudflareProvider for FakeCloudflare { }) } + fn replace_named_zone_token( + &self, + _creator_token: &str, + _zone_id: &str, + _account_id: &str, + _name: &str, + _lifetime: Option, + ) -> Result { + self.issued.set(self.issued.get() + 1); + Ok(IssuedToken { + id: "33333333333333333333333333333333".to_string(), + value: "persistent dummy token".to_string(), + expires_on: None, + }) + } + fn reconcile_dns( &self, _token: &str, @@ -68,9 +176,16 @@ impl UiCloudflareProvider for FakeCloudflare { _ui_domain: &str, desired: &[DesiredRecord], ) -> Result { - let ownership = read_ownership(&self.run_directory); - assert!(ownership.token_id.is_some()); - assert_eq!(ownership.marker, marker); + if provisioning_state::cloudflare_ownership_exists( + &self.run_directory.join("cloudflare.json"), + ) + .unwrap() + { + let ownership = read_ownership(&self.run_directory); + assert!(ownership.token_id.is_some()); + assert_eq!(ownership.marker, marker); + } + self.reconciled.set(self.reconciled.get() + 1); if self.fail_dns { return Err(CloudflareError::Invalid("fixture DNS failure".into())); } @@ -131,6 +246,7 @@ fn fake_cloudflare(run_directory: &Path, fail_dns: bool) -> FakeCloudflare { issued: std::cell::Cell::new(0), removed: std::cell::Cell::new(0), revoked: std::cell::RefCell::new(Vec::new()), + reconciled: std::cell::Cell::new(0), } } @@ -166,6 +282,16 @@ fn cleanup_request(run_directory: &Path) -> DisposableUiCleanupRequest<'_> { } } +fn persistent_request() -> PersistentUiDelivery<'static> { + PersistentUiDelivery { + host: "clamps", + policy: ProvisioningPolicy::new(Environment::Production), + zone_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + relative_ui_domain: Some("ui"), + creator_token: "dummy creator token", + } +} + fn create_cleanup_fixture(run_directory: &Path, provider: &FakeCloudflare) { let addresses = addresses(); provision_disposable_ui( @@ -412,3 +538,143 @@ fn disposable_cloudflare_cleanup_failure_retains_journal_for_retry() { ); assert!(run.path().join("cloudflare.json").exists()); } + +#[test] +fn persistent_ui_reuses_token_and_defers_caddy_until_credential_set_is_installed() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + store.entries.borrow_mut().insert( + "skillet/environments/production/hosts/clamps/cloudflare/acme-token".into(), + "existing ACME token".into(), + ); + let guest = FakeGuest::default(); + + let sites = deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &guest, + ) + .unwrap(); + + assert_eq!(sites.host, "clamps"); + assert_eq!(cloudflare.issued.get(), 0); + assert_eq!(cloudflare.reconciled.get(), 1); + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 3); + assert!(calls[0] + .1 + .as_ref() + .is_some_and(|bytes| { serde_json::from_slice::(bytes).is_ok() })); + assert_eq!( + calls[1].1.as_deref(), + Some(b"existing ACME token".as_slice()) + ); + assert_eq!( + calls[2].0.last().map(String::as_str), + Some("skillet-caddy-apply.service") + ); +} + +#[test] +fn persistent_ui_mints_and_persists_token_before_dns_reconciliation() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + + deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &FakeGuest::default(), + ) + .unwrap(); + + assert_eq!(cloudflare.issued.get(), 1); + assert_eq!(store.saves.get(), 1); + assert_eq!(cloudflare.reconciled.get(), 1); + assert_eq!( + store + .entries + .borrow() + .get("skillet/environments/production/hosts/clamps/cloudflare/acme-token") + .map(String::as_str), + Some("persistent dummy token") + ); +} + +#[test] +fn persistent_ui_migrates_legacy_production_token_without_reissuing_it() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + store.entries.borrow_mut().insert( + "skillet/hosts/clamps/cloudflare/acme-token".into(), + "legacy ACME token".into(), + ); + + deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &FakeGuest::default(), + ) + .unwrap(); + + assert_eq!(cloudflare.issued.get(), 0); + assert_eq!(store.saves.get(), 1); + assert_eq!( + store + .entries + .borrow() + .get("skillet/environments/production/hosts/clamps/cloudflare/acme-token") + .map(String::as_str), + Some("legacy ACME token") + ); +} + +#[test] +fn persistent_ui_vault_conflict_prevents_token_issuance() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + store.changed.set(true); + + assert!(deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &FakeGuest::default(), + ) + .is_err()); + assert_eq!(cloudflare.issued.get(), 0); + assert_eq!(cloudflare.reconciled.get(), 0); +} + +#[test] +fn persistent_ui_failed_vault_save_revokes_just_issued_token() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + store.fail_save.set(true); + + assert!(deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &FakeGuest::default(), + ) + .is_err()); + assert_eq!(cloudflare.issued.get(), 1); + assert_eq!(cloudflare.reconciled.get(), 0); + assert_eq!( + cloudflare.revoked.borrow().as_slice(), + ["33333333333333333333333333333333"] + ); +} From ed791d77d300868ec805abb0ec3c5c30d7ae22b4 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 05:07:27 +0200 Subject: [PATCH 175/252] refactor(workstation): own disposable Caddy delivery --- ublue/butane/ACCEPTANCE.md | 17 +++ ublue/design/cloudflare-ui-lifecycle.md | 6 +- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 14 +- .../skillet/crates/cli/src/secret_delivery.rs | 82 ++--------- .../crates/workstation/src/ui_provisioning.rs | 128 ++++++++++++++++++ .../workstation/src/ui_provisioning/tests.rs | 89 +++++++++++- 6 files changed, 256 insertions(+), 80 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 5d83a33f..9fb1114a 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1234,6 +1234,23 @@ host tool; it still uses normal command approvals. acceptance and Tailscale device lookup on a named production host remain unverified; `/dev/kvm` is unavailable. +### Workstation-owned disposable Caddy guest delivery, 2026-10-04 + +- Moved disposable Caddy credential-set installation, activation, per-name + non-tailnet denial probes, and superseded-token revocation into + `skillet_workstation::ui_provisioning`. Retry timing is bounded per hostname. + Old tokens are retained until guest activation and every canonical UI/alias + probe succeeds. +- Fake guest/provider tests cover deferred credential delivery, all-name probe + arguments, revocation after success, and retaining older credentials when + the denial response fails. Full workspace tests, strict all-target Clippy, + and formatting passed (58 workstation tests). +- The beezelbot Fedora base integration exited 0: both applies succeeded; the + repeat issued no service start/restart and the container was removed. Podman + needed SIGKILL after its 10-second stop timeout during cleanup. +- No VM, vault, or live provider API was used. Live Caddy ACME/HTTPS acceptance + remains pending because `/dev/kvm` is unavailable. + ### Workstation-owned disposable Cloudflare UI transaction, 2026-10-04 - Moved disposable UI token issuance and DNS reconciliation into diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index a6f6d83c..0b2987c0 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -115,8 +115,10 @@ DNS reconciliation, complete credential-set delivery, and deferred Caddy activation. Cleanup validates the recorded identity and current environment configuration before mutation, removes marker-owned DNS, revokes the disposable and temporary cleanup tokens, and removes its journal last. -Failures retain the journal for retry. Disposable guest credential delivery -and certificate acceptance still run through the CLI. Local +Failures retain the journal for retry. Disposable guest delivery now installs +both credentials before Caddy activation, checks the explicit non-tailnet +denial response for every canonical UI and alias, and only then revokes older +tokens with the same deterministic name. Local tests cover the SDK transport, permission group discovery, scoped token payloads, address-family validation, and DNS planning. Live 2026-10-02 staging acceptance issued certificates for each diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index f398b517..8c9e4df7 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -173,7 +173,13 @@ and VM delivery have duplicate implementations with different validation. reconciliation, complete Caddy credential-set delivery, and deferred activation. The CLI supplies its parsed host/environment, vault, provider credentials, and verified SSH transport. -- Pending: disposable Caddy guest delivery/activation and its retry policy, - production plus named-VM acceptance, and the remaining non-UI production - credential workflows still need coverage or extraction. Complete the - remaining sequence items before closing this workstream. +- Implemented: disposable Caddy guest delivery now installs the complete + credential set before activation, probes every canonical UI/alias for the + explicit non-tailnet denial response with a bounded retry per name, and only + then revokes superseded named tokens. The guest step is separately retryable + after DNS/token ownership has been journaled. +- Pending: production plus named-VM acceptance and the remaining non-UI + production credential workflows still need coverage or extraction. Guest + delivery and cleanup paths should remain behind the shared lifecycle + operations. Complete the remaining sequence items before closing this + workstream. diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index fa4c1b35..f4f21e9b 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -359,79 +359,15 @@ fn provision_vm_ui( &api, || vault.ensure_unchanged(), )?; - let sites = provisioned.sites; - let issued = provisioned.token; - let account_id = provisioned.account_id; - let token_name = provisioned.token_name; - let work = (|| { - let sites_payload = serde_json::to_string(&sites)?; - skillet_vm::credential::install_set( - &ssh.transport, - &args.hostname, - "skillet-caddy-apply.service", - skillet_vm::credential::ActivationPolicy::DeferConsumer, - &[ - ("caddy_sites", sites_payload.as_bytes()), - ("cloudflare_acme_token", issued.value.as_bytes()), - ], - )?; - ssh.run( - "/usr/bin/sudo", - &["-n", "systemctl", "start", "skillet-caddy-apply.service"], - )?; - verify_caddy_denies_non_tailnet_probe(ssh, &sites)?; - for old_id in api.token_ids_by_name(&creator, &account_id, &token_name)? { - if old_id != issued.id { - api.revoke_token(&creator, &account_id, &old_id)?; - } - } - Ok::<(), anyhow::Error>(()) - })(); - if let Err(error) = work { - return Err(error.context("provisioning disposable Cloudflare DNS and Caddy")); - } - Ok(()) -} - -fn verify_caddy_denies_non_tailnet_probe( - ssh: &VmSsh, - sites: &skillet_caddy::CaddySites, -) -> Result<()> { - for site in &sites.services { - for hostname in std::iter::once(&site.hostname).chain(&site.aliases) { - let resolve = format!("{hostname}:443:127.0.0.1"); - let url = format!("https://{hostname}/"); - let mut denied = false; - for _ in 0..30 { - let output = ssh.output( - "/usr/bin/curl", - &[ - "--insecure", - "--silent", - "--show-error", - "--max-time", - "8", - "--resolve", - &resolve, - "--write-out", - "\n%{http_code}", - &url, - ], - )?; - let response = String::from_utf8_lossy(&output.stdout); - if output.status.success() - && response.trim() == "Access denied by Skillet tailnet policy\n403" - { - denied = true; - break; - } - std::thread::sleep(std::time::Duration::from_secs(2)); - } - if !denied { - return Err(anyhow!("Caddy did not return its explicit access-denied response to a non-tailnet probe")); - } - } - } + skillet_workstation::ui_provisioning::deliver_disposable_ui( + &args.hostname, + &provisioned, + &creator, + &api, + &ssh.transport, + std::time::Duration::from_mins(1), + std::time::Duration::from_secs(2), + )?; Ok(()) } diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs index e7410af8..a9dff382 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -237,6 +237,134 @@ pub struct DisposableUiCredentials { pub token_name: String, } +/// Install the complete disposable Caddy input, activate it, verify the +/// non-tailnet rejection response, and then remove superseded named tokens. +pub fn deliver_disposable_ui( + host: &str, + credentials: &DisposableUiCredentials, + creator_token: &str, + provider: &impl UiCloudflareProvider, + guest: &impl GuestTransport, + acceptance_timeout: Duration, + acceptance_interval: Duration, +) -> Result<(), UiProvisioningError> { + if credentials.sites.host != host { + return Err(UiProvisioningError::Invalid( + "Caddy payload host does not match the requested guest".into(), + )); + } + let sites_payload = serde_json::to_vec(&credentials.sites) + .map_err(|error| UiProvisioningError::Invalid(format!("encoding Caddy sites: {error}")))?; + skillet_vm::credential::install_set( + guest, + host, + "skillet-caddy-apply.service", + skillet_vm::credential::ActivationPolicy::DeferConsumer, + &[ + ("caddy_sites", &sites_payload), + ("cloudflare_acme_token", credentials.token.value.as_bytes()), + ], + )?; + run_guest( + guest, + host, + &["-n", "systemctl", "start", "skillet-caddy-apply.service"], + "activating Caddy after installing its credentials", + )?; + verify_non_tailnet_denial( + guest, + &credentials.sites, + acceptance_timeout, + acceptance_interval, + )?; + for old_id in provider.token_ids_by_name( + creator_token, + &credentials.account_id, + &credentials.token_name, + )? { + if old_id != credentials.token.id { + provider.revoke_token(creator_token, &credentials.account_id, &old_id)?; + } + } + Ok(()) +} + +fn run_guest( + guest: &impl GuestTransport, + host: &str, + arguments: &[&str], + description: &str, +) -> Result<(), UiProvisioningError> { + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments, + }, + None, + )?; + if !output.status.success() { + return Err(UiProvisioningError::Invalid(format!( + "{description} failed for {host} with status {}", + output.status + ))); + } + Ok(()) +} + +fn verify_non_tailnet_denial( + guest: &impl GuestTransport, + sites: &CaddySites, + timeout: Duration, + interval: Duration, +) -> Result<(), UiProvisioningError> { + let mut verified_names = 0; + for site in &sites.services { + for hostname in std::iter::once(&site.hostname).chain(&site.aliases) { + let started = std::time::Instant::now(); + let resolve = format!("{hostname}:443:127.0.0.1"); + let url = format!("https://{hostname}/"); + loop { + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/curl", + arguments: &[ + "--insecure", + "--silent", + "--show-error", + "--max-time", + "8", + "--resolve", + &resolve, + "--write-out", + "\\n%{http_code}", + &url, + ], + }, + None, + )?; + if output.status.success() + && output.stdout == b"Access denied by Skillet tailnet policy\n403" + { + verified_names += 1; + break; + } + if started.elapsed() >= timeout { + return Err(UiProvisioningError::Invalid(format!( + "Caddy did not return its explicit tailnet-denial response for UI name {hostname}" + ))); + } + std::thread::sleep(interval); + } + } + } + if verified_names == 0 { + return Err(UiProvisioningError::Invalid( + "Caddy payload declares no hostnames to verify".into(), + )); + } + Ok(()) +} + pub struct PersistentUiDelivery<'a> { pub host: &'a str, pub policy: ProvisioningPolicy, diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index aec8e9cc..e403447d 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -69,6 +69,7 @@ impl UiTailscaleProvider for FakeTailnet { #[derive(Default)] struct FakeGuest { calls: Mutex>, + denies_curl: Cell, } type CapturedGuestCall = (Vec, Option>); @@ -90,9 +91,16 @@ impl GuestTransport for FakeGuest { .collect(), input.map(<[u8]>::to_vec), )); + let stdout = if command.program == "/usr/bin/curl" && self.denies_curl.get() { + b"Access denied by Skillet tailnet policy\n403".to_vec() + } else if command.program == "/usr/bin/curl" { + b"unexpected response".to_vec() + } else { + Vec::new() + }; Ok(Output { status: ExitStatus::from_raw(0), - stdout: Vec::new(), + stdout, stderr: Vec::new(), }) } @@ -292,6 +300,28 @@ fn persistent_request() -> PersistentUiDelivery<'static> { } } +fn disposable_credentials() -> DisposableUiCredentials { + let addresses = addresses(); + let plan = build_ui_provisioning_plan( + "clamps", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + Some("smoke.ui"), + &addresses, + ) + .unwrap(); + DisposableUiCredentials { + sites: plan.sites, + token: IssuedToken { + id: "44444444444444444444444444444444".to_string(), + value: "disposable ACME token".to_string(), + expires_on: Some("2030-01-01T00:00:00Z".to_string()), + }, + account_id: "11111111111111111111111111111111".to_string(), + token_name: "skillet:test:clamps-smoke".to_string(), + } +} + fn create_cleanup_fixture(run_directory: &Path, provider: &FakeCloudflare) { let addresses = addresses(); provision_disposable_ui( @@ -678,3 +708,60 @@ fn persistent_ui_failed_vault_save_revokes_just_issued_token() { ["33333333333333333333333333333333"] ); } + +#[test] +fn disposable_ui_delivery_installs_both_inputs_activates_probes_all_names_then_retires_old_tokens() +{ + let run = tempfile::tempdir().unwrap(); + let provider = fake_cloudflare(run.path(), false); + let credentials = disposable_credentials(); + let guest = FakeGuest::default(); + guest.denies_curl.set(true); + + deliver_disposable_ui( + "clamps", + &credentials, + "dummy creator token", + &provider, + &guest, + std::time::Duration::from_secs(1), + std::time::Duration::ZERO, + ) + .unwrap(); + + let calls = guest.calls.lock().unwrap(); + assert!(serde_json::from_slice::(calls[0].1.as_deref().unwrap()).is_ok()); + assert_eq!( + calls[1].1.as_deref(), + Some(b"disposable ACME token".as_slice()) + ); + assert!(calls.iter().any(|call| { + call.0 + .first() + .is_some_and(|program| program == "/usr/bin/curl") + && call.0.iter().any(|argument| argument == "--resolve") + })); + assert_eq!( + provider.revoked.borrow().as_slice(), + ["id-for:skillet:test:clamps-smoke"] + ); +} + +#[test] +fn disposable_ui_denial_failure_keeps_old_token_until_guest_acceptance_passes() { + let run = tempfile::tempdir().unwrap(); + let provider = fake_cloudflare(run.path(), false); + let credentials = disposable_credentials(); + + assert!(deliver_disposable_ui( + "clamps", + &credentials, + "dummy creator token", + &provider, + &FakeGuest::default(), + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ) + .is_err()); + assert!(provider.revoked.borrow().is_empty()); +} From d93ca4288d48490698c7647071553b91d64fac7d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 05:21:20 +0200 Subject: [PATCH 176/252] refactor(workstation): own host credential delivery --- ublue/butane/ACCEPTANCE.md | 19 ++ ublue/design/skillet-architecture.md | 8 +- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 15 +- ublue/plan/SKILLET-REFACTOR.md | 2 +- .../skillet/crates/cli/src/secret_delivery.rs | 162 ++---------- .../workstation/src/credential_delivery.rs | 205 +++++++++++++++ .../src/credential_delivery/tests.rs | 242 ++++++++++++++++++ ublue/skillet/crates/workstation/src/lib.rs | 1 + .../crates/workstation/src/ui_provisioning.rs | 37 +-- .../workstation/src/ui_provisioning/tests.rs | 2 +- ublue/skillet/crates/workstation/src/vault.rs | 33 +++ 11 files changed, 539 insertions(+), 187 deletions(-) create mode 100644 ublue/skillet/crates/workstation/src/credential_delivery.rs create mode 100644 ublue/skillet/crates/workstation/src/credential_delivery/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 9fb1114a..b7a761ce 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1214,6 +1214,25 @@ host tool; it still uses normal command approvals. removed. Podman needed SIGKILL after its 10-second stop timeout. - No VM or live Tailscale API was contacted. `/dev/kvm` is unavailable. +### Workstation-owned non-UI credential delivery, 2026-10-04 + +- Moved production Pi-hole password reuse/use-or-create and Tailscale auth-key + creation/delivery into `skillet_workstation::credential_delivery`. Disposable + Pi-hole secret reuse/rotation now uses the same module. The shared + `vault::SecretStore` and `VaultSecretStore` adapter also back Caddy token + provisioning. The CLI now selects its typed workflow and constructs the + verified transport. +- The workstation suite passed (66 tests), including missing/existing Pi-hole + secrets, refusal when the guest already has an untracked secret, disposable + reuse/rotation and inspection failure, and production-tagged Tailscale key + delivery. Full workspace tests, strict all-target Clippy, and formatting + passed. +- The documented Fedora Podman base integration exited 0: both applies + succeeded; the repeat issued no service start/restart and the container was + removed. Podman needed SIGKILL after its 10-second stop timeout. +- No production vault, live provider API, or named VM was used. Production + credential acceptance remains unverified; `/dev/kvm` is unavailable. + ### Workstation-owned persistent private UI provisioning, 2026-10-04 - Moved persistent Caddy provisioning into one workstation operation. It checks diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index b2200d54..93d70448 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -19,9 +19,11 @@ recording readiness. A retry first clears the persisted Ready checkpoint and restores it only after acceptance. Guest operations are guarded by ownership checks before and after each call. Production and disposable-VM credential delivery use the same verified guest transport and typed credential installer. -Transport's bounded I/O stays in memory; payload copies are zeroized after -stdin transfer. Provider and VM lifecycle orchestration still needs to move -behind the workstation library boundary. +Workstation modules now own host credential delivery, UI/DNS derivation, +provider mutations, and recovery cleanup; the CLI opens the vault, constructs +verified targets, and dispatches typed operations. Transport's bounded I/O +stays in memory; payload copies are zeroized after stdin transfer. The named +VM's overall provisioning dispatch and live acceptance remain pending. Workstream 2 adds `skillet_hosts` as the single host capability declaration. It owns host composition and supplies boot, service, network, UI, storage, and diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 8c9e4df7..6f0882d5 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -178,8 +178,13 @@ and VM delivery have duplicate implementations with different validation. explicit non-tailnet denial response with a bounded retry per name, and only then revokes superseded named tokens. The guest step is separately retryable after DNS/token ownership has been journaled. -- Pending: production plus named-VM acceptance and the remaining non-UI - production credential workflows still need coverage or extraction. Guest - delivery and cleanup paths should remain behind the shared lifecycle - operations. Complete the remaining sequence items before closing this - workstream. +- Implemented: host Pi-hole password reuse/use-or-create and production + Tailscale auth-key delivery now live in + `skillet_workstation::credential_delivery`. They share the verified guest + credential installer and vault `SecretStore` adapter with Caddy provisioning. + Missing Pi-hole values require a successful guest absence check before + generating and encrypting a replacement; disposable Pi-hole reuse and + rotation use the same workstation module. +- Pending: production and named-VM acceptance, plus review of the final CLI + lifecycle dispatch against the typed workstation operations. Live + environment checks remain subject to available hardware and credentials. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 298a76e1..ea29fc4d 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -23,7 +23,7 @@ assignments to execute concurrently against the same interfaces. | 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; live acceptance pending | -| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress; production and VM credential delivery share transport, installation, and UI/DNS planning | +| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress; workstation now owns credential and provider transactions; live acceptance pending | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | In progress | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Implemented; VM acceptance pending | | 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Implemented; VM acceptance pending | diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index f4f21e9b..0d978f95 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -7,7 +7,6 @@ use skillet_workstation::tailscale; use skillet_workstation::vault::Vault; use std::{ fs, - io::Read as _, path::Path, process::{Command, Stdio}, }; @@ -26,50 +25,27 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { let mut vault = Vault::open(&database, args.key_file.as_deref())?; match args.service.as_str() { "pihole" => { - let path = format!("skillet/hosts/{}/pihole/web-password", args.hostname); - let secret = if let Some(secret) = vault.get(&path)? { - ensure_vault_unchanged(&vault)?; - secret - } else { - if remote_credential_state(args)? != RemoteCredentialState::Absent { - return Err(anyhow!( - "host already has a Pi-hole credential; restore the missing KeePassXC entry instead of creating a replacement" - )); - } - let secret = random_password()?; - vault.insert(&path, &secret)?; - vault.save_verified(args.key_file.as_deref(), &path, &secret)?; - secret - }; let transport = credential_transport(args)?; - skillet_vm::credential::install( - &transport, + let mut store = skillet_workstation::vault::VaultSecretStore::new( + &mut vault, + args.key_file.as_deref(), + ); + skillet_workstation::credential_delivery::deliver_pihole_credential( &args.hostname, - "pihole_web_password", - "skillet-full-apply.service", - skillet_vm::credential::ActivationPolicy::StartConsumer, - secret.as_bytes(), + &mut store, + &transport, )?; Ok(()) } "tailscale" => { let credentials = tailscale_credentials(&vault)?; let policy = UiEnvironmentName::Production.policy(); - let auth_key = tailscale::create_auth_key( - &credentials, - policy.tailscale_tag( - skillet_workstation::provisioning_policy::DeviceClass::ProductionHost, - ), - &format!("Skillet {0} production host", args.hostname), - )?; let transport = credential_transport(args)?; - skillet_vm::credential::install( - &transport, + skillet_workstation::credential_delivery::deliver_tailscale_credential( &args.hostname, - "tailscale_auth_key", - "skillet-full-apply.service", - skillet_vm::credential::ActivationPolicy::StartConsumer, - auth_key.key.as_bytes(), + policy, + &credentials, + &transport, )?; Ok(()) } @@ -112,10 +88,8 @@ fn deliver_caddy_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Resu let tailnet = tailscale_credentials(vault)?; let transport = credential_transport(args)?; let cloudflare = skillet_workstation::cloudflare::Cloudflare::new(); - let mut token_store = skillet_workstation::ui_provisioning::VaultUiTokenStore::new( - vault, - args.key_file.as_deref(), - ); + let mut token_store = + skillet_workstation::vault::VaultSecretStore::new(vault, args.key_file.as_deref()); skillet_workstation::ui_provisioning::deliver_persistent_ui( &skillet_workstation::ui_provisioning::PersistentUiDelivery { host: &args.hostname, @@ -144,11 +118,6 @@ pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { Ok(()) } -fn ensure_vault_unchanged(vault: &Vault) -> Result<()> { - vault.ensure_unchanged()?; - Ok(()) -} - fn tailscale_credentials(vault: &Vault) -> Result { let client_id = vault .get("skillet/tailscale/provisioner-client-id")? @@ -182,36 +151,6 @@ fn credential_transport(args: &SecretDeliverArgs) -> Result Result { - let transport = credential_transport(args)?; - let program = format!("/var/usrlocal/bin/skillet-{}", args.hostname); - let output = skillet_vm::transport::GuestTransport::execute( - &transport, - &skillet_vm::transport::GuestCommand { - program: "/usr/bin/sudo", - arguments: &["-n", &program, "credential", "state", "pihole_web_password"], - }, - None, - ) - .context("checking host state before generating a production credential")?; - if !output.status.success() { - return Err(anyhow!( - "SSH host credential state check failed; no production credential was generated" - )); - } - match output.stdout.as_slice() { - b"present\n" => Ok(RemoteCredentialState::Present), - b"absent\n" => Ok(RemoteCredentialState::Absent), - _ => Err(anyhow!("host returned an unrecognized credential state")), - } -} - fn default_database_path() -> Result { Ok(Vault::default_path()?) } @@ -240,7 +179,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let identity = run_dir.join("ssh/id_ed25519"); let known_hosts = run_dir.join("ssh/known_hosts"); let port = read_vm_port(&run_dir.join("run.conf"))?; - let ssh = VmSsh::new(&args.hostname, &identity, &known_hosts, port)?; + let ssh = VmSsh::new(&identity, &known_hosts, port)?; validate_vm_tailscale_delivery(&ssh)?; let vault_path = match &args.database { Some(path) => path.clone(), @@ -262,17 +201,11 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { &ssh.transport, )?; - let pihole_credential = "/etc/credstore.encrypted/skillet/pihole_web_password.cred"; - let has_pihole_credential = vm_credential_present(&ssh, pihole_credential)?; - if args.rotate || !has_pihole_credential { - let secret = random_password()?; - ssh.install("pihole_web_password", &secret)?; - } else { - ssh.run( - "/usr/bin/sudo", - &["-n", "systemctl", "start", "skillet-full-apply.service"], - )?; - } + skillet_workstation::credential_delivery::ensure_disposable_pihole_credential( + &args.hostname, + args.rotate, + &ssh.transport, + )?; if args.with_ui { provision_vm_ui(args, &run_dir, &ssh, &mut vault, &record)?; @@ -409,14 +342,12 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( } struct VmSsh { - host: String, transport: skillet_vm::transport::SshTransport, } impl VmSsh { - fn new(host: &str, identity: &Path, known_hosts: &Path, port: u16) -> Result { + fn new(identity: &Path, known_hosts: &Path, port: u16) -> Result { Ok(Self { - host: host.to_string(), transport: skillet_vm::transport::SshTransport::new( skillet_vm::SshTarget { user: "giacomo".to_string(), @@ -439,53 +370,6 @@ impl VmSsh { ) .context("running command over recorded VM SSH transport") } - - fn run(&self, program: &str, arguments: &[&str]) -> Result<()> { - let output = self.output(program, arguments)?; - if !output.status.success() { - return Err(anyhow!( - "VM command {program} failed with status {}", - output.status - )); - } - Ok(()) - } - - fn install(&self, name: &str, secret: &str) -> Result<()> { - self.install_for_unit( - name, - "skillet-full-apply.service", - secret, - skillet_vm::credential::ActivationPolicy::StartConsumer, - ) - } - - fn install_for_unit( - &self, - name: &str, - unit: &str, - secret: &str, - activation: skillet_vm::credential::ActivationPolicy, - ) -> Result<()> { - skillet_vm::credential::install( - &self.transport, - &self.host, - name, - unit, - activation, - secret.as_bytes(), - )?; - Ok(()) - } -} - -fn vm_credential_present(ssh: &VmSsh, path: &str) -> Result { - let output = ssh.output("/usr/bin/sudo", &["-n", "test", "-s", path])?; - match output.status.code() { - Some(0) => Ok(true), - Some(1) => Ok(false), - _ => Err(anyhow!("could not inspect VM credential state")), - } } fn read_vm_port(manifest: &Path) -> Result { @@ -528,12 +412,6 @@ fn validate_tailscale_unit_config(contents: &str) -> Result<()> { Ok(()) } -fn random_password() -> Result { - let mut bytes = [0_u8; 32]; - fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; - Ok(hex::encode(bytes)) -} - #[cfg(test)] #[path = "secret_delivery/tests.rs"] mod tests; diff --git a/ublue/skillet/crates/workstation/src/credential_delivery.rs b/ublue/skillet/crates/workstation/src/credential_delivery.rs new file mode 100644 index 00000000..16eea7cb --- /dev/null +++ b/ublue/skillet/crates/workstation/src/credential_delivery.rs @@ -0,0 +1,205 @@ +//! Workstation-side persistence and delivery of host credentials. + +use crate::{ + provisioning_policy::{DeviceClass, Environment, ProvisioningPolicy}, + tailscale::{self, AuthKey, OAuthCredentials, TailscaleError}, + vault::{SecretStore, VaultError}, +}; +use skillet_vm::{credential, transport::GuestTransport}; +use std::{fs::File, io::Read}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CredentialDeliveryError { + #[error("invalid host credential delivery: {0}")] + Invalid(String), + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Tailscale(#[from] TailscaleError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), + #[error("operating system randomness is unavailable: {0}")] + Randomness(#[from] std::io::Error), +} + +pub trait ProductionAuthKeyProvider { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result; +} + +impl ProductionAuthKeyProvider for OAuthCredentials { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result { + tailscale::create_auth_key(self, tag, description) + } +} + +pub fn deliver_pihole_credential( + host: &str, + store: &mut impl SecretStore, + guest: &impl GuestTransport, +) -> Result<(), CredentialDeliveryError> { + let profile = skillet_hosts::profile_for_name(host) + .ok_or_else(|| CredentialDeliveryError::Invalid(format!("unknown host profile {host}")))?; + if !profile.supports_service("pihole") { + return Err(CredentialDeliveryError::Invalid(format!( + "host {host} does not declare Pi-hole credential delivery" + ))); + } + let path = format!("skillet/hosts/{host}/pihole/web-password"); + let password = if let Some(password) = store.get(&path)? { + store.ensure_unchanged()?; + password + } else { + if remote_credential_present(host, guest)? { + return Err(CredentialDeliveryError::Invalid( + "host already has a Pi-hole credential; restore the missing KeePassXC entry instead of creating a replacement".into(), + )); + } + store.ensure_unchanged()?; + let password = random_password()?; + store.save_verified(&path, &password)?; + password + }; + credential::install( + guest, + host, + "pihole_web_password", + "skillet-full-apply.service", + credential::ActivationPolicy::StartConsumer, + password.as_bytes(), + )?; + Ok(()) +} + +pub fn deliver_tailscale_credential( + host: &str, + policy: ProvisioningPolicy, + provider: &impl ProductionAuthKeyProvider, + guest: &impl GuestTransport, +) -> Result<(), CredentialDeliveryError> { + if policy.environment() != Environment::Production { + return Err(CredentialDeliveryError::Invalid( + "production Tailscale credential delivery requires production policy".into(), + )); + } + let profile = skillet_hosts::profile_for_name(host) + .ok_or_else(|| CredentialDeliveryError::Invalid(format!("unknown host profile {host}")))?; + if !profile.supports_service("tailscale") { + return Err(CredentialDeliveryError::Invalid(format!( + "host {host} does not declare Tailscale credential delivery" + ))); + } + let tag = policy.tailscale_tag(DeviceClass::ProductionHost); + let auth_key = provider.create_auth_key(tag, &format!("Skillet {host} production host"))?; + credential::install( + guest, + host, + "tailscale_auth_key", + "skillet-full-apply.service", + credential::ActivationPolicy::StartConsumer, + auth_key.key.as_bytes(), + )?; + Ok(()) +} + +pub fn ensure_disposable_pihole_credential( + host: &str, + rotate: bool, + guest: &impl GuestTransport, +) -> Result<(), CredentialDeliveryError> { + let profile = skillet_hosts::profile_for_name(host) + .ok_or_else(|| CredentialDeliveryError::Invalid(format!("unknown host profile {host}")))?; + if !profile.supports_service("pihole") { + return Err(CredentialDeliveryError::Invalid(format!( + "host {host} does not declare Pi-hole" + ))); + } + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &[ + "-n", + "test", + "-s", + "/etc/credstore.encrypted/skillet/pihole_web_password.cred", + ], + }, + None, + )?; + let present = match output.status.code() { + Some(0) => true, + Some(1) => false, + _ => { + return Err(CredentialDeliveryError::Invalid( + "could not inspect disposable VM credential state".into(), + )); + } + }; + if rotate || !present { + credential::install( + guest, + host, + "pihole_web_password", + "skillet-full-apply.service", + credential::ActivationPolicy::StartConsumer, + random_password()?.as_bytes(), + )?; + } else { + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "systemctl", "start", "skillet-full-apply.service"], + }, + None, + )?; + if !output.status.success() { + return Err(CredentialDeliveryError::Invalid(format!( + "starting host apply failed with status {}", + output.status + ))); + } + } + Ok(()) +} + +fn remote_credential_present( + host: &str, + guest: &impl GuestTransport, +) -> Result { + let program = format!("/var/usrlocal/bin/skillet-{host}"); + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &[ + "-n", + program.as_str(), + "credential", + "state", + "pihole_web_password", + ], + }, + None, + )?; + if !output.status.success() { + return Err(CredentialDeliveryError::Invalid( + "host credential state check failed; no production credential was generated".into(), + )); + } + match output.stdout.as_slice() { + b"present\n" => Ok(true), + b"absent\n" => Ok(false), + _ => Err(CredentialDeliveryError::Invalid( + "host returned an unrecognized credential state".into(), + )), + } +} + +fn random_password() -> Result { + let mut bytes = [0_u8; 32]; + File::open("/dev/urandom")?.read_exact(&mut bytes)?; + Ok(hex::encode(bytes)) +} + +#[cfg(test)] +#[path = "credential_delivery/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs new file mode 100644 index 00000000..6ed67be2 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs @@ -0,0 +1,242 @@ +use super::*; +use crate::{ + provisioning_policy::Environment, + vault::{SecretStore, VaultError}, +}; +use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + os::unix::process::ExitStatusExt, + process::{ExitStatus, Output}, + sync::Mutex, +}; + +#[derive(Default)] +struct FakeStore { + entries: RefCell>, + saves: Cell, + changed: Cell, +} + +impl SecretStore for FakeStore { + fn get(&self, path: &str) -> Result, VaultError> { + Ok(self.entries.borrow().get(path).cloned()) + } + + fn ensure_unchanged(&self) -> Result<(), VaultError> { + if self.changed.get() { + Err(VaultError::Invalid("fixture vault changed".into())) + } else { + Ok(()) + } + } + + fn save_verified(&mut self, path: &str, secret: &str) -> Result<(), VaultError> { + self.saves.set(self.saves.get() + 1); + self.entries + .borrow_mut() + .insert(path.to_string(), secret.to_string()); + Ok(()) + } +} + +#[derive(Default)] +struct FakeGuest { + state: Vec, + state_exit_code: i32, + calls: Mutex>, +} + +type CapturedGuestCall = (Vec, Option>); + +impl GuestTransport for FakeGuest { + fn execute( + &self, + command: &skillet_vm::transport::GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + self.calls.lock().unwrap().push(( + std::iter::once(command.program.to_string()) + .chain( + command + .arguments + .iter() + .map(std::string::ToString::to_string), + ) + .collect(), + input.map(<[u8]>::to_vec), + )); + let status = if command.arguments.contains(&"test") { + ExitStatus::from_raw(self.state_exit_code << 8) + } else { + ExitStatus::from_raw(0) + }; + Ok(Output { + status, + stdout: if command.arguments.contains(&"state") { + self.state.clone() + } else { + Vec::new() + }, + stderr: Vec::new(), + }) + } + + fn upload(&self, _source: &std::path::Path, _destination: &str) -> skillet_vm::Result<()> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeTailscale { + calls: Cell, + tag: RefCell, + description: RefCell, +} + +impl ProductionAuthKeyProvider for FakeTailscale { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result { + self.calls.set(self.calls.get() + 1); + *self.tag.borrow_mut() = tag.to_string(); + *self.description.borrow_mut() = description.to_string(); + Ok(AuthKey { + key: "dummy auth key".into(), + }) + } +} + +#[test] +fn missing_pihole_password_checks_remote_state_then_persists_and_installs() { + let mut store = FakeStore::default(); + let guest = FakeGuest { + state: b"absent\n".to_vec(), + ..FakeGuest::default() + }; + + deliver_pihole_credential("clamps", &mut store, &guest).unwrap(); + + assert_eq!(store.saves.get(), 1); + let password = store + .entries + .borrow() + .get("skillet/hosts/clamps/pihole/web-password") + .cloned() + .unwrap(); + assert_eq!(password.len(), 64); + assert!(password.bytes().all(|byte| byte.is_ascii_hexdigit())); + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls[0].0.iter().any(|argument| argument == "state")); + assert_eq!(calls[1].1.as_deref(), Some(password.as_bytes())); +} + +#[test] +fn existing_pihole_password_is_reused_without_remote_state_probe() { + let mut store = FakeStore::default(); + store.entries.borrow_mut().insert( + "skillet/hosts/clamps/pihole/web-password".into(), + "existing password".into(), + ); + let guest = FakeGuest::default(); + + deliver_pihole_credential("clamps", &mut store, &guest).unwrap(); + + assert_eq!(store.saves.get(), 0); + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1.as_deref(), Some(b"existing password".as_slice())); +} + +#[test] +fn existing_remote_pihole_secret_blocks_replacement_and_store_mutation() { + let mut store = FakeStore::default(); + let guest = FakeGuest { + state: b"present\n".to_vec(), + ..FakeGuest::default() + }; + + assert!(deliver_pihole_credential("clamps", &mut store, &guest).is_err()); + assert_eq!(store.saves.get(), 0); + assert_eq!(guest.calls.lock().unwrap().len(), 1); +} + +#[test] +fn tailscale_production_key_uses_profile_tag_and_shared_credential_installer() { + let provider = FakeTailscale::default(); + let guest = FakeGuest::default(); + + deliver_tailscale_credential( + "clamps", + ProvisioningPolicy::new(Environment::Production), + &provider, + &guest, + ) + .unwrap(); + + assert_eq!(provider.calls.get(), 1); + assert_eq!(&*provider.tag.borrow(), tailscale::SERVER_TAG); + assert_eq!( + &*provider.description.borrow(), + "Skillet clamps production host" + ); + assert_eq!( + guest.calls.lock().unwrap()[0].1.as_deref(), + Some(b"dummy auth key".as_slice()) + ); +} + +#[test] +fn invalid_host_or_nonproduction_tailscale_policy_fails_before_effects() { + let provider = FakeTailscale::default(); + let guest = FakeGuest::default(); + assert!(deliver_tailscale_credential( + "clamps", + ProvisioningPolicy::new(Environment::Test), + &provider, + &guest, + ) + .is_err()); + assert!(deliver_pihole_credential("agent", &mut FakeStore::default(), &guest).is_err()); + assert_eq!(provider.calls.get(), 0); + assert!(guest.calls.lock().unwrap().is_empty()); +} + +#[test] +fn disposable_pihole_credential_is_created_when_missing_and_rotation_is_requested() { + let guest = FakeGuest { + state_exit_code: 1, + ..FakeGuest::default() + }; + + ensure_disposable_pihole_credential("clamps", false, &guest).unwrap(); + + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls[0].0.iter().any(|argument| argument == "test")); + assert_eq!(calls[1].1.as_ref().map(Vec::len), Some(64)); +} + +#[test] +fn disposable_pihole_reapply_reuses_secret_and_rotation_replaces_it() { + let guest = FakeGuest::default(); + + ensure_disposable_pihole_credential("clamps", false, &guest).unwrap(); + + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls[1] + .0 + .windows(3) + .any(|arguments| arguments == ["systemctl", "start", "skillet-full-apply.service"])); +} + +#[test] +fn disposable_pihole_inspection_errors_do_not_generate_a_replacement() { + let guest = FakeGuest { + state_exit_code: 2, + ..FakeGuest::default() + }; + + assert!(ensure_disposable_pihole_credential("clamps", false, &guest).is_err()); + assert_eq!(guest.calls.lock().unwrap().len(), 1); +} diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index c1896206..8fce4561 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -1,6 +1,7 @@ //! Workstation-only provisioning capabilities. pub mod cloudflare; +pub mod credential_delivery; pub mod provisioning_policy; pub mod provisioning_state; pub mod tailscale; diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs index a9dff382..e1a7a9b7 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -5,7 +5,7 @@ use crate::{ provisioning_policy::{Environment, ProvisioningPolicy}, provisioning_state::{self, CloudflareVmOwnership, ProvisioningIdentity}, tailscale::{self, OAuthCredentials, TailscaleError}, - vault::{Vault, VaultError}, + vault::{SecretStore, VaultError}, }; use skillet_caddy::{CaddyError, CaddySites, UiEnvironment}; use skillet_vm::transport::GuestTransport; @@ -168,39 +168,6 @@ impl UiCloudflareProvider for cloudflare::Cloudflare { } } -/// The vault operations required to reuse or persist a host ACME token. -pub trait UiTokenStore { - fn get(&self, path: &str) -> Result, VaultError>; - fn ensure_unchanged(&self) -> Result<(), VaultError>; - fn save_verified(&mut self, path: &str, token: &str) -> Result<(), VaultError>; -} - -pub struct VaultUiTokenStore<'a> { - vault: &'a mut Vault, - key_file: Option<&'a Path>, -} - -impl<'a> VaultUiTokenStore<'a> { - pub fn new(vault: &'a mut Vault, key_file: Option<&'a Path>) -> Self { - Self { vault, key_file } - } -} - -impl UiTokenStore for VaultUiTokenStore<'_> { - fn get(&self, path: &str) -> Result, VaultError> { - self.vault.get(path) - } - - fn ensure_unchanged(&self) -> Result<(), VaultError> { - self.vault.ensure_unchanged() - } - - fn save_verified(&mut self, path: &str, token: &str) -> Result<(), VaultError> { - self.vault.insert(path, token)?; - self.vault.save_verified(self.key_file, path, token) - } -} - pub trait UiTailscaleProvider { fn find_device_by_hostname( &self, @@ -377,7 +344,7 @@ pub struct PersistentUiDelivery<'a> { /// deliver both Caddy credentials before activating the consumer. pub fn deliver_persistent_ui( request: &PersistentUiDelivery<'_>, - token_store: &mut impl UiTokenStore, + token_store: &mut impl SecretStore, cloudflare: &impl UiCloudflareProvider, tailnet: &impl UiTailscaleProvider, guest: &impl GuestTransport, diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index e403447d..a3021071 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -24,7 +24,7 @@ struct FakeTokenStore { fail_save: Cell, } -impl UiTokenStore for FakeTokenStore { +impl crate::vault::SecretStore for FakeTokenStore { fn get(&self, path: &str) -> Result, VaultError> { Ok(self.entries.borrow().get(path).cloned()) } diff --git a/ublue/skillet/crates/workstation/src/vault.rs b/ublue/skillet/crates/workstation/src/vault.rs index 8c33107e..e8d4d492 100644 --- a/ublue/skillet/crates/workstation/src/vault.rs +++ b/ublue/skillet/crates/workstation/src/vault.rs @@ -206,6 +206,39 @@ impl Vault { } } +/// Minimal exact-entry persistence boundary shared by workstation provisioners. +pub trait SecretStore { + fn get(&self, path: &str) -> Result, VaultError>; + fn ensure_unchanged(&self) -> Result<(), VaultError>; + fn save_verified(&mut self, path: &str, secret: &str) -> Result<(), VaultError>; +} + +pub struct VaultSecretStore<'a> { + vault: &'a mut Vault, + key_file: Option<&'a Path>, +} + +impl<'a> VaultSecretStore<'a> { + pub fn new(vault: &'a mut Vault, key_file: Option<&'a Path>) -> Self { + Self { vault, key_file } + } +} + +impl SecretStore for VaultSecretStore<'_> { + fn get(&self, path: &str) -> Result, VaultError> { + self.vault.get(path) + } + + fn ensure_unchanged(&self) -> Result<(), VaultError> { + self.vault.ensure_unchanged() + } + + fn save_verified(&mut self, path: &str, secret: &str) -> Result<(), VaultError> { + self.vault.insert(path, secret)?; + self.vault.save_verified(self.key_file, path, secret) + } +} + pub fn lock(path: Option<&Path>) -> Result<(), VaultError> { let path = match path { Some(path) => path.to_path_buf(), From c516a4c9dcc521f849a1759d7deccdd2fcdd77b2 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 05:29:44 +0200 Subject: [PATCH 177/252] docs(skillet): record remaining ownership boundary gap --- ublue/butane/ACCEPTANCE.md | 14 ++++++++++++++ ublue/design/skillet-architecture.md | 6 ++++-- ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 7 +++++++ ublue/plan/SKILLET-REFACTOR.md | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index b7a761ce..5404c1c7 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1214,6 +1214,20 @@ host tool; it still uses normal command approvals. removed. Podman needed SIGKILL after its 10-second stop timeout. - No VM or live Tailscale API was contacted. `/dev/kvm` is unavailable. +### Workstream 3 ownership API audit, 2026-10-04 + +- Static search found no `/etc/subuid` or `/etc/subgid` reads outside the + Podman adapter and no recipe reads of `CREDENTIALS_DIRECTORY`; account/NSS + lookups remain in the system adapter. File, systemd, and Btrfs observations + remain inside their declared core adapters. +- The ownership contract still has a concrete gap: file mutation accepts + separate name-only owner/group arguments while directory mutation accepts + the shared named-or-numeric `Ownership` type. A proposed broad core API + migration was rejected by automatic review because it affects filesystem + behavior across recipes. No source changes were made for that rejected + migration. `/dev/kvm` is absent, so privileged ownership and mount checks + remain unavailable. + ### Workstation-owned non-UI credential delivery, 2026-10-04 - Moved production Pi-hole password reuse/use-or-create and Tailscale auth-key diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 93d70448..c6169e63 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -38,8 +38,10 @@ Workstream 3 is in progress. Account observations and subordinate-ID files enter composition through injected resource interfaces; missing or invalid subordinate ranges fail closed. Directory ownership uses one typed named or numeric identity contract, and entry points pass required systemd credentials -to host composition. Remaining broad file/system traits and fake contracts -are still scheduled for this workstream. +to host composition. File ownership is not fully unified yet: file mutations +still accept separate name-only owner/group strings, while directory +mutations accept the typed named-or-numeric ownership value. Host composition +also retains aggregate effect interfaces during migration. Keep application crates as reusable recipes. Separate canonical host profiles and composition from guest runtime adapters, CLI parsing, workstation diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md index 8c6eb8b8..3ee9e8e5 100644 --- a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -73,6 +73,13 @@ ownership use separate APIs; mocks do not enforce important metadata contracts. numeric UID/GID. Numeric IDs do not require matching account names. The fake preserves mode and ownership across existence-only checks; the UniFi repeat-apply case verifies child data survives. +- Pending local slice: the 2026-10-04 static audit found that `ensure_file` + still accepts separate name-only owner/group strings while directory + ownership accepts the shared named-or-numeric `Ownership` value. The + ownership-interface exit criterion is therefore not met. A proposed + cross-crate API migration was rejected by automatic review as a broad core + filesystem change; do not claim this item complete until an authorized, + reviewable migration is implemented and validated across every caller. - Implemented: the guest CLI loads phase-required systemd credentials and passes `CredentialInputs` to host composition. Recipes no longer inspect `CREDENTIALS_DIRECTORY`; required names derive from profile consumers. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index ea29fc4d..ddbd48ea 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -22,7 +22,7 @@ assignments to execute concurrently against the same interfaces. | --- | --- | --- | --- | | 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | -| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; live acceptance pending | +| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; file and directory ownership APIs still differ; VM checks pending | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress; workstation now owns credential and provider transactions; live acceptance pending | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | In progress | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Implemented; VM acceptance pending | From 9f1a79d4a89c7e067fbd67b087f7e134f9b8077d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 05:58:40 +0200 Subject: [PATCH 178/252] refactor(vm): use owned manifests for provisioning --- ublue/skillet/crates/cli/src/main.rs | 4 - .../skillet/crates/cli/src/secret_delivery.rs | 139 ++++++++---------- .../crates/cli/src/secret_delivery/tests.rs | 12 +- ublue/skillet/crates/cli/src/vm.rs | 4 +- ublue/skillet/crates/vm/src/readiness.rs | 37 +---- ublue/skillet/crates/vm/src/transport.rs | 33 +++++ .../skillet/crates/vm/src/transport/tests.rs | 74 +++++++++- 7 files changed, 171 insertions(+), 132 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 8964acc8..fa9a008a 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -497,10 +497,6 @@ fn run_vm_list(args: &VmListArgs) -> Result<()> { vm::list(args) } -fn vm_name(hostname: &str, instance: &str) -> Result { - Ok(skillet_vm::RunIdentity::new(hostname, instance)?.domain_name()) -} - fn butane_root() -> Result { let root = workspace_root()?; let butane = root diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 0d978f95..a9c9fd7e 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -1,15 +1,16 @@ -use super::{ - butane_root, vm_name, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, VmProvisionArgs, -}; +use super::{butane_root, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, VmProvisionArgs}; use anyhow::{anyhow, Context, Result}; +use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + transport::{ + GuestCommand, GuestTransport, HostKeyPolicy, OwnershipCheckedTransport, SshTransport, + }, + Environment, ManifestStore, Phase, RunIdentity, VmRun, +}; use skillet_workstation::provisioning_state; use skillet_workstation::tailscale; use skillet_workstation::vault::Vault; -use std::{ - fs, - path::Path, - process::{Command, Stdio}, -}; +use std::path::Path; pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { validate_delivery_service(&args.hostname, &args.service)?; @@ -164,30 +165,28 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { args.hostname )); } - let name = vm_name(&args.hostname, &args.instance)?; let butane = butane_root()?; - let helper = butane.join("bin/test-vm"); - let status = Command::new(&helper) - .args([&args.hostname, "status", &args.instance]) - .stdout(Stdio::null()) - .status() - .context("validating owned VM")?; - if !status.success() { - return Err(anyhow!("VM ownership or status validation failed")); + let run_identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _lock = store.lock(&run_identity)?; + let run = store.load(&run_identity)?; + if run.environment != Environment::Test || !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(anyhow!("VM must be a started test run before provisioning")); } - let run_dir = butane.join("runs").join(&name); - let identity = run_dir.join("ssh/id_ed25519"); - let known_hosts = run_dir.join("ssh/known_hosts"); - let port = read_vm_port(&run_dir.join("run.conf"))?; - let ssh = VmSsh::new(&identity, &known_hosts, port)?; - validate_vm_tailscale_delivery(&ssh)?; + let run_dir = store.run_dir(&run_identity); + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let base_transport = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + let verify_ownership = || verify_running_vm(&backend, &run); + verify_ownership()?; + let transport = OwnershipCheckedTransport::new(&base_transport, &verify_ownership); + validate_vm_tailscale_delivery(&transport)?; let vault_path = match &args.database { Some(path) => path.clone(), None => default_database_path()?, }; let mut vault = Vault::open(&vault_path, args.key_file.as_deref())?; let credentials = tailscale_credentials(&vault)?; - let expected_hostname = name.as_str(); + let expected_hostname = run.guest_hostname.as_str(); let policy = UiEnvironmentName::Test.policy(); let record = skillet_workstation::tailscale_enrollment::enroll_disposable_vm( &skillet_workstation::tailscale_enrollment::DisposableEnrollment { @@ -198,27 +197,31 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { policy, }, &credentials, - &ssh.transport, + &transport, )?; skillet_workstation::credential_delivery::ensure_disposable_pihole_credential( &args.hostname, args.rotate, - &ssh.transport, + &transport, )?; if args.with_ui { - provision_vm_ui(args, &run_dir, &ssh, &mut vault, &record)?; + provision_vm_ui(args, &run_dir, &transport, &mut vault, &record)?; } provisioning_state::remove_tailscale_pending(&run_dir)?; println!("Tailscale connected VM {expected_hostname}"); Ok(()) } -pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { - let name = vm_name(&args.hostname, &args.instance)?; +pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs, run: &VmRun) -> Result<()> { + if run.identity.host() != args.hostname || run.identity.instance() != args.instance { + return Err(anyhow!( + "destroy arguments do not match the recorded VM identity" + )); + } let butane = butane_root()?; - let run_dir = butane.join("runs").join(&name); + let run_dir = butane.join("runs").join(run.identity.domain_name()); let cloudflare_path = run_dir.join("cloudflare.json"); let cloudflare_exists = provisioning_state::cloudflare_ownership_exists(&cloudflare_path)?; let tailscale_pending = provisioning_state::tailscale_pending_exists(&run_dir)?; @@ -245,20 +248,23 @@ pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs) -> Result<()> { &skillet_workstation::tailscale_enrollment::DisposableCleanup { host: &args.hostname, instance: &args.instance, - vm_hostname: &name, + vm_hostname: &run.identity.domain_name(), run_directory: &run_dir, policy, }, &credentials, )?; - println!("Cleaned Tailscale ownership for {name}"); + println!( + "Cleaned Tailscale ownership for {}", + run.identity.domain_name() + ); Ok(()) } fn provision_vm_ui( args: &VmProvisionArgs, run_dir: &Path, - ssh: &VmSsh, + guest: &impl GuestTransport, vault: &mut Vault, device: &tailscale::DeviceRecord, ) -> Result<()> { @@ -297,7 +303,7 @@ fn provision_vm_ui( &provisioned, &creator, &api, - &ssh.transport, + guest, std::time::Duration::from_mins(1), std::time::Duration::from_secs(2), )?; @@ -341,55 +347,28 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( Ok(()) } -struct VmSsh { - transport: skillet_vm::transport::SshTransport, -} - -impl VmSsh { - fn new(identity: &Path, known_hosts: &Path, port: u16) -> Result { - Ok(Self { - transport: skillet_vm::transport::SshTransport::new( - skillet_vm::SshTarget { - user: "giacomo".to_string(), - address: "127.0.0.1".to_string(), - port, - identity: identity.to_path_buf(), - known_hosts: known_hosts.to_path_buf(), - }, - skillet_vm::transport::HostKeyPolicy::Verify, - )?, - }) - } - - fn output(&self, program: &str, arguments: &[&str]) -> Result { - use skillet_vm::transport::GuestTransport as _; - self.transport - .execute( - &skillet_vm::transport::GuestCommand { program, arguments }, - None, - ) - .context("running command over recorded VM SSH transport") +fn verify_running_vm(backend: &impl VmBackend, run: &VmRun) -> skillet_vm::Result<()> { + let domain = backend + .inspect(run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned VM domain is absent".into()))?; + domain.validate_owned(run)?; + if domain.state != "running" { + return Err(skillet_vm::Error::Invalid(format!( + "VM is not running (libvirt state: {})", + domain.state + ))); } + Ok(()) } -fn read_vm_port(manifest: &Path) -> Result { - let contents = fs::read_to_string(manifest)?; - let value = contents - .lines() - .find_map(|line| line.strip_prefix("ssh_port=")) - .ok_or_else(|| anyhow!("VM manifest has no SSH port"))?; - let port: u16 = value.parse().context("invalid VM SSH port")?; - if !(2200..=2299).contains(&port) { - return Err(anyhow!("VM SSH port is outside the test range")); - } - Ok(port) -} - -fn validate_vm_tailscale_delivery(ssh: &VmSsh) -> Result<()> { - let output = ssh - .output( - "/usr/bin/sudo", - &["-n", "systemctl", "cat", "skillet-full-apply.service"], +fn validate_vm_tailscale_delivery(guest: &impl GuestTransport) -> Result<()> { + let output = guest + .execute( + &GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "systemctl", "cat", "skillet-full-apply.service"], + }, + None, ) .context("checking the VM's full-apply credential configuration")?; if !output.status.success() { diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 591295aa..1787630f 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,14 +1,4 @@ -use super::{read_vm_port, validate_delivery_service, validate_tailscale_unit_config}; - -#[test] -fn vm_port_requires_manifest_range() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("run.conf"); - std::fs::write(&file, "ssh_port=2201\n").unwrap(); - assert_eq!(read_vm_port(&file).unwrap(), 2201); - std::fs::write(&file, "ssh_port=22\n").unwrap(); - assert!(read_vm_port(&file).is_err()); -} +use super::{validate_delivery_service, validate_tailscale_unit_config}; #[test] fn rejects_vm_without_tailscale_systemd_credential() { diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 0fc3fe22..7d903cfb 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -366,8 +366,8 @@ pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { let store = ManifestStore::new(&butane.join("runs"), current_uid())?; let run = store.load(&identity)?; let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; - skillet_vm::lifecycle::destroy(&store, &identity, &backend, |_| { - secret_delivery::remove_vm_external_resources(args) + skillet_vm::lifecycle::destroy(&store, &identity, &backend, |run| { + secret_delivery::remove_vm_external_resources(args, run) .map_err(|error| skillet_vm::Error::ExternalCleanup(error.to_string())) })?; println!("Disposed {}", identity.domain_name()); diff --git a/ublue/skillet/crates/vm/src/readiness.rs b/ublue/skillet/crates/vm/src/readiness.rs index eb851631..c67c312f 100644 --- a/ublue/skillet/crates/vm/src/readiness.rs +++ b/ublue/skillet/crates/vm/src/readiness.rs @@ -2,7 +2,7 @@ use crate::{ delivery::{checked, deliver, sha256}, manifest::reject_symlinks, - transport::{GuestCommand, GuestTransport}, + transport::{GuestCommand, GuestTransport, OwnershipCheckedTransport}, Error, ManifestStore, Phase, Result, VmRun, }; use std::{ @@ -48,31 +48,6 @@ pub struct ReadinessIo<'a, T> { pub ownership: &'a dyn Fn() -> Result<()>, } -struct OwnedTransport<'a, T> { - transport: &'a T, - ownership: &'a dyn Fn() -> Result<()>, -} - -impl GuestTransport for OwnedTransport<'_, T> { - fn execute( - &self, - command: &GuestCommand<'_>, - input: Option<&[u8]>, - ) -> Result { - (self.ownership)()?; - let result = self.transport.execute(command, input); - (self.ownership)()?; - result - } - - fn upload(&self, source: &Path, destination: &str) -> Result<()> { - (self.ownership)()?; - let result = self.transport.upload(source, destination); - (self.ownership)()?; - result - } -} - pub fn ready( run: &mut VmRun, store: &ManifestStore, @@ -105,14 +80,8 @@ pub fn ready( // cannot leave a Ready manifest behind. run.phase = Phase::Started; store.save(run)?; - let probe = OwnedTransport { - transport: io.probe, - ownership: io.ownership, - }; - let operations = OwnedTransport { - transport: io.operations, - ownership: io.ownership, - }; + let probe = OwnershipCheckedTransport::new(io.probe, io.ownership); + let operations = OwnershipCheckedTransport::new(io.operations, io.ownership); let result = (|| { wait(run, policy, &probe, io.ownership, clock, report, false)?; (io.ownership)()?; diff --git a/ublue/skillet/crates/vm/src/transport.rs b/ublue/skillet/crates/vm/src/transport.rs index 919ad554..65034414 100644 --- a/ublue/skillet/crates/vm/src/transport.rs +++ b/ublue/skillet/crates/vm/src/transport.rs @@ -16,6 +16,39 @@ pub trait GuestTransport { fn upload(&self, source: &Path, destination: &str) -> Result<()>; } +/// Check that the target is still owned immediately before and after each +/// remote operation. Lifecycle callers keep their run lock for the wrapper's +/// lifetime and supply the ownership check for the selected backend. +pub struct OwnershipCheckedTransport<'a, T> { + transport: &'a T, + ownership: &'a dyn Fn() -> Result<()>, +} + +impl<'a, T> OwnershipCheckedTransport<'a, T> { + pub fn new(transport: &'a T, ownership: &'a dyn Fn() -> Result<()>) -> Self { + Self { + transport, + ownership, + } + } +} + +impl GuestTransport for OwnershipCheckedTransport<'_, T> { + fn execute(&self, command: &GuestCommand<'_>, input: Option<&[u8]>) -> Result { + (self.ownership)()?; + let result = self.transport.execute(command, input); + (self.ownership)()?; + result + } + + fn upload(&self, source: &Path, destination: &str) -> Result<()> { + (self.ownership)()?; + let result = self.transport.upload(source, destination); + (self.ownership)()?; + result + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum HostKeyPolicy { Enroll, diff --git a/ublue/skillet/crates/vm/src/transport/tests.rs b/ublue/skillet/crates/vm/src/transport/tests.rs index 23ed332e..90db4fe2 100644 --- a/ublue/skillet/crates/vm/src/transport/tests.rs +++ b/ublue/skillet/crates/vm/src/transport/tests.rs @@ -1,5 +1,77 @@ use super::*; -use std::{fs, os::unix::fs::symlink}; +use std::{ + fs, + os::unix::fs::symlink, + sync::{Arc, Mutex}, +}; + +struct FakeTransport(Arc>>); + +impl GuestTransport for FakeTransport { + fn execute(&self, _: &GuestCommand<'_>, _: Option<&[u8]>) -> Result { + self.0.lock().unwrap().push("execute"); + Ok(std::process::Command::new("true").output().unwrap()) + } + + fn upload(&self, _: &Path, _: &str) -> Result<()> { + self.0.lock().unwrap().push("upload"); + Ok(()) + } +} + +#[test] +fn ownership_checked_transport_checks_before_and_after_each_remote_operation() { + let operations = Arc::new(Mutex::new(Vec::new())); + let transport = FakeTransport(operations.clone()); + let checks = std::cell::Cell::new(0); + let ownership = || checks.set(checks.get() + 1); + let validate = || { + ownership(); + Ok(()) + }; + let checked = OwnershipCheckedTransport::new(&transport, &validate); + checked + .execute( + &GuestCommand { + program: "true", + arguments: &[], + }, + None, + ) + .unwrap(); + checked + .upload(Path::new("unused"), "/var/tmp/file") + .unwrap(); + assert_eq!(checks.get(), 4); + assert_eq!(*operations.lock().unwrap(), ["execute", "upload"]); +} + +#[test] +fn ownership_loss_after_operation_is_reported() { + let operations = Arc::new(Mutex::new(Vec::new())); + let transport = FakeTransport(operations.clone()); + let checks = std::cell::Cell::new(0); + let validate = || { + checks.set(checks.get() + 1); + if checks.get() == 2 { + return Err(Error::Invalid("ownership changed".into())); + } + Ok(()) + }; + let checked = OwnershipCheckedTransport::new(&transport, &validate); + let result = checked.execute( + &GuestCommand { + program: "true", + arguments: &[], + }, + None, + ); + assert!(result + .unwrap_err() + .to_string() + .contains("ownership changed")); + assert_eq!(*operations.lock().unwrap(), ["execute"]); +} fn target(dir: &Path) -> SshTarget { let identity = dir.join("id_ed25519"); From d3066b7859ade4ed78f05812dc14b8bd34e049fd Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 05:58:51 +0200 Subject: [PATCH 179/252] docs(skillet): record provisioning ownership boundaries --- ublue/AGENTS.md | 4 ++++ ublue/butane/ACCEPTANCE.md | 24 ++++++++++++++++++++++ ublue/design/skillet-architecture.md | 14 ++++++++----- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 8 ++++++++ ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 19 +++++++++++------ ublue/plan/REFACTOR-05-PODMAN-MODEL.md | 6 +++--- ublue/plan/SKILLET-REFACTOR.md | 4 ++-- 7 files changed, 63 insertions(+), 16 deletions(-) diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 83a0fc5a..d1abd597 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -47,6 +47,10 @@ - Keep shell entry points as narrow argument adapters when Rust owns a lifecycle. Backend selection, resource inspection, retries, and cleanup belong to the shared Rust owner rather than being duplicated in wrappers. +- VM guest operations hold the per-run lock and revalidate the recorded domain + identity immediately before and after each remote operation. Consumers load + target details from the typed manifest; they do not launch compatibility + wrappers for status checks or reparse legacy run files. - Keep host profile, environment, deployment instance, runtime identity, and connection target distinct. Derive conventions in one place and persist the resolved values needed for recovery. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 5404c1c7..1930a970 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1333,3 +1333,27 @@ host tool; it still uses normal command approvals. repeat issued no service start/restart and the container was removed. Podman needed SIGKILL after its 10-second stop timeout during cleanup. - No VM or live Tailscale API was contacted. `/dev/kvm` is unavailable. + +### Manifest-backed disposable provisioning transport, 2026-10-04 + +- Removed the disposable provisioner's call through the `test-vm` compatibility + wrapper and its parser for legacy `run.conf`. It now loads the typed run + manifest, holds the per-run lock, uses its recorded SSH target, and validates + the expected running domain before and after each guest operation. +- Destroy's provider cleanup callback now receives the validated run identity + from the lifecycle orchestrator, checks it against the CLI request, and + derives the cleanup journal path from that recorded identity. +- Extracted `OwnershipCheckedTransport` from readiness so both readiness and + workstation provisioning use the same pre/post ownership-check contract. + Transport tests cover both checks around execution/upload and report ownership + loss after a remote operation. +- `cargo fmt --all -- --check`, `cargo test --workspace --all-targets --offline`, + and `cargo clippy --workspace --all-targets --offline -- -D warnings` passed. + The workspace suite includes 66 workstation tests. +- `cargo run --offline --bin skillet -- test run beezelbot --phase base --image + fedora:latest` exited 0. Both applies succeeded and the repeat issued no + service start/restart; Podman needed SIGKILL after its 10-second stop timeout + during cleanup. +- No named VM or production vault/provider was used. `/dev/kvm` is absent, so + named-VM delivery, cleanup, and production credential acceptance remain + unverified. diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index c6169e63..168b2693 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -3,7 +3,9 @@ Decision, 2026-10-03: complete the seven refactoring workstreams before further service migrations, encryption implementation, or production cutover. Existing service behavior and unfinished acceptance requirements remain authoritative. -Status: planned; the review and plans do not establish runtime acceptance. +Status: implementation is complete for workstreams 1, 2, and 4–7; workstream 3 +still has an ownership API gap. Required named-VM and production acceptance has +not passed. Implementation progress: `skillet_vm` now supplies validated run identity and versioned manifests with explicit, non-destructive legacy import. Rust status @@ -20,10 +22,12 @@ restores it only after acceptance. Guest operations are guarded by ownership checks before and after each call. Production and disposable-VM credential delivery use the same verified guest transport and typed credential installer. Workstation modules now own host credential delivery, UI/DNS derivation, -provider mutations, and recovery cleanup; the CLI opens the vault, constructs -verified targets, and dispatches typed operations. Transport's bounded I/O -stays in memory; payload copies are zeroized after stdin transfer. The named -VM's overall provisioning dispatch and live acceptance remain pending. +provider mutations, and recovery cleanup; the CLI opens the vault, loads the +canonical VM manifest, and dispatches typed operations. Disposable provisioning +holds the VM run lock and revalidates recorded backend ownership around each +guest operation through a shared checked transport. Transport's bounded I/O +stays in memory; payload copies are zeroized after stdin transfer. Named-VM +and production acceptance remain pending. Workstream 2 adds `skillet_hosts` as the single host capability declaration. It owns host composition and supplies boot, service, network, UI, storage, and diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index 2851a6cd..ac0334de 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -58,6 +58,14 @@ and retained-run acceptance have not yet passed. See and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), plus [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). +Progress, 2026-10-04: disposable provisioning now loads and locks the validated +run manifest directly rather than invoking the compatibility wrapper and +re-reading `run.conf`. It uses the manifest SSH target and checks immutable +domain ownership before and after every guest operation. Destroy's external +cleanup callback derives its run directory from the recorded identity and +refuses CLI identity mismatches. Live VM cycle and recovery acceptance remain +pending. + ## Read and locate - `../design/{skillet-architecture,smoke-vms,secrets,cloudflare-ui-lifecycle}.md`. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 6f0882d5..03fd5ad4 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -1,8 +1,9 @@ # 4. Thin CLIs and shared workstation provisioning -Status: in progress; follows workstreams 1–3 in -[the prerequisite roadmap](SKILLET-REFACTOR.md). -Further feature milestones wait for that roadmap's completion. +Status: implementation complete; named-VM and production acceptance are +pending. This follows workstreams 1–3 in +[the prerequisite roadmap](SKILLET-REFACTOR.md); further feature milestones +wait for the full roadmap gate. ## Read and locate @@ -185,6 +186,12 @@ and VM delivery have duplicate implementations with different validation. Missing Pi-hole values require a successful guest absence check before generating and encrypting a replacement; disposable Pi-hole reuse and rotation use the same workstation module. -- Pending: production and named-VM acceptance, plus review of the final CLI - lifecycle dispatch against the typed workstation operations. Live - environment checks remain subject to available hardware and credentials. +- Reviewed the remaining CLI dispatch: it opens the vault, selects explicit + environment/host/instance inputs, loads the canonical VM run, and sequences + workstation operations. Disposable provisioning now holds the per-run lock, + uses the recorded SSH target, and checks backend ownership before and after + every guest operation. No compatibility script or legacy `run.conf` reader + remains in this delivery path. +- Pending: named-VM acceptance for disposable delivery, retry/rotation and + cleanup, plus production credential acceptance. Live environment checks + remain subject to available hardware and credentials. diff --git a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md index 4e957aa3..bbf3039c 100644 --- a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md +++ b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md @@ -1,8 +1,8 @@ # 5. Podman configuration with explicit host policy -Status: in progress; follows workstreams 2–4 in -[the prerequisite roadmap](SKILLET-REFACTOR.md). -Further feature milestones wait for that roadmap's completion. +Status: implementation complete; named-VM acceptance is pending. This follows +workstreams 2–4 in [the prerequisite roadmap](SKILLET-REFACTOR.md); further +feature milestones wait for the full roadmap gate. ## Read and locate diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index ddbd48ea..f874285e 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -23,8 +23,8 @@ assignments to execute concurrently against the same interfaces. | 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | | 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; file and directory ownership APIs still differ; VM checks pending | -| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | In progress; workstation now owns credential and provider transactions; live acceptance pending | -| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | In progress | +| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Implementation complete; named-VM and production acceptance pending | +| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Implementation complete; named-VM acceptance pending | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Implemented; VM acceptance pending | | 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Implemented; VM acceptance pending | From b4b45067b834e0a9d89112e2641085b0e5585888 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 12:12:51 +0200 Subject: [PATCH 180/252] refactor(core): unify file ownership inputs --- ublue/skillet/crates/btrbk/src/lib.rs | 22 ++-- ublue/skillet/crates/caddy/src/lib.rs | 17 ++- ublue/skillet/crates/cli-common/src/lib.rs | 2 +- .../crates/cli-common/src/recording_tests.rs | 4 +- ublue/skillet/crates/core/src/activation.rs | 3 +- ublue/skillet/crates/core/src/files.rs | 112 ++++++++---------- ublue/skillet/crates/core/src/files/tests.rs | 76 +++++++++--- ublue/skillet/crates/core/src/recorder.rs | 14 +-- .../skillet/crates/core/src/recorder_tests.rs | 30 ++++- .../core/src/resource_contract_tests.rs | 18 +-- ublue/skillet/crates/core/src/resource_op.rs | 4 +- ublue/skillet/crates/core/src/templates.rs | 7 +- ublue/skillet/crates/core/src/test_utils.rs | 32 +++-- ublue/skillet/crates/hardening/src/lib.rs | 34 +++++- ublue/skillet/crates/pihole/src/lib.rs | 23 ++-- ublue/skillet/crates/podman/src/lib.rs | 48 +++++--- ublue/skillet/crates/podman/src/tests.rs | 16 +-- ublue/skillet/crates/syncthing/src/lib.rs | 7 +- ublue/skillet/crates/test-fixture/src/lib.rs | 14 +-- 19 files changed, 290 insertions(+), 193 deletions(-) diff --git a/ublue/skillet/crates/btrbk/src/lib.rs b/ublue/skillet/crates/btrbk/src/lib.rs index b9a9c67c..6428040d 100644 --- a/ublue/skillet/crates/btrbk/src/lib.rs +++ b/ublue/skillet/crates/btrbk/src/lib.rs @@ -1,7 +1,7 @@ use sha2::{Digest, Sha256}; use skillet_core::{ activation::{self, ActivationRequest, ConsumerKind}, - files::{FileError, FileMutationResource, FileReadResource, StorageResource}, + files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}, system::{ServiceResource, SystemError}, }; use std::path::{Component, Path, PathBuf}; @@ -47,7 +47,7 @@ where let source = Path::new(DATA_MOUNT).join(relative); files.require_btrfs_subvolume(&source)?; let snapshot_dir = Path::new(DATA_MOUNT).join("snapshots").join(relative); - files.ensure_directory(&snapshot_dir, Some(0o755), None, None)?; + files.ensure_directory(&snapshot_dir, Some(0o755), &Ownership::default())?; sources.push(( relative, snapshot_dir @@ -60,29 +60,25 @@ where files.ensure_directory( Path::new("/etc/btrbk"), Some(0o755), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; let config_changed = files.ensure_file( Path::new(CONFIG_PATH), render_config(&sources).as_bytes(), Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; let service_changed = files.ensure_file( Path::new(SERVICE_PATH), service_unit().as_bytes(), Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; let timer_changed = files.ensure_file( Path::new(TIMER_PATH), timer_unit().as_bytes(), Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; let definition_changed = config_changed || service_changed || timer_changed; @@ -93,7 +89,11 @@ where let revision = hex::encode(hasher.finalize()); system.service_enable("skillet-btrbk.timer")?; let state_dir = Path::new("/var/lib/skillet/btrbk"); - files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + state_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; activation::activate( system, files, diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index dc27d75f..aff62683 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -1,7 +1,7 @@ //! Caddy reverse proxy for administrative interfaces. use serde::{Deserialize, Serialize}; -use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource, Ownership}; use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; @@ -278,13 +278,16 @@ where { let caddyfile = sites.render(); let config_dir = Path::new("/etc/skillet/caddy"); - files.ensure_directory(config_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + config_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; files.ensure_file( &config_dir.join("Caddyfile"), caddyfile.as_bytes(), Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; let mut container = BTreeMap::new(); @@ -358,7 +361,11 @@ where // Preserve the caller's network configuration and let Podman create its // persistent directories on the shared data filesystem. for path in ["/var/lib/data/caddy/data", "/var/lib/data/caddy/config"] { - files.ensure_directory(Path::new(path), Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + Path::new(path), + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; } skillet_podman::container(system, files, config)?; Ok(()) diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 0a272e7c..98520340 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -209,7 +209,7 @@ where let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); let apply_result = apply_fn(&recorder_system, &recorder_files, credentials); let diagnostic = DiagnosticRecording { - format_version: 1, + format_version: 2, host: hostname, outcome: if apply_result.is_ok() { "succeeded" diff --git a/ublue/skillet/crates/cli-common/src/recording_tests.rs b/ublue/skillet/crates/cli-common/src/recording_tests.rs index 96a6321a..b723dd6b 100644 --- a/ublue/skillet/crates/cli-common/src/recording_tests.rs +++ b/ublue/skillet/crates/cli-common/src/recording_tests.rs @@ -8,7 +8,7 @@ fn diagnostic_recording_is_versioned_and_contains_only_sanitized_results() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("nested/apply.yml"); let diagnostic = DiagnosticRecording { - format_version: 1, + format_version: 2, host: "fixture-host", outcome: "failed", operations: vec![RecordedOperation { @@ -20,7 +20,7 @@ fn diagnostic_recording_is_versioned_and_contains_only_sanitized_results() { }; persist_recording(&path, &diagnostic).unwrap(); let output = fs::read_to_string(path).unwrap(); - assert!(output.contains("format_version: 1")); + assert!(output.contains("format_version: 2")); assert!(output.contains("outcome: failed")); assert!(output.contains("status: failed")); assert!(!output.contains("payload")); diff --git a/ublue/skillet/crates/core/src/activation.rs b/ublue/skillet/crates/core/src/activation.rs index cc0fef48..f2611a8d 100644 --- a/ublue/skillet/crates/core/src/activation.rs +++ b/ublue/skillet/crates/core/src/activation.rs @@ -77,8 +77,7 @@ where &request.state_path, &request.revision, Some(0o644), - Some("root"), - Some("root"), + &crate::files::Ownership::named(Some("root"), Some("root")), )?; } diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs index 70c32b2e..812d9db1 100644 --- a/ublue/skillet/crates/core/src/files.rs +++ b/ublue/skillet/crates/core/src/files.rs @@ -61,6 +61,15 @@ pub struct Ownership { pub gid: Option, } +impl Ownership { + pub fn named(uid: Option<&str>, gid: Option<&str>) -> Self { + Self { + uid: uid.map(|name| OwnerIdentity::Name(name.to_string())), + gid: gid.map(|name| OwnerIdentity::Name(name.to_string())), + } + } +} + pub trait FileReadResource { fn read_file(&self, path: &Path) -> Result>, FileError>; } @@ -71,31 +80,14 @@ pub trait FileMutationResource { path: &Path, content: &[u8], mode: Option, - owner: Option<&str>, - group: Option<&str>, - ) -> Result; - fn ensure_directory_with_ownership( - &self, - path: &Path, - mode: Option, ownership: &Ownership, ) -> Result; fn ensure_directory( &self, path: &Path, mode: Option, - owner: Option<&str>, - group: Option<&str>, - ) -> Result { - self.ensure_directory_with_ownership( - path, - mode, - &Ownership { - uid: owner.map(|name| OwnerIdentity::Name(name.to_string())), - gid: group.map(|name| OwnerIdentity::Name(name.to_string())), - }, - ) - } + ownership: &Ownership, + ) -> Result; fn delete_file(&self, path: &Path) -> Result; } @@ -126,8 +118,7 @@ impl LocalFileResource { fn check_metadata( path: &Path, mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result { let metadata = fs::metadata(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; @@ -139,20 +130,12 @@ impl LocalFileResource { } } - if let Some(desired_user) = owner { - let user = get_user_by_name(desired_user) - .ok_or_else(|| FileError::UserNotFound(desired_user.to_string()))?; - if metadata.uid() != user.uid() { - changed = true; - } - } - - if let Some(desired_group) = group { - let grp = get_group_by_name(desired_group) - .ok_or_else(|| FileError::GroupNotFound(desired_group.to_string()))?; - if metadata.gid() != grp.gid() { - changed = true; - } + let uid = ownership.uid.as_ref().map(Self::identity_uid).transpose()?; + let gid = ownership.gid.as_ref().map(Self::identity_gid).transpose()?; + if uid.is_some_and(|desired| metadata.uid() != desired) + || gid.is_some_and(|desired| metadata.gid() != desired) + { + changed = true; } Ok(changed) @@ -161,8 +144,7 @@ impl LocalFileResource { fn apply_metadata_to_file( file: &File, mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result<(), FileError> { use std::os::unix::io::AsRawFd; @@ -172,18 +154,19 @@ impl LocalFileResource { file.set_permissions(perms).map_err(FileError::Io)?; } - if owner.is_some() || group.is_some() { - let uid = owner - .map(|u| get_user_by_name(u).ok_or_else(|| FileError::UserNotFound(u.to_string()))) + if ownership.uid.is_some() || ownership.gid.is_some() { + let uid = ownership + .uid + .as_ref() + .map(Self::identity_uid) .transpose()? - .map(|u| Uid::from_raw(u.uid())); - - let gid = group - .map(|g| { - get_group_by_name(g).ok_or_else(|| FileError::GroupNotFound(g.to_string())) - }) + .map(Uid::from_raw); + let gid = ownership + .gid + .as_ref() + .map(Self::identity_gid) .transpose()? - .map(|g| Gid::from_raw(g.gid())); + .map(Gid::from_raw); fchown(file.as_raw_fd(), uid, gid) .map_err(|e| FileError::SetOwnership("temp file".to_string(), e.to_string()))?; @@ -195,8 +178,7 @@ impl LocalFileResource { fn apply_metadata( path: &Path, mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result<(), FileError> { if let Some(desired_mode) = mode { let mut perms = fs::metadata(path) @@ -207,18 +189,19 @@ impl LocalFileResource { .map_err(|e| FileError::SetPermissions(path.display().to_string(), e))?; } - if owner.is_some() || group.is_some() { - let uid = owner - .map(|u| get_user_by_name(u).ok_or_else(|| FileError::UserNotFound(u.to_string()))) + if ownership.uid.is_some() || ownership.gid.is_some() { + let uid = ownership + .uid + .as_ref() + .map(Self::identity_uid) .transpose()? - .map(|u| Uid::from_raw(u.uid())); - - let gid = group - .map(|g| { - get_group_by_name(g).ok_or_else(|| FileError::GroupNotFound(g.to_string())) - }) + .map(Uid::from_raw); + let gid = ownership + .gid + .as_ref() + .map(Self::identity_gid) .transpose()? - .map(|g| Gid::from_raw(g.gid())); + .map(Gid::from_raw); chown(path, uid, gid) .map_err(|e| FileError::SetOwnership(path.display().to_string(), e.to_string()))?; @@ -383,8 +366,7 @@ impl FileMutationResource for LocalFileResource { path: &Path, content: &[u8], mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result { // 1. Check parent directory let parent = path @@ -427,7 +409,7 @@ impl FileMutationResource for LocalFileResource { let mut temp_file = NamedTempFile::new_in(parent)?; temp_file.write_all(content)?; // Apply metadata to temp file before persist - Self::apply_metadata_to_file(temp_file.as_file(), mode, owner, group)?; + Self::apply_metadata_to_file(temp_file.as_file(), mode, ownership)?; temp_file .persist(path) .map_err(|e| FileError::Persist(path.display().to_string(), e.error))?; @@ -435,8 +417,8 @@ impl FileMutationResource for LocalFileResource { info!("Updated file content for {}", path.display()); } else { // Even if content didn't change, we might need to update metadata - if path.exists() && Self::check_metadata(path, mode, owner, group)? { - Self::apply_metadata(path, mode, owner, group)?; + if path.exists() && Self::check_metadata(path, mode, ownership)? { + Self::apply_metadata(path, mode, ownership)?; changed = true; info!("Updated file metadata for {}", path.display()); } @@ -445,7 +427,7 @@ impl FileMutationResource for LocalFileResource { Ok(changed) } - fn ensure_directory_with_ownership( + fn ensure_directory( &self, path: &Path, mode: Option, diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs index 0df318cb..94cfd2dc 100644 --- a/ublue/skillet/crates/core/src/files/tests.rs +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -44,7 +44,7 @@ fn test_ensure_file_creates_file() { let resource = LocalFileResource::new(); let changed = resource - .ensure_file(&file_path, content, None, None, None) + .ensure_file(&file_path, content, None, &Ownership::default()) .unwrap(); assert!(changed); assert!(file_path.exists()); @@ -60,13 +60,13 @@ fn test_ensure_file_idempotent() { // First write let changed = resource - .ensure_file(&file_path, content, None, None, None) + .ensure_file(&file_path, content, None, &Ownership::default()) .unwrap(); assert!(changed); // Second write (same content) let changed_again = resource - .ensure_file(&file_path, content, None, None, None) + .ensure_file(&file_path, content, None, &Ownership::default()) .unwrap(); assert!(!changed_again); } @@ -78,11 +78,11 @@ fn test_ensure_file_updates_content() { let resource = LocalFileResource::new(); resource - .ensure_file(&file_path, b"initial", None, None, None) + .ensure_file(&file_path, b"initial", None, &Ownership::default()) .unwrap(); let changed = resource - .ensure_file(&file_path, b"updated", None, None, None) + .ensure_file(&file_path, b"updated", None, &Ownership::default()) .unwrap(); assert!(changed); assert_eq!(fs::read(&file_path).unwrap(), b"updated"); @@ -97,12 +97,12 @@ fn test_ensure_file_metadata() { // 1. Create with default meta resource - .ensure_file(&file_path, content, None, None, None) + .ensure_file(&file_path, content, None, &Ownership::default()) .unwrap(); // 2. Change mode let changed = resource - .ensure_file(&file_path, content, Some(0o644), None, None) + .ensure_file(&file_path, content, Some(0o644), &Ownership::default()) .unwrap(); assert!(changed); let meta = fs::metadata(&file_path).unwrap(); @@ -110,7 +110,7 @@ fn test_ensure_file_metadata() { // 3. Idempotent mode change let changed_again = resource - .ensure_file(&file_path, content, Some(0o644), None, None) + .ensure_file(&file_path, content, Some(0o644), &Ownership::default()) .unwrap(); assert!(!changed_again); @@ -130,7 +130,7 @@ fn test_ensure_file_replaces_symlink() { let resource = LocalFileResource::new(); let content = b"new content"; let changed = resource - .ensure_file(&link_path, content, None, None, None) + .ensure_file(&link_path, content, None, &Ownership::default()) .unwrap(); assert!(changed); @@ -146,7 +146,7 @@ fn test_ensure_directory_creates_dir() { let resource = LocalFileResource::new(); let changed = resource - .ensure_directory(&sub_dir, Some(0o755), None, None) + .ensure_directory(&sub_dir, Some(0o755), &Ownership::default()) .unwrap(); assert!(changed); assert!(sub_dir.exists()); @@ -164,7 +164,7 @@ fn directory_numeric_owner_is_idempotent() { let resource = LocalFileResource::new(); assert!(resource - .ensure_directory_with_ownership( + .ensure_directory( &path, Some(0o750), &Ownership { @@ -174,7 +174,7 @@ fn directory_numeric_owner_is_idempotent() { ) .unwrap()); assert!(!resource - .ensure_directory_with_ownership( + .ensure_directory( &path, Some(0o750), &Ownership { @@ -191,6 +191,54 @@ fn directory_numeric_owner_is_idempotent() { ); } +#[test] +fn file_numeric_owner_is_idempotent_and_does_not_need_name_lookup() { + use std::os::unix::fs::MetadataExt; + + let dir = tempdir().unwrap(); + let path = dir.path().join("numeric-owner-file"); + let parent = fs::metadata(dir.path()).unwrap(); + let resource = LocalFileResource::new(); + let ownership = Ownership { + uid: Some(OwnerIdentity::Id(parent.uid())), + gid: Some(OwnerIdentity::Id(parent.gid())), + }; + + assert!(resource + .ensure_file(&path, b"numeric owner", Some(0o640), &ownership) + .unwrap()); + assert!(!resource + .ensure_file(&path, b"numeric owner", Some(0o640), &ownership) + .unwrap()); + + let metadata = fs::metadata(path).unwrap(); + assert_eq!( + (metadata.uid(), metadata.gid()), + (parent.uid(), parent.gid()) + ); +} + +#[test] +fn numeric_file_owner_comparison_does_not_require_an_nss_entry() { + use std::os::unix::fs::MetadataExt; + + let dir = tempdir().unwrap(); + let path = dir.path().join("unmapped-numeric-owner"); + fs::write(&path, b"fixture").unwrap(); + let metadata = fs::metadata(&path).unwrap(); + let unmatched_uid = metadata.uid().wrapping_add(1); + + assert!(LocalFileResource::check_metadata( + &path, + None, + &Ownership { + uid: Some(OwnerIdentity::Id(unmatched_uid)), + gid: None, + }, + ) + .unwrap()); +} + #[test] fn test_ensure_directory_fails_if_file() { let dir = tempdir().unwrap(); @@ -198,7 +246,7 @@ fn test_ensure_directory_fails_if_file() { fs::write(&file_path, b"not a dir").unwrap(); let resource = LocalFileResource::new(); - let result = resource.ensure_directory(&file_path, None, None, None); + let result = resource.ensure_directory(&file_path, None, &Ownership::default()); assert!(result.is_err()); match result { Err(FileError::NotADirectory(p)) => assert_eq!(p, file_path.display().to_string()), @@ -217,7 +265,7 @@ fn test_ensure_directory_follows_symlink() { let resource = LocalFileResource::new(); let changed = resource - .ensure_directory(&link_path, None, None, None) + .ensure_directory(&link_path, None, &Ownership::default()) .unwrap(); // target_dir already exists, and we follow the symlink link_path to it. diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index e739ea7f..bda2189c 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -94,30 +94,26 @@ impl FileMutationResource for Recorder { path: &Path, content: &[u8], mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result { - let result = self.inner.ensure_file(path, content, mode, owner, group); + let result = self.inner.ensure_file(path, content, mode, ownership); self.record( ResourceOp::EnsureFile { path: path.display().to_string(), mode: mode.map(|m| format!("0o{m:o}")), - owner: owner.map(ToString::to_string), - group: group.map(ToString::to_string), + ownership: ownership.clone(), }, changed(&result), ); result } - fn ensure_directory_with_ownership( + fn ensure_directory( &self, path: &Path, mode: Option, ownership: &Ownership, ) -> Result { - let result = self - .inner - .ensure_directory_with_ownership(path, mode, ownership); + let result = self.inner.ensure_directory(path, mode, ownership); self.record( ResourceOp::EnsureDirectory { path: path.display().to_string(), diff --git a/ublue/skillet/crates/core/src/recorder_tests.rs b/ublue/skillet/crates/core/src/recorder_tests.rs index 6de016a4..5a66df68 100644 --- a/ublue/skillet/crates/core/src/recorder_tests.rs +++ b/ublue/skillet/crates/core/src/recorder_tests.rs @@ -1,6 +1,7 @@ use super::*; +use crate::files::{FileMutationResource, OwnerIdentity, Ownership}; use crate::resource_op::EffectResult; -use crate::test_utils::MockSystem; +use crate::test_utils::{MockFiles, MockSystem}; use sha2::Digest as _; use std::sync::atomic::Ordering; @@ -25,3 +26,30 @@ fn records_results_without_payloads_or_fingerprints() { assert!(!yaml.contains(secret)); assert!(!yaml.contains(&hex::encode(sha2::Sha256::digest(secret.as_bytes())))); } + +#[test] +fn file_recording_preserves_numeric_ownership() { + let files = MockFiles::new(); + let recorder = Recorder::new(files); + let ownership = Ownership { + uid: Some(OwnerIdentity::Id(1234)), + gid: Some(OwnerIdentity::Id(2345)), + }; + recorder + .ensure_file( + std::path::Path::new("/tmp/recorded-file"), + b"content", + Some(0o640), + &ownership, + ) + .unwrap(); + + let operations = recorder.get_ops(); + assert!(matches!( + &operations[0].operation, + ResourceOp::EnsureFile { + ownership: recorded_ownership, + .. + } if recorded_ownership == &ownership + )); +} diff --git a/ublue/skillet/crates/core/src/resource_contract_tests.rs b/ublue/skillet/crates/core/src/resource_contract_tests.rs index 45a3e395..8fe124f6 100644 --- a/ublue/skillet/crates/core/src/resource_contract_tests.rs +++ b/ublue/skillet/crates/core/src/resource_contract_tests.rs @@ -1,5 +1,5 @@ use crate::{ - files::{FileMutationResource, FileReadResource, LocalFileResource}, + files::{FileMutationResource, FileReadResource, LocalFileResource, Ownership}, test_utils::MockFiles, }; use std::path::Path; @@ -7,14 +7,14 @@ use std::path::Path; fn file_mutation_contract(resource: &(impl FileMutationResource + FileReadResource), path: &Path) { assert_eq!(resource.read_file(path).unwrap(), None); assert!(resource - .ensure_file(path, b"initial", Some(0o640), None, None) + .ensure_file(path, b"initial", Some(0o640), &Ownership::default()) .unwrap()); assert_eq!(resource.read_file(path).unwrap(), Some(b"initial".to_vec())); assert!(!resource - .ensure_file(path, b"initial", Some(0o640), None, None) + .ensure_file(path, b"initial", Some(0o640), &Ownership::default()) .unwrap()); assert!(resource - .ensure_file(path, b"updated", Some(0o640), None, None) + .ensure_file(path, b"updated", Some(0o640), &Ownership::default()) .unwrap()); assert_eq!(resource.read_file(path).unwrap(), Some(b"updated".to_vec())); assert!(resource.delete_file(path).unwrap()); @@ -45,13 +45,15 @@ fn local_and_mock_file_resources_reject_file_directory_conflicts() { let mock = MockFiles::new(); local - .ensure_file(&local_path, b"file", None, None, None) + .ensure_file(&local_path, b"file", None, &Ownership::default()) .unwrap(); - mock.ensure_file(&mock_path, b"file", None, None, None) + mock.ensure_file(&mock_path, b"file", None, &Ownership::default()) .unwrap(); assert!(local - .ensure_directory(&local_path, None, None, None) + .ensure_directory(&local_path, None, &Ownership::default()) + .is_err()); + assert!(mock + .ensure_directory(&mock_path, None, &Ownership::default()) .is_err()); - assert!(mock.ensure_directory(&mock_path, None, None, None).is_err()); } diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index d5ac3ab4..13ef3669 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -6,8 +6,8 @@ pub enum ResourceOp { EnsureFile { path: String, mode: Option, - owner: Option, - group: Option, + #[serde(default)] + ownership: Ownership, }, DeleteFile { path: String, diff --git a/ublue/skillet/crates/core/src/templates.rs b/ublue/skillet/crates/core/src/templates.rs index 48d0e0c9..cb41761f 100644 --- a/ublue/skillet/crates/core/src/templates.rs +++ b/ublue/skillet/crates/core/src/templates.rs @@ -1,4 +1,4 @@ -use crate::files::{FileError, FileResource}; +use crate::files::{FileError, FileResource, Ownership}; use askama::Template; use std::path::Path; @@ -7,8 +7,7 @@ pub fn ensure_templated_file( path: &Path, template: &T, mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result where T: Template, @@ -20,5 +19,5 @@ where ))) })?; - files.ensure_file(path, content.as_bytes(), mode, owner, group) + files.ensure_file(path, content.as_bytes(), mode, ownership) } diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index 76908a02..d64aaa3b 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -257,7 +257,7 @@ impl ServiceResource for MockSystem { } } -pub type FileMetadata = (Option, Option, Option); +pub type FileMetadata = (Option, Ownership); pub type DirectoryMetadata = (Option, Ownership); pub type DirectoryMetadataMap = Arc>>; @@ -310,7 +310,7 @@ impl StorageResource for MockFiles { } fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { - FileMutationResource::ensure_directory(self, path, None, None, None) + FileMutationResource::ensure_directory(self, path, None, &Ownership::default()) } } @@ -331,8 +331,7 @@ impl FileMutationResource for MockFiles { path: &Path, content: &[u8], mode: Option, - owner: Option<&str>, - group: Option<&str>, + ownership: &Ownership, ) -> Result { let path_str = path.display().to_string(); if self @@ -369,25 +368,22 @@ impl FileMutationResource for MockFiles { changed = true; } - let new_meta = ( - mode, - owner.map(ToString::to_string), - group.map(ToString::to_string), + let current = metadata.get(&path_str).cloned().unwrap_or_default(); + let desired = ( + mode.or(current.0), + Ownership { + uid: ownership.uid.clone().or(current.1.uid), + gid: ownership.gid.clone().or(current.1.gid), + }, ); - if let Some(existing_meta) = metadata.get(&path_str) { - if existing_meta != &new_meta { - metadata.insert(path_str, new_meta); - changed = true; - } - } else { - metadata.insert(path_str, new_meta); - changed = true; - } + let unchanged = !changed && metadata.get(&path_str) == Some(&desired); + metadata.insert(path_str, desired); + changed |= !unchanged; Ok(changed) } - fn ensure_directory_with_ownership( + fn ensure_directory( &self, path: &Path, mode: Option, diff --git a/ublue/skillet/crates/hardening/src/lib.rs b/ublue/skillet/crates/hardening/src/lib.rs index 5b47ac47..89806404 100644 --- a/ublue/skillet/crates/hardening/src/lib.rs +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -1,6 +1,6 @@ use sha2::{Digest, Sha256}; use skillet_core::activation::{self, ActivationRequest, ConsumerKind}; -use skillet_core::files::{FileError, FileMutationResource, FileReadResource}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource, Ownership}; use skillet_core::system::{ServiceResource, SystemError}; use std::path::Path; use thiserror::Error; @@ -31,7 +31,11 @@ where // Common setup for SSH let ssh_dir = Path::new("/etc/ssh"); - files.ensure_directory(ssh_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + ssh_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; // 3. Include 'ssh-hardening::server' apply_ssh_hardening_server(system, files)?; @@ -55,10 +59,19 @@ where F: FileMutationResource + FileReadResource + ?Sized, { let state_dir = Path::new("/var/lib/skillet/hardening"); - files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + state_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; let applied_path = state_dir.join(format!("{service}.applied")); let revision = hex::encode(Sha256::digest(content)); - let changed = files.ensure_file(path, content, Some(mode), Some("root"), Some("root"))?; + let changed = files.ensure_file( + path, + content, + Some(mode), + &Ownership::named(Some("root"), Some("root")), + )?; activation::activate( system, files, @@ -81,7 +94,11 @@ where { info!("Applying sysctl hardening..."); let sysctl_dir = Path::new("/etc/sysctl.d"); - files.ensure_directory(sysctl_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + sysctl_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; let content = include_bytes!("../files/sysctl.boxy.conf"); let path = sysctl_dir.join("99-hardening.conf"); @@ -118,7 +135,12 @@ where let content = include_bytes!("../files/ssh_config"); let path = Path::new("/etc/ssh/ssh_config"); - files.ensure_file(path, content, Some(0o644), Some("root"), Some("root"))?; + files.ensure_file( + path, + content, + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; Ok(()) } diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 83af1051..86a971fa 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -1,5 +1,7 @@ use askama::Template; -use skillet_core::files::{FileError, FileMutationResource, FileReadResource, StorageResource}; +use skillet_core::files::{ + FileError, FileMutationResource, FileReadResource, Ownership, StorageResource, +}; use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; @@ -63,10 +65,18 @@ where system.ensure_user(&user_config.name, user_config.uid, user_config.gid)?; // 2. Ensure directories - files.ensure_directory(Path::new(root), Some(0o755), Some("root"), Some("root"))?; - files.ensure_directory(Path::new(etc), Some(0o755), None, None)?; - files.ensure_directory(&Path::new(root).join("dnsmasq.d"), Some(0o755), None, None)?; - files.ensure_directory(Path::new(logs), Some(0o755), None, None)?; + files.ensure_directory( + Path::new(root), + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + files.ensure_directory(Path::new(etc), Some(0o755), &Ownership::default())?; + files.ensure_directory( + &Path::new(root).join("dnsmasq.d"), + Some(0o755), + &Ownership::default(), + )?; + files.ensure_directory(Path::new(logs), Some(0o755), &Ownership::default())?; // 3. Custom list template (records supplied by the host) let template = CustomListTemplate { @@ -81,8 +91,7 @@ where &Path::new(etc).join("custom.list"), custom_list.as_bytes(), Some(0o640), - None, - None, + &Ownership::default(), )?; // 4. Define container diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 15541b00..945f3f8f 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -250,13 +250,16 @@ pub fn ensure_dns_listener_port( port: u16, ) -> Result { let config_dir = Path::new("/etc/containers/containers.conf.d"); - files.ensure_directory(config_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + config_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; Ok(files.ensure_file( &config_dir.join("90-skillet-aardvark.conf"), format!("[network]\ndns_bind_port={port}\n").as_bytes(), Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?) } @@ -278,24 +281,30 @@ where } } let quadlet_dir = Path::new("/etc/containers/systemd"); - files.ensure_directory(quadlet_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + quadlet_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; if files.ensure_file( &quadlet_dir.join(format!("{}.network", network.unit_name)), content.as_bytes(), Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )? { system.daemon_reload()?; } let state_dir = Path::new("/var/lib/skillet/networks"); - files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + state_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; files.ensure_file( &marker_path, content.as_bytes(), Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; Ok(()) } @@ -359,11 +368,7 @@ where // Ownership is part of this typed volume declaration and affects only // the root directory. `None` preserves application-managed metadata. let ownership = vol.host_ownership.unwrap_or_default(); - files.ensure_directory_with_ownership( - Path::new(&vol.host_path), - vol.host_mode, - &ownership, - )?; + files.ensure_directory(Path::new(&vol.host_path), vol.host_mode, &ownership)?; let mut vol_line = format!("Volume={}:{}", vol.host_path, vol.container_path); if let Some(opt) = vol.options { @@ -791,19 +796,26 @@ where } let revision = hex::encode(hasher.finalize()); let state_dir = Path::new("/var/lib/skillet/containers"); - files.ensure_directory(state_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + state_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; let applied_path = state_dir.join(format!("{name}.applied")); let quadlet_dir = Path::new("/etc/containers/systemd"); - files.ensure_directory(quadlet_dir, Some(0o755), Some("root"), Some("root"))?; + files.ensure_directory( + quadlet_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; let quadlet_path = quadlet_dir.join(format!("{name}.container")); let changed = files.ensure_file( &quadlet_path, content.as_bytes(), Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; let outcome = activation::activate( diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index fba0a47e..4574f6fc 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -535,17 +535,19 @@ fn filesystem_fake_enforces_object_types_and_injected_failures() { let files = MockFiles::new(); let file_path = std::path::Path::new("/tmp/mock-file"); files - .ensure_file(file_path, b"data", None, None, None) + .ensure_file(file_path, b"data", None, &Ownership::default()) .unwrap(); assert!(matches!( - files.ensure_directory(file_path, None, None, None), + files.ensure_directory(file_path, None, &Ownership::default()), Err(FileError::NotADirectory(_)) )); let directory = std::path::Path::new("/tmp/mock-directory"); - files.ensure_directory(directory, None, None, None).unwrap(); + files + .ensure_directory(directory, None, &Ownership::default()) + .unwrap(); assert!(matches!( - files.ensure_file(directory, b"data", None, None, None), + files.ensure_file(directory, b"data", None, &Ownership::default()), Err(FileError::NotAFile(_)) )); @@ -555,8 +557,7 @@ fn filesystem_fake_enforces_object_types_and_injected_failures() { std::path::Path::new("/tmp/failing-file"), b"data", None, - None, - None + &Ownership::default() ) .is_err()); files.fail_directory_once.store(true, Ordering::SeqCst); @@ -564,8 +565,7 @@ fn filesystem_fake_enforces_object_types_and_injected_failures() { .ensure_directory( std::path::Path::new("/tmp/failing-directory"), None, - None, - None + &Ownership::default() ) .is_err()); } diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index 0ab0e089..d4721c07 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -1,4 +1,6 @@ -use skillet_core::files::{FileError, FileMutationResource, FileReadResource, StorageResource}; +use skillet_core::files::{ + FileError, FileMutationResource, FileReadResource, Ownership, StorageResource, +}; use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; @@ -42,8 +44,7 @@ where files.ensure_directory( data_path, Some(0o755), - Some(&config.data_owner), - Some(&config.data_group), + &Ownership::named(Some(&config.data_owner), Some(&config.data_group)), )?; let volumes = vec![Volume { diff --git a/ublue/skillet/crates/test-fixture/src/lib.rs b/ublue/skillet/crates/test-fixture/src/lib.rs index c701f4fd..90c22dbb 100644 --- a/ublue/skillet/crates/test-fixture/src/lib.rs +++ b/ublue/skillet/crates/test-fixture/src/lib.rs @@ -1,7 +1,7 @@ //! Disposable application used only by the real-runtime acceptance fixture. use skillet_core::{ - files::{FileError, FileResource}, + files::{FileError, FileResource, Ownership}, system::{SystemError, SystemResource}, }; use skillet_podman::{ @@ -39,28 +39,24 @@ pub fn apply(system: &dyn SystemResource, files: &dyn FileResource) -> Result<() files.ensure_directory( Path::new("/etc/skillet-smoke"), Some(0o755), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; files.ensure_directory( Path::new("/var/lib/skillet-smoke/data"), Some(0o755), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; files.ensure_file( Path::new(CONFIG_PATH), &config, Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; files.ensure_file( Path::new("/etc/skillet-smoke/entrypoint.sh"), ENTRYPOINT, Some(0o644), - Some("root"), - Some("root"), + &Ownership::named(Some("root"), Some("root")), )?; system.ensure_podman_secret(SECRET_NAME, &secret)?; From ccd5eba7d42157f32ea4507c01be7f21c125ac60 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 12:12:58 +0200 Subject: [PATCH 181/252] docs(skillet): record unified ownership contract --- ublue/AGENTS.md | 3 ++ ublue/butane/ACCEPTANCE.md | 22 +++++++++++ ublue/design/skillet-architecture.md | 22 +++++------ ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 38 +++++++++---------- ublue/plan/SKILLET-REFACTOR.md | 2 +- 5 files changed, 55 insertions(+), 32 deletions(-) diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index d1abd597..b2847a08 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -54,6 +54,9 @@ - Keep host profile, environment, deployment instance, runtime identity, and connection target distinct. Derive conventions in one place and persist the resolved values needed for recovery. +- File and directory mutations use the same typed ownership contract for + named and numeric UID/GID values. Numeric ownership must not require a host + account lookup; preserve omitted metadata fields on existing paths. - Persist ownership before mutations and preserve it across partial failures. Cleanup must tolerate already-absent owned resources while refusing ambiguous or unrelated resources. Remove recovery metadata only after cleanup completes. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 1930a970..5aad6b42 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1357,3 +1357,25 @@ host tool; it still uses normal command approvals. - No named VM or production vault/provider was used. `/dev/kvm` is absent, so named-VM delivery, cleanup, and production credential acceptance remain unverified. + +### Unified typed file ownership API, 2026-10-04 + +- File and directory mutation APIs now accept the same `Ownership` value for + optional named or numeric UID/GID. Local files and directories resolve named + identities consistently, while numeric IDs do not require a matching NSS + account. Existing metadata omitted from an ownership request is preserved. +- Recorder operations serialize this common ownership value; diagnostic + recording schema is now version 2. Version 1 output remains historical and + is not rewritten. +- Added local filesystem tests for numeric ownership and idempotent repeat + apply, plus a numeric comparison test that does not perform NSS lookup. + Existing shared mutation contract tests continue to exercise both the local + and fake file adapters. +- `cargo fmt --all -- --check`, + `cargo test --workspace --all-targets --offline`, and strict all-target + Clippy passed. The beezelbot Fedora base integration exited 0: both applies + succeeded, the repeat issued no service start/restart, and the disposable + container was removed. Podman needed SIGKILL after its 10-second stop + timeout during cleanup. +- Named-VM ownership/mount acceptance remains unverified because `/dev/kvm` + is unavailable on this workstation. diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 168b2693..e3137a90 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -3,9 +3,9 @@ Decision, 2026-10-03: complete the seven refactoring workstreams before further service migrations, encryption implementation, or production cutover. Existing service behavior and unfinished acceptance requirements remain authoritative. -Status: implementation is complete for workstreams 1, 2, and 4–7; workstream 3 -still has an ownership API gap. Required named-VM and production acceptance has -not passed. +Status: implementation is complete for workstreams 1–7. Workstream 3's named-VM +ownership and mount acceptance remains pending; required named-VM and production +acceptance has not passed. Implementation progress: `skillet_vm` now supplies validated run identity and versioned manifests with explicit, non-destructive legacy import. Rust status @@ -38,14 +38,14 @@ Tailscale enrollment now accepts provider, profile/environment, VM identity, state path, and guest transport as explicit inputs. Retained-VM runtime acceptance remains pending. -Workstream 3 is in progress. Account observations and subordinate-ID files -enter composition through injected resource interfaces; missing or invalid -subordinate ranges fail closed. Directory ownership uses one typed named or -numeric identity contract, and entry points pass required systemd credentials -to host composition. File ownership is not fully unified yet: file mutations -still accept separate name-only owner/group strings, while directory -mutations accept the typed named-or-numeric ownership value. Host composition -also retains aggregate effect interfaces during migration. +Workstream 3 implementation is complete. Account observations and +subordinate-ID files enter composition through injected resource interfaces; +missing or invalid subordinate ranges fail closed. File and directory +mutations share the typed named-or-numeric `Ownership` contract, including +numeric IDs that do not require a matching host account. Entry points pass +required systemd credentials to host composition. Host composition retains +aggregate effect interfaces during migration. Named-VM ownership and mount +acceptance remains pending. Keep application crates as reusable recipes. Separate canonical host profiles and composition from guest runtime adapters, CLI parsing, workstation diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md index 3ee9e8e5..706e44a2 100644 --- a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -1,8 +1,9 @@ # 3. Cohesive effect interfaces and unified ownership -Status: in progress; follows workstream 2 in -[the prerequisite roadmap](SKILLET-REFACTOR.md). Further feature milestones -wait for that roadmap's completion. +Status: implementation complete; named-VM ownership/mount acceptance is +pending. This follows workstream 2 in +[the prerequisite roadmap](SKILLET-REFACTOR.md); further feature milestones +wait for the full roadmap gate. ## Read and locate @@ -11,10 +12,10 @@ wait for that roadmap's completion. and the host library; ownership inputs in application crates. - `../design/{secrets,storage,skillet-architecture}.md` and workstreams 2 and 4. -Current defects: broad file/system traits accumulate unrelated capabilities, -but recipe code still reads live account data, subordinate-ID files and the -credential environment outside those interfaces. Named and numeric directory -ownership use separate APIs; mocks do not enforce important metadata contracts. +Original defects: broad file/system traits accumulated unrelated capabilities, +and recipe code read live account data, subordinate-ID files and the credential +environment outside those interfaces. File and directory ownership are now +unified; remaining trait aggregates are transitional composition contexts. ## Implementation sequence @@ -69,17 +70,14 @@ ownership use separate APIs; mocks do not enforce important metadata contracts. lookups; NSS access remains in the Linux system adapter. Podman reads subordinate-ID files through `FileResource`, validates the selected single range, and fails closed for missing, invalid, duplicate, or undersized ranges. -- Implemented: directory ownership uses one `Ownership` value with named or - numeric UID/GID. Numeric IDs do not require matching account names. The - fake preserves mode and ownership across existence-only checks; the UniFi - repeat-apply case verifies child data survives. -- Pending local slice: the 2026-10-04 static audit found that `ensure_file` - still accepts separate name-only owner/group strings while directory - ownership accepts the shared named-or-numeric `Ownership` value. The - ownership-interface exit criterion is therefore not met. A proposed - cross-crate API migration was rejected by automatic review as a broad core - filesystem change; do not claim this item complete until an authorized, - reviewable migration is implemented and validated across every caller. +- Implemented: file and directory mutations use the same `Ownership` value + with named or numeric UID/GID. Numeric IDs do not require matching account + names. The local adapter and fake preserve partial metadata across + existence-only checks; UniFi's repeat-apply case verifies child data survives. +- Implemented: the file API, directory API, local adapter, recorder, fake, + templates, and all application callers share this ownership input. Recording + schema version 2 records named and numeric identities. Version 1 recordings + remain historical output and are not rewritten. - Implemented: the guest CLI loads phase-required systemd credentials and passes `CredentialInputs` to host composition. Recipes no longer inspect `CREDENTIALS_DIRECTORY`; required names derive from profile consumers. @@ -104,5 +102,5 @@ ownership use separate APIs; mocks do not enforce important metadata contracts. - Implemented: service state and Podman-secret idempotency/rotation contracts run against both `MockSystem` and its `Recorder` decorator. Unit tests do not mutate the workstation's real services or secrets. -- Pending: live ownership/mount acceptance remains outstanding. This - workstream is not complete until the named VM checks pass. +- Pending: live ownership/mount acceptance remains outstanding. Implementation + is complete, but the named-VM exit criterion still needs to pass. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index f874285e..02cfa890 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -22,7 +22,7 @@ assignments to execute concurrently against the same interfaces. | --- | --- | --- | --- | | 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | | 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | -| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | In progress; file and directory ownership APIs still differ; VM checks pending | +| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Implementation complete; named-VM ownership/mount acceptance pending | | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Implementation complete; named-VM and production acceptance pending | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Implementation complete; named-VM acceptance pending | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Implemented; VM acceptance pending | From 8b14b7678bc5744de7c795a708f00174eabd4db0 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 15:25:43 +0200 Subject: [PATCH 182/252] feat(cli): add standalone vault unlock command --- ublue/butane/ACCEPTANCE.md | 15 +++++++++ ublue/design/secrets.md | 3 +- ublue/skillet/README.md | 12 +++++++ ublue/skillet/crates/cli/src/main.rs | 33 +++++++++++++++++++ .../skillet/crates/cli/src/secret_delivery.rs | 16 +++++++++ ublue/skillet/crates/workstation/src/vault.rs | 19 ++++++++--- .../crates/workstation/src/vault/tests.rs | 6 ++-- 7 files changed, 96 insertions(+), 8 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 5aad6b42..06b9b840 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1379,3 +1379,18 @@ host tool; it still uses normal command approvals. timeout during cleanup. - Named-VM ownership/mount acceptance remains unverified because `/dev/kvm` is unavailable on this workstation. + +### Standalone KeePassXC unlock, 2026-10-04 + +- Added `skillet secret unlock` to verify the configured KeePassXC database + and establish the existing three-hour session-keyring cache without starting + host or VM provisioning. `--database` and `--key-file` override the defaults. +- The command exits with an error if the vault opens but the cache cannot be + established. CLI parsing tests cover the default path and both overrides; + the vault cache tests cover the three-hour expiry and the cache result. +- `cargo fmt --all -- --check`, offline workspace all-target tests, and strict + offline all-target Clippy passed. The beezelbot Fedora base integration + exited 0: both applies succeeded, the repeat issued no service start/restart, + and Podman removed the disposable container after its stop timeout. +- Interactive validation against the user's vault was not run because the + password must be entered by the user. diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 030bb796..d331794e 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -45,7 +45,8 @@ The password key remains in the session keyring, where the process has the permissions needed to set its expiry. If the kernel denies expiry, Skillet removes the new key and continues without a cache. `skillet secret lock` removes a cached key early. A new workstation can unlock the copied database -without the old one's keyring. +without the old one's keyring. `skillet secret unlock` verifies the database and +establishes the same session cache without coupling unlock to provisioning. The Cloudflare token creator is stored at `skillet/cloudflare/token-creator`. Vault paths are group paths plus an entry title; values use the Password field. diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 4f2fb43e..0bb556dd 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -131,6 +131,18 @@ three hours. It does not write decrypted values to a file. Run `cargo run --release -p skillet -- secret lock` to clear the cache early. If the kernel denies key expiry, Skillet reports that it could not cache the unlock and asks for the password on the next command. + +To unlock the database and verify the three-hour session cache without +provisioning a host or VM, run: + +```bash +cargo run --release -p skillet -- secret unlock +``` + +Use `--database PATH` for a non-default database or `--key-file PATH` when the +database uses a key file. The command verifies the database, then exits; it +does not change the vault. It reports an error if the password is valid but +the kernel session cache could not be established. Skillet looks for `$XDG_DATA_HOME/skillet/secrets.kdbx`, falling back to `$HOME/.local/share/skillet/secrets.kdbx`. Point that location at your Syncthing-synced database, for example: diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index fa9a008a..f664a210 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -47,10 +47,20 @@ enum Commands { enum SecretCommands { /// Deliver credentials for a configured host service Deliver(SecretDeliverArgs), + /// Verify and cache the `KeePassXC` password for this session + Unlock(SecretUnlockArgs), /// Remove the cached vault password from the kernel keyring Lock(SecretLockArgs), } +#[derive(clap::Args, Debug)] +struct SecretUnlockArgs { + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, +} + #[derive(clap::Args, Debug)] struct SecretLockArgs { #[arg(long)] @@ -248,6 +258,9 @@ fn main() -> Result<()> { } => { secret_delivery::deliver_from_vault(&args)?; } + Commands::Secret { + command: SecretCommands::Unlock(args), + } => secret_delivery::unlock_vault(args.database.as_deref(), args.key_file.as_deref())?, Commands::Secret { command: SecretCommands::Lock(args), } => secret_delivery::lock_vault(args.database.as_deref())?, @@ -727,6 +740,26 @@ mod tests { ]); assert!(parsed.is_ok()); } + + #[test] + fn secret_unlock_accepts_database_and_key_file_overrides() { + let parsed = Args::try_parse_from([ + "skillet", + "secret", + "unlock", + "--database", + "/tmp/secrets.kdbx", + "--key-file", + "/tmp/secrets.keyx", + ]); + assert!(parsed.is_ok()); + } + + #[test] + fn secret_unlock_uses_default_database_when_no_path_is_given() { + let parsed = Args::try_parse_from(["skillet", "secret", "unlock"]); + assert!(parsed.is_ok()); + } } #[cfg(test)] diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index a9c9fd7e..b308a293 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -119,6 +119,22 @@ pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { Ok(()) } +pub(super) fn unlock_vault(path: Option<&Path>, key_file: Option<&Path>) -> Result<()> { + let path = match path { + Some(path) => path.to_path_buf(), + None => default_database_path()?, + }; + let vault = Vault::open(&path, key_file).context("opening KeePassXC database for unlock")?; + if !vault.password_cached() { + return Err(anyhow!( + "KeePassXC password was verified, but the three-hour session cache could not be established; see the keyring warning above" + )); + } + drop(vault); + println!("KeePassXC vault unlocked; password cached for up to three hours"); + Ok(()) +} + fn tailscale_credentials(vault: &Vault) -> Result { let client_id = vault .get("skillet/tailscale/provisioner-client-id")? diff --git a/ublue/skillet/crates/workstation/src/vault.rs b/ublue/skillet/crates/workstation/src/vault.rs index e8d4d492..3614049e 100644 --- a/ublue/skillet/crates/workstation/src/vault.rs +++ b/ublue/skillet/crates/workstation/src/vault.rs @@ -45,6 +45,7 @@ pub struct Vault { original: Vec, database: Database, password: String, + password_cached: bool, } impl Vault { @@ -72,6 +73,7 @@ impl Vault { original, database, password: cached, + password_cached: true, }); } cache_clear(&path)?; @@ -79,15 +81,21 @@ impl Vault { let password = rpassword::prompt_password("KeePassXC database password: ") .map_err(|error| source("reading database password from terminal", error))?; let database = open_with_password(&original, &password, key_file)?; - cache_store(&path, &password)?; + let password_cached = cache_store(&path, &password)?; Ok(Self { path, original, database, password, + password_cached, }) } + /// Whether the verified password is currently stored in the session keyring. + pub fn password_cached(&self) -> bool { + self.password_cached + } + pub fn get(&self, path: &str) -> Result, VaultError> { lookup(&self.database, path) } @@ -425,25 +433,26 @@ fn cache_read(database: &Path) -> Result, VaultError> { .map_err(|error| source("decoding cached vault password", error)) } -fn cache_store(database: &Path, password: &str) -> Result<(), VaultError> { +fn cache_store(database: &Path, password: &str) -> Result { let mut ring = match session_keyring() { Ok(ring) => ring, Err(error) => { eprintln!("Vault password was not cached: Linux session keyring unavailable: {error}"); - return Ok(()); + return Ok(false); } }; let Ok(mut key) = ring.add_key::(cache_description(database), password.as_bytes()) else { eprintln!("Vault password was not cached: could not add a kernel key"); - return Ok(()); + return Ok(false); }; if key.set_timeout(CACHE_LIFETIME).is_err() { ring.unlink_key(&key) .map_err(|error| source("removing a vault key whose expiry could not be set", error))?; eprintln!("Vault password was not cached: kernel denied the three-hour expiry"); + return Ok(false); } - Ok(()) + Ok(true) } fn cache_clear(database: &Path) -> Result<(), VaultError> { diff --git a/ublue/skillet/crates/workstation/src/vault/tests.rs b/ublue/skillet/crates/workstation/src/vault/tests.rs index a47782f6..c32556ef 100644 --- a/ublue/skillet/crates/workstation/src/vault/tests.rs +++ b/ublue/skillet/crates/workstation/src/vault/tests.rs @@ -99,6 +99,7 @@ fn verified_save_updates_symlink_target_and_keeps_recovery_copy() { original, database, password: password.to_string(), + password_cached: false, }; let entry_path = "skillet/hosts/clamps/pihole/web-password"; vault.insert(entry_path, "generated test value").unwrap(); @@ -139,6 +140,7 @@ fn verified_save_refuses_a_concurrent_vault_change() { original, database, password: password.to_string(), + password_cached: false, }; vault.insert("skillet/new-token", "dummy").unwrap(); std::fs::write(&path, b"external update").unwrap(); @@ -152,10 +154,10 @@ fn session_password_cache_uses_three_hour_expiry_and_can_be_cleared() { assert_eq!(CACHE_LIFETIME, std::time::Duration::from_hours(3)); let directory = tempfile::tempdir().unwrap(); let database = directory.path().join("secrets.kdbx"); - cache_store(&database, "dummy vault password").unwrap(); + let cached = cache_store(&database, "dummy vault password").unwrap(); assert_eq!( cache_read(&database).unwrap().as_deref(), - Some("dummy vault password") + cached.then_some("dummy vault password") ); cache_clear(&database).unwrap(); assert_eq!(cache_read(&database).unwrap(), None); From 6177483d7c0853b2409d32a12d9245e232d79ea3 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 16:12:41 +0200 Subject: [PATCH 183/252] docs(skillet): record clamps smoke acceptance --- ublue/butane/ACCEPTANCE.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 06b9b840..1e5095b4 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1394,3 +1394,20 @@ host tool; it still uses normal command approvals. and Podman removed the disposable container after its stop timeout. - Interactive validation against the user's vault was not run because the password must be entered by the user. + +### Retained clamps smoke VM readiness and fixture, 2026-10-04 + +- The retained `clamps-test-smoke` VM was running but its local run state was + not marked ready. The first readiness attempt reached signed boot and base + apply, then found `uv` missing from the cloned dotfiles Brewfile. Installed + the missing dependency inside the disposable VM with its existing Brewfile; + no workstation packages were installed. +- Retried `cargo run --offline --bin skillet -- test vm ready clamps smoke`; + it passed signed boot, base apply, Homebrew bundle, and user-environment + checks. `cargo run --offline --bin skillet -- test smoke clamps` then passed + all fixture cases, including its reboot phase. Guest snapshots and journals + are under `/var/lib/skillet-smoke/`; the VM remains running for inspection. +- Vault-backed Caddy/Cloudflare provisioning was not attempted. Although the + user unlocked the vault interactively, the assistant execution process could + not read that session keyring entry and has no interactive password input. + No provider mutations were made by this attempt. From 103167b206d22a880d23ed274f87f568129d2a49 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 17:33:21 +0200 Subject: [PATCH 184/252] fix(vm): handle fresh host provisioning prerequisites --- .../skillet/crates/cli/src/secret_delivery.rs | 14 ++++---- ublue/skillet/crates/vm/src/staging.rs | 34 ++++++++++++++++++- ublue/skillet/crates/vm/src/staging_tests.rs | 23 +++++++++++++ .../workstation/src/tailscale_enrollment.rs | 19 +++++++++++ .../src/tailscale_enrollment/tests.rs | 33 ++++++++++++++++++ 5 files changed, 116 insertions(+), 7 deletions(-) diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index b308a293..c1f67530 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -204,6 +204,14 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let credentials = tailscale_credentials(&vault)?; let expected_hostname = run.guest_hostname.as_str(); let policy = UiEnvironmentName::Test.policy(); + // The host full-apply unit requires both encrypted credentials. Deliver + // Pi-hole first; Tailscale delivery then starts full apply with the full + // credential set, which creates the Tailscale container on a fresh VM. + skillet_workstation::credential_delivery::ensure_disposable_pihole_credential( + &args.hostname, + args.rotate, + &transport, + )?; let record = skillet_workstation::tailscale_enrollment::enroll_disposable_vm( &skillet_workstation::tailscale_enrollment::DisposableEnrollment { host: &args.hostname, @@ -216,12 +224,6 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { &transport, )?; - skillet_workstation::credential_delivery::ensure_disposable_pihole_credential( - &args.hostname, - args.rotate, - &transport, - )?; - if args.with_ui { provision_vm_ui(args, &run_dir, &transport, &mut vault, &record)?; } diff --git a/ublue/skillet/crates/vm/src/staging.rs b/ublue/skillet/crates/vm/src/staging.rs index df46e712..f54670f8 100644 --- a/ublue/skillet/crates/vm/src/staging.rs +++ b/ublue/skillet/crates/vm/src/staging.rs @@ -208,7 +208,39 @@ fn path_value(path: &Path) -> Result { fn parse_yaml(path: &Path) -> Result { reject_symlinks(path)?; let contents = fs::read_to_string(path)?; - serde_yml::from_str(&contents).map_err(|error| Error::Preparation(error.to_string())) + let mut value: Value = + serde_yml::from_str(&contents).map_err(|error| Error::Preparation(error.to_string()))?; + normalize_butane_modes(&mut value)?; + Ok(value) +} + +/// Butane requires file modes to be numeric. `serde_yml` parses YAML values such +/// as `0644` as strings, so convert octal mode strings before writing staged +/// configs back out. +fn normalize_butane_modes(value: &mut Value) -> Result<()> { + match value { + Value::Mapping(mapping) => { + for (key, value) in mapping.iter_mut() { + if key.as_str() == Some("mode") { + if let Some(mode) = value.as_str() { + let mode = u64::from_str_radix(mode, 8).map_err(|_| { + Error::Invalid(format!("invalid octal Butane file mode: {mode}")) + })?; + *value = Value::Number(mode.into()); + } + } else { + normalize_butane_modes(value)?; + } + } + } + Value::Sequence(values) => { + for value in values { + normalize_butane_modes(value)?; + } + } + _ => {} + } + Ok(()) } fn write_yaml_if_changed(path: &Path, value: &Value) -> Result<()> { diff --git a/ublue/skillet/crates/vm/src/staging_tests.rs b/ublue/skillet/crates/vm/src/staging_tests.rs index a8552b72..d8b2c99f 100644 --- a/ublue/skillet/crates/vm/src/staging_tests.rs +++ b/ublue/skillet/crates/vm/src/staging_tests.rs @@ -150,6 +150,29 @@ fn staging_specializes_only_the_owned_copy_and_hashes_artifacts() { .contains("inline: clamps")); } +#[test] +fn staging_converts_octal_mode_strings_to_butane_numbers() { + let (_temp, store, run, config, includes, host_binary, generic_binary, image) = fixture(); + fs::write( + &config, + "variant: fcos\nversion: 1.6.0\nstorage:\n files:\n - path: /etc/hostname\n mode: '0644'\n contents:\n inline: clamps\n", + ) + .unwrap(); + stage_butane_source( + &store, + &run, + &config, + &includes, + &host_binary, + &generic_binary, + &image, + ) + .unwrap(); + let staged = store.run_dir(&run.identity).join("source/clamps.bu"); + let yaml: Value = serde_yml::from_str(&fs::read_to_string(staged).unwrap()).unwrap(); + assert_eq!(yaml["storage"]["files"][0]["mode"].as_u64(), Some(0o644)); +} + #[test] fn staging_is_idempotent_and_refuses_progressed_runs_or_linked_sources() { let (_temp, store, run, config, includes, host_binary, generic_binary, image) = fixture(); diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs index 3523a3ec..14d5c902 100644 --- a/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs @@ -198,6 +198,25 @@ pub fn cleanup_disposable_vm( } fn guest_addresses(guest: &impl GuestTransport) -> Result, EnrollmentError> { + let container = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "podman", "container", "exists", "tailscale"], + }, + None, + )?; + if container.status.code() == Some(1) { + // A newly created VM has not run full apply yet. Absence is a normal + // unenrolled state: credential installation starts full apply, which + // creates and enrolls the Tailscale container. + return Ok(BTreeSet::new()); + } + if !container.status.success() { + return Err(EnrollmentError::Invalid(format!( + "could not determine whether the Tailscale container exists (status {}); refusing to treat probe failure as an unenrolled VM", + container.status + ))); + } let output = guest.execute( &skillet_vm::transport::GuestCommand { program: "/usr/bin/sudo", diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs index d7f983ab..362284fd 100644 --- a/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs @@ -67,6 +67,7 @@ impl EnrollmentProvider for FakeProvider { struct FakeGuest { statuses: Mutex>, installs: Mutex>, + container_exists: bool, } type CapturedInstall = (Vec, Option>); @@ -76,6 +77,15 @@ impl FakeGuest { Self { statuses: Mutex::new(statuses.into_iter().collect()), installs: Mutex::new(Vec::new()), + container_exists: true, + } + } + + fn without_tailscale_container(statuses: impl IntoIterator) -> Self { + Self { + statuses: Mutex::new(statuses.into_iter().collect()), + installs: Mutex::new(Vec::new()), + container_exists: false, } } } @@ -86,6 +96,16 @@ impl GuestTransport for FakeGuest { command: &skillet_vm::transport::GuestCommand<'_>, input: Option<&[u8]>, ) -> skillet_vm::Result { + if command + .arguments + .ends_with(&["container", "exists", "tailscale"]) + { + let created_after_credential_delivery = !self.installs.lock().unwrap().is_empty(); + return Ok(output( + self.container_exists || created_after_credential_delivery, + Vec::new(), + )); + } if command.arguments.contains(&"credential") { self.installs.lock().unwrap().push(( command @@ -209,6 +229,19 @@ fn unenrolled_vm_receives_auth_key_before_device_lookup_and_state_is_saved() { assert!(run.path().join("tailscale.json").is_file()); } +#[test] +fn fresh_vm_without_tailscale_container_is_enrolled_after_credential_delivery() { + let run = tempfile::tempdir().unwrap(); + let guest = FakeGuest::without_tailscale_container([status("Running", &["100.64.0.10"])]); + let provider = FakeProvider::default(); + + enroll_disposable_vm(&request(run.path()), &provider, &guest).unwrap(); + + assert_eq!(provider.keys_created.get(), 1); + assert_eq!(provider.devices_found.get(), 1); + assert_eq!(guest.installs.lock().unwrap().len(), 1); +} + #[test] fn failed_guest_status_keeps_recovery_marker_and_does_not_issue_key() { let run = tempfile::tempdir().unwrap(); From 5ab6e050dab945e8b9a5bdead61adda8f1e0b73c Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 17:33:32 +0200 Subject: [PATCH 185/252] docs: record fresh VM acceptance and shared zone path --- ublue/AGENTS.md | 6 ++++++ ublue/butane/ACCEPTANCE.md | 28 +++++++++++++++++++++++++ ublue/design/cloudflare-ui-lifecycle.md | 6 +++--- ublue/design/secrets.md | 2 +- ublue/plan/GENERIC-PRIVATE-UIS.md | 10 ++++++++- ublue/skillet/README.md | 2 +- 6 files changed, 48 insertions(+), 6 deletions(-) diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index b2847a08..89672f7a 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -44,6 +44,12 @@ - Give each resource lifecycle one owner. Every public entry point must use the same ownership validation, recovery, and cleanup policy; transport adapters must not provide a shortcut that bypasses those guarantees. +- Preserve downstream schema types when parsing and re-emitting configuration. + Validate staged output with the actual consumer/compiler where possible, not + only with a parser round trip. +- Before activating a unit with multiple credential prerequisites, deliver the + complete credential set first. Cover activation from a fresh host where the + consumer service and its containers do not yet exist. - Keep shell entry points as narrow argument adapters when Rust owns a lifecycle. Backend selection, resource inspection, retries, and cleanup belong to the shared Rust owner rather than being duplicated in wrappers. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 1e5095b4..24cfc93f 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,33 @@ # Clamps VM acceptance log +## Refactoring and disposable UI revalidation, 2026-10-04 + +- Passed `cargo fmt --all -- --check`, strict workspace Clippy, and + `cargo test --workspace --all-targets --offline`. The CI Fedora repeat-apply + command passed: `cargo run --offline --bin skillet -- test run beezelbot + --phase base --image fedora:latest`. +- A fresh `clamps-test-generic-ui` VM was created on SSH port 2202 and reached + `Ready` through signed uCore boot, bootstrap, and user-environment checks. + `cargo run --offline --bin skillet -- test smoke clamps --instance + generic-ui` passed its real systemd/Podman apply, failure-recovery, and + reboot-persistence cases. `test vm destroy clamps generic-ui` removed the + disposable VM and its recorded Tailscale device. +- Live provisioning exposed two fresh-host issues and they are fixed in code: + staged Butane `mode` values are normalized to numeric values before compile, + and Pi-hole credentials are delivered before Tailscale so the full-apply + unit receives its complete credential prerequisites. Tailscale enrollment + now treats a confirmed absent container as unenrolled while keeping other + Podman probe failures fail-closed. Unit coverage and all local checks pass. + A credential-empty fresh VM has not yet completed that entire enrollment + sequence end-to-end; the live run reached Cloudflare after retrying a VM with + partially delivered credentials. +- The latest `test vm provision clamps generic-ui --with-ui` run enrolled the + VM in Tailscale but stopped before Cloudflare mutation because KeePassXC + lacks `skillet/environments/dns/cloudflare-zone-id`. The disposable VM and + its Tailscale device were cleaned up. Current live Caddy ACME/HTTPS + acceptance is blocked until that shared zone entry is present; no production + DNS or credentials were changed. + ## Recoverable VM creation intent, 2026-10-03 - Creation now writes a mode-0600 Rust manifest with a generated domain UUID diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index 0b2987c0..7ff65e88 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -14,9 +14,9 @@ remains confined to child tokens. ## Configuration and access -Each environment requires `skillet/environments//dns/cloudflare-zone-id` -in the vault's Password field. Fetch that exact zone's domain from Cloudflare. -The optional `skillet/environments//dns/ui-domain` is a relative +The shared zone requires `skillet/environments/dns/cloudflare-zone-id` in the +vault's Password field. Fetch that exact zone's domain from Cloudflare. Each +environment's optional `skillet/environments//dns/ui-domain` is a relative prefix, defaulting to `ui` only when absent. Always append the zone's domain: `ui.whatever` in zone `example.com` resolves to `ui.whatever.example.com`. This removes redundant full-domain configuration and keeps namespaces within diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index d331794e..f2a0ac42 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -56,7 +56,7 @@ The prefix is consistently singular, `skillet`: | --- | --- | | `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | | `skillet/environments//dns/ui-domain` | Optional relative UI prefix; defaults to `ui`, appended to the fetched zone domain | -| `skillet/environments//dns/cloudflare-zone-id` | Cloudflare Zone ID lookup | +| `skillet/environments/dns/cloudflare-zone-id` | Shared Cloudflare Zone ID lookup | | `skillet/environments//hosts//cloudflare/acme-token` | Persistent `cloudflare_acme_token`, created on first Caddy delivery | | `skillet/hosts//cloudflare/acme-token` | Legacy production credential migrated on first delivery | | `skillet/cloudflare/token-creator` | Workstation only | diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 653b7bab..ae3556df 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -14,6 +14,14 @@ Caddy's explicit 403 response. Remaining: denial from a separate network outside the tailnet and production ACME/renewal acceptance; these are grouped with Pi-hole LAN DNS acceptance in the [deferred live acceptance batch](CLAMPS-MIGRATION.md#deferred-live-acceptance-batch). +Current revalidation (2026-10-04): the fresh disposable VM and real-runtime +smoke passed and were cleaned up. The latest UI provisioning retry reached the +Cloudflare setup but found no `skillet/environments/dns/cloudflare-zone-id` +entry in the unlocked vault, so no Cloudflare mutation occurred. Resume live +ACME/HTTPS acceptance after adding that shared Zone ID. First-boot Tailscale +credential activation has unit coverage, but its full credential-empty live +sequence remains unverified. + ## Goal and fixed decisions Every host declares its UI services. Shared provisioning derives each public @@ -39,9 +47,9 @@ the Password field, following the existing exact-lookup convention: | Entry | Purpose | | --- | --- | | `skillet/environments//dns/ui-domain` | Optional relative prefix; absent means `ui` | -| `skillet/environments//dns/cloudflare-zone-id` | Cloudflare Zone ID of the authorized existing zone | | `skillet/environments//hosts//cloudflare/acme-token` | Durable token for a persistent host | | `skillet/cloudflare/token-creator` | Workstation-only token issuer | +| `skillet/environments/dns/cloudflare-zone-id` | Shared Cloudflare Zone ID of the authorized existing zone | Production and test are environment names, not distinct implementations. Fetch the mandatory Zone ID's domain from Cloudflare and always append it to diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 0bb556dd..ed4d5f5f 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -217,7 +217,7 @@ KeePassXC entries in their Password fields: | Group path | Entry title | Value | | --- | --- | --- | | `skillet/environments//dns` | `ui-domain` | Optional relative prefix; defaults to `ui` when absent | -| `skillet/environments//dns` | `cloudflare-zone-id` | Required Cloudflare Zone ID | +| `skillet/environments/dns` | `cloudflare-zone-id` | Required shared Cloudflare Zone ID | | `skillet/cloudflare` | `token-creator` | Workstation token issuer | Select `production` or `test` for ``. Copy the Zone ID from the From 43cfe66c0b6659357f67c83990b01c9894a977d9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 17:47:57 +0200 Subject: [PATCH 186/252] fix(workstation): scope Cloudflare zone by environment --- ublue/butane/ACCEPTANCE.md | 2 +- ublue/design/cloudflare-ui-lifecycle.md | 6 +++--- ublue/design/secrets.md | 2 +- ublue/plan/GENERIC-PRIVATE-UIS.md | 6 +++--- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 3 ++- ublue/skillet/README.md | 2 +- ublue/skillet/crates/cli/src/secret_delivery.rs | 14 ++++++-------- .../crates/workstation/src/provisioning_policy.rs | 10 ++++++++-- .../workstation/src/provisioning_policy/tests.rs | 6 +++++- 9 files changed, 30 insertions(+), 21 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 24cfc93f..2e5d041f 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -23,7 +23,7 @@ partially delivered credentials. - The latest `test vm provision clamps generic-ui --with-ui` run enrolled the VM in Tailscale but stopped before Cloudflare mutation because KeePassXC - lacks `skillet/environments/dns/cloudflare-zone-id`. The disposable VM and + lacks `skillet/environments/test/dns/cloudflare-zone-id`. The disposable VM and its Tailscale device were cleaned up. Current live Caddy ACME/HTTPS acceptance is blocked until that shared zone entry is present; no production DNS or credentials were changed. diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index 7ff65e88..d8c06012 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -14,9 +14,9 @@ remains confined to child tokens. ## Configuration and access -The shared zone requires `skillet/environments/dns/cloudflare-zone-id` in the -vault's Password field. Fetch that exact zone's domain from Cloudflare. Each -environment's optional `skillet/environments//dns/ui-domain` is a relative +Each environment requires `skillet/environments//dns/cloudflare-zone-id` +in the vault's Password field (`prod` or `test`). Fetch that exact zone's domain +from Cloudflare. Its optional `skillet/environments//dns/ui-domain` is a relative prefix, defaulting to `ui` only when absent. Always append the zone's domain: `ui.whatever` in zone `example.com` resolves to `ui.whatever.example.com`. This removes redundant full-domain configuration and keeps namespaces within diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index f2a0ac42..6024c5af 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -56,7 +56,7 @@ The prefix is consistently singular, `skillet`: | --- | --- | | `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | | `skillet/environments//dns/ui-domain` | Optional relative UI prefix; defaults to `ui`, appended to the fetched zone domain | -| `skillet/environments/dns/cloudflare-zone-id` | Shared Cloudflare Zone ID lookup | +| `skillet/environments//dns/cloudflare-zone-id` | Environment-specific Cloudflare Zone ID lookup (`prod` or `test`) | | `skillet/environments//hosts//cloudflare/acme-token` | Persistent `cloudflare_acme_token`, created on first Caddy delivery | | `skillet/hosts//cloudflare/acme-token` | Legacy production credential migrated on first delivery | | `skillet/cloudflare/token-creator` | Workstation only | diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index ae3556df..c79d35c4 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -16,9 +16,9 @@ with Pi-hole LAN DNS acceptance in the [deferred live acceptance batch](CLAMPS-M Current revalidation (2026-10-04): the fresh disposable VM and real-runtime smoke passed and were cleaned up. The latest UI provisioning retry reached the -Cloudflare setup but found no `skillet/environments/dns/cloudflare-zone-id` +Cloudflare setup but found no `skillet/environments/test/dns/cloudflare-zone-id` entry in the unlocked vault, so no Cloudflare mutation occurred. Resume live -ACME/HTTPS acceptance after adding that shared Zone ID. First-boot Tailscale +ACME/HTTPS acceptance after adding that test Zone ID. First-boot Tailscale credential activation has unit coverage, but its full credential-empty live sequence remains unverified. @@ -49,7 +49,7 @@ the Password field, following the existing exact-lookup convention: | `skillet/environments//dns/ui-domain` | Optional relative prefix; absent means `ui` | | `skillet/environments//hosts//cloudflare/acme-token` | Durable token for a persistent host | | `skillet/cloudflare/token-creator` | Workstation-only token issuer | -| `skillet/environments/dns/cloudflare-zone-id` | Shared Cloudflare Zone ID of the authorized existing zone | +| `skillet/environments//dns/cloudflare-zone-id` | Environment-specific Cloudflare Zone ID of the authorized existing zone (`prod` or `test`) | Production and test are environment names, not distinct implementations. Fetch the mandatory Zone ID's domain from Cloudflare and always append it to diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 03fd5ad4..0b2cfd6c 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -110,7 +110,8 @@ and VM delivery have duplicate implementations with different validation. versus test environment names and vault paths, ACME staging, Cloudflare credential lifetimes, cleanup-token lifetime, and Tailscale device tags. Delivery and cleanup consume this policy; the Cloudflare zone ID uses the - shared `skillet/environments/dns/cloudflare-zone-id` vault entry. + environment-specific `skillet/environments//dns/cloudflare-zone-id` + vault entry. - Implemented: production credential delivery and disposable-VM provisioning use `skillet_vm::SshTransport` with verified recorded host keys and literal executable arguments. Caddy activation, VM status probes, and the non-tailnet diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index ed4d5f5f..7f565be8 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -217,7 +217,7 @@ KeePassXC entries in their Password fields: | Group path | Entry title | Value | | --- | --- | --- | | `skillet/environments//dns` | `ui-domain` | Optional relative prefix; defaults to `ui` when absent | -| `skillet/environments/dns` | `cloudflare-zone-id` | Required shared Cloudflare Zone ID | +| `skillet/environments//dns` | `cloudflare-zone-id` | Required Zone ID for `prod` or `test` | | `skillet/cloudflare` | `token-creator` | Workstation token issuer | Select `production` or `test` for ``. Copy the Zone ID from the diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index c1f67530..de408641 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -76,7 +76,7 @@ fn deliver_caddy_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Resu let domain_prefix = vault.get(&domain_path)?; let zone_path = policy.cloudflare_zone_entry(); let zone_id = vault - .get(zone_path)? + .get(&zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; let zone_id = zone_id.trim().to_string(); let creator = vault @@ -291,7 +291,7 @@ fn provision_vm_ui( let zone_path = policy.cloudflare_zone_entry(); let domain_prefix = vault.get(&domain_path)?; let zone_id = vault - .get(zone_path)? + .get(&zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; let creator = vault .get("skillet/cloudflare/token-creator")? @@ -342,12 +342,10 @@ fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<( "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" ) })?; - let configured_zone = vault.get(policy.cloudflare_zone_entry())?.ok_or_else(|| { - anyhow!( - "KeePassXC Cloudflare zone entry is missing: {}", - policy.cloudflare_zone_entry() - ) - })?; + let zone_path = policy.cloudflare_zone_entry(); + let configured_zone = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; let api = skillet_workstation::cloudflare::Cloudflare::new(); let configured_prefix = vault.get(&policy.ui_domain_entry())?; skillet_workstation::ui_provisioning::cleanup_disposable_ui( diff --git a/ublue/skillet/crates/workstation/src/provisioning_policy.rs b/ublue/skillet/crates/workstation/src/provisioning_policy.rs index eb228f62..b77f9c10 100644 --- a/ublue/skillet/crates/workstation/src/provisioning_policy.rs +++ b/ublue/skillet/crates/workstation/src/provisioning_policy.rs @@ -37,8 +37,14 @@ impl ProvisioningPolicy { format!("skillet/environments/{}/dns/ui-domain", self.name()) } - pub fn cloudflare_zone_entry(self) -> &'static str { - "skillet/environments/dns/cloudflare-zone-id" + pub fn cloudflare_zone_entry(self) -> String { + format!( + "skillet/environments/{}/dns/cloudflare-zone-id", + match self.environment { + Environment::Production => "prod", + Environment::Test => "test", + } + ) } pub const fn acme_staging(self) -> bool { diff --git a/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs b/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs index f69580d8..9ad90761 100644 --- a/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs +++ b/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs @@ -12,7 +12,7 @@ fn production_policy_selects_durable_credentials_and_live_acme() { ); assert_eq!( policy.cloudflare_zone_entry(), - "skillet/environments/dns/cloudflare-zone-id" + "skillet/environments/prod/dns/cloudflare-zone-id" ); assert_eq!(policy.cloudflare_token_lifetime(), None); assert!(!policy.acme_staging()); @@ -31,6 +31,10 @@ fn disposable_policy_selects_short_lived_credentials_and_staging_acme() { policy.ui_domain_entry(), "skillet/environments/test/dns/ui-domain" ); + assert_eq!( + policy.cloudflare_zone_entry(), + "skillet/environments/test/dns/cloudflare-zone-id" + ); assert_eq!( policy.cloudflare_token_lifetime(), Some(Duration::from_hours(12)) From 72f9d4cbda9d13c94f73c21037e42ea1e2d27a02 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 17:50:12 +0200 Subject: [PATCH 187/252] fix(workstation): use prod and test vault groups --- ublue/design/cloudflare-ui-lifecycle.md | 2 +- ublue/design/secrets.md | 2 ++ ublue/plan/GENERIC-PRIVATE-UIS.md | 4 ++-- ublue/skillet/README.md | 3 ++- .../crates/workstation/src/provisioning_policy.rs | 14 +++++++++----- .../workstation/src/provisioning_policy/tests.rs | 4 +++- .../crates/workstation/src/ui_provisioning.rs | 2 +- .../workstation/src/ui_provisioning/tests.rs | 6 +++--- 8 files changed, 23 insertions(+), 14 deletions(-) diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md index d8c06012..b513db94 100644 --- a/ublue/design/cloudflare-ui-lifecycle.md +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -15,7 +15,7 @@ remains confined to child tokens. ## Configuration and access Each environment requires `skillet/environments//dns/cloudflare-zone-id` -in the vault's Password field (`prod` or `test`). Fetch that exact zone's domain +in the vault's Password field (`prod` or `test`; CLI `production` maps to `prod`). Fetch that exact zone's domain from Cloudflare. Its optional `skillet/environments//dns/ui-domain` is a relative prefix, defaulting to `ui` only when absent. Always append the zone's domain: `ui.whatever` in zone `example.com` resolves to `ui.whatever.example.com`. diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 6024c5af..b1180ad6 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -51,6 +51,8 @@ The Cloudflare token creator is stored at `skillet/cloudflare/token-creator`. Vault paths are group paths plus an entry title; values use the Password field. The prefix is consistently singular, `skillet`: +KeePassXC environment groups are named `prod` and `test`; the CLI's `production` +policy maps to the `prod` group. | Vault entry | Host credential and Podman secret name | | --- | --- | diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index c79d35c4..33f71238 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -46,8 +46,8 @@ the Password field, following the existing exact-lookup convention: | Entry | Purpose | | --- | --- | -| `skillet/environments//dns/ui-domain` | Optional relative prefix; absent means `ui` | -| `skillet/environments//hosts//cloudflare/acme-token` | Durable token for a persistent host | +| `skillet/environments//dns/ui-domain` | Optional relative prefix; absent means `ui` (`` is `prod` or `test`) | +| `skillet/environments//hosts//cloudflare/acme-token` | Durable token for a persistent host (`` is `prod` or `test`) | | `skillet/cloudflare/token-creator` | Workstation-only token issuer | | `skillet/environments//dns/cloudflare-zone-id` | Environment-specific Cloudflare Zone ID of the authorized existing zone (`prod` or `test`) | diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 7f565be8..060cef0d 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -220,7 +220,8 @@ KeePassXC entries in their Password fields: | `skillet/environments//dns` | `cloudflare-zone-id` | Required Zone ID for `prod` or `test` | | `skillet/cloudflare` | `token-creator` | Workstation token issuer | -Select `production` or `test` for ``. Copy the Zone ID from the +Select `production` or `test` for the CLI environment; use `prod` or `test` as +the KeePassXC environment group name. Copy the Zone ID from the selected zone's Cloudflare Overview page. Skillet fetches the zone's domain and always appends it to the relative prefix. For zone `example.com`, an absent entry produces `ui.example.com`; `ui.whatever` produces diff --git a/ublue/skillet/crates/workstation/src/provisioning_policy.rs b/ublue/skillet/crates/workstation/src/provisioning_policy.rs index b77f9c10..46db81a2 100644 --- a/ublue/skillet/crates/workstation/src/provisioning_policy.rs +++ b/ublue/skillet/crates/workstation/src/provisioning_policy.rs @@ -33,17 +33,21 @@ impl ProvisioningPolicy { } } + pub const fn vault_name(self) -> &'static str { + match self.environment { + Environment::Production => "prod", + Environment::Test => "test", + } + } + pub fn ui_domain_entry(self) -> String { - format!("skillet/environments/{}/dns/ui-domain", self.name()) + format!("skillet/environments/{}/dns/ui-domain", self.vault_name()) } pub fn cloudflare_zone_entry(self) -> String { format!( "skillet/environments/{}/dns/cloudflare-zone-id", - match self.environment { - Environment::Production => "prod", - Environment::Test => "test", - } + self.vault_name() ) } diff --git a/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs b/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs index 9ad90761..c5251806 100644 --- a/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs +++ b/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs @@ -6,9 +6,10 @@ fn production_policy_selects_durable_credentials_and_live_acme() { let policy = ProvisioningPolicy::new(Environment::Production); assert_eq!(policy.name(), "production"); + assert_eq!(policy.vault_name(), "prod"); assert_eq!( policy.ui_domain_entry(), - "skillet/environments/production/dns/ui-domain" + "skillet/environments/prod/dns/ui-domain" ); assert_eq!( policy.cloudflare_zone_entry(), @@ -27,6 +28,7 @@ fn disposable_policy_selects_short_lived_credentials_and_staging_acme() { let policy = ProvisioningPolicy::new(Environment::Test); assert_eq!(policy.name(), "test"); + assert_eq!(policy.vault_name(), "test"); assert_eq!( policy.ui_domain_entry(), "skillet/environments/test/dns/ui-domain" diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs index e1a7a9b7..ca1a9686 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -370,7 +370,7 @@ pub fn deliver_persistent_ui( let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?.to_string(); let token_path = format!( "skillet/environments/{}/hosts/{}/cloudflare/acme-token", - request.policy.name(), + request.policy.vault_name(), request.host ); let token_name = format!("skillet:{}:{}", request.policy.name(), request.host); diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index a3021071..79cad3ad 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -575,7 +575,7 @@ fn persistent_ui_reuses_token_and_defers_caddy_until_credential_set_is_installed let cloudflare = fake_cloudflare(run.path(), false); let mut store = FakeTokenStore::default(); store.entries.borrow_mut().insert( - "skillet/environments/production/hosts/clamps/cloudflare/acme-token".into(), + "skillet/environments/prod/hosts/clamps/cloudflare/acme-token".into(), "existing ACME token".into(), ); let guest = FakeGuest::default(); @@ -630,7 +630,7 @@ fn persistent_ui_mints_and_persists_token_before_dns_reconciliation() { store .entries .borrow() - .get("skillet/environments/production/hosts/clamps/cloudflare/acme-token") + .get("skillet/environments/prod/hosts/clamps/cloudflare/acme-token") .map(String::as_str), Some("persistent dummy token") ); @@ -661,7 +661,7 @@ fn persistent_ui_migrates_legacy_production_token_without_reissuing_it() { store .entries .borrow() - .get("skillet/environments/production/hosts/clamps/cloudflare/acme-token") + .get("skillet/environments/prod/hosts/clamps/cloudflare/acme-token") .map(String::as_str), Some("legacy ACME token") ); From 06f49890c7ffb5c313e7f81f523459e948c747b8 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 4 Oct 2026 21:03:47 +0200 Subject: [PATCH 188/252] docs: record successful test ACME acceptance --- ublue/butane/ACCEPTANCE.md | 20 +++++++++++--------- ublue/plan/GENERIC-PRIVATE-UIS.md | 15 ++++++++------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 2e5d041f..b007eb40 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -18,15 +18,17 @@ unit receives its complete credential prerequisites. Tailscale enrollment now treats a confirmed absent container as unenrolled while keeping other Podman probe failures fail-closed. Unit coverage and all local checks pass. - A credential-empty fresh VM has not yet completed that entire enrollment - sequence end-to-end; the live run reached Cloudflare after retrying a VM with - partially delivered credentials. -- The latest `test vm provision clamps generic-ui --with-ui` run enrolled the - VM in Tailscale but stopped before Cloudflare mutation because KeePassXC - lacks `skillet/environments/test/dns/cloudflare-zone-id`. The disposable VM and - its Tailscale device were cleaned up. Current live Caddy ACME/HTTPS - acceptance is blocked until that shared zone entry is present; no production - DNS or credentials were changed. +- The corrected environment paths were exercised from a fresh credential-empty + VM. `test vm provision clamps generic-ui --with-ui` completed using the test + KeePass group. Caddy's guest journal confirmed successful certificate + issuance from `acme-staging-v02.api.letsencrypt.org-directory` for all three + declared UI names. The provisioning probes also received the configured 403 + tailnet-denial response over HTTPS for the UI names. +- `test vm destroy clamps generic-ui` completed Cloudflare ownership cleanup, + removed the disposable Tailscale device, and deleted the VM. No production + DNS or credentials were changed. A separate tailnet-client URL check, + cryptographic SAN/chain verification, and denial from a separate outside + network remain unverified; staging certificates are intentionally untrusted. ## Recoverable VM creation intent, 2026-10-03 diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 33f71238..3f00a724 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -14,13 +14,14 @@ Caddy's explicit 403 response. Remaining: denial from a separate network outside the tailnet and production ACME/renewal acceptance; these are grouped with Pi-hole LAN DNS acceptance in the [deferred live acceptance batch](CLAMPS-MIGRATION.md#deferred-live-acceptance-batch). -Current revalidation (2026-10-04): the fresh disposable VM and real-runtime -smoke passed and were cleaned up. The latest UI provisioning retry reached the -Cloudflare setup but found no `skillet/environments/test/dns/cloudflare-zone-id` -entry in the unlocked vault, so no Cloudflare mutation occurred. Resume live -ACME/HTTPS acceptance after adding that test Zone ID. First-boot Tailscale -credential activation has unit coverage, but its full credential-empty live -sequence remains unverified. +Current revalidation (2026-10-04): the fresh disposable VM, real-runtime smoke, +credential-empty Tailscale enrollment, test Cloudflare lifecycle, and Caddy +staging certificate issuance passed; `test vm destroy` cleaned the test DNS, +token, Tailscale device, and VM. The issuer was confirmed in Caddy's guest +journal for every declared UI name, and HTTPS tailnet-denial probes passed. +Tailnet-client URL/DNS resolution, cryptographic certificate SAN/chain +verification, separate-network denial, and production ACME/renewal remain +unverified. ## Goal and fixed decisions From 5cd2e22d3658d2d38629bea725ccdd463068a7d9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 00:00:50 +0200 Subject: [PATCH 189/252] docs(skillet): reconcile refactor acceptance status --- ublue/butane/ACCEPTANCE.md | 64 +++++++++++++++++++ ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 17 +++-- ublue/plan/REFACTOR-02-HOST-PROFILES.md | 12 ++-- ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md | 14 ++-- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 15 +++-- ublue/plan/REFACTOR-05-PODMAN-MODEL.md | 13 ++-- .../plan/REFACTOR-06-CONVERGENCE-RECORDING.md | 10 +-- ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md | 19 +++--- ublue/plan/SKILLET-REFACTOR.md | 38 ++++++----- 9 files changed, 150 insertions(+), 52 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index b007eb40..6a9afc57 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1441,3 +1441,67 @@ host tool; it still uses normal command approvals. user unlocked the vault interactively, the assistant execution process could not read that session keyring entry and has no interactive password input. No provider mutations were made by this attempt. + +### Fresh disposable UI and roadmap acceptance, 2026-10-04 + +- After the environment-scoped vault paths were corrected, a fresh + `clamps-test-generic-ui` VM completed create, signed readiness, and + `test vm provision clamps generic-ui --with-ui`. Provisioning used the test + vault entries, enrolled the disposable VM in Tailscale, reconciled owned + Cloudflare DNS, delivered credentials, and activated Caddy. +- Guest Caddy logs confirmed successful Let's Encrypt staging ACME issuance + for every declared UI name. The in-guest HTTPS loopback denial probes returned + the expected 403 for each name. The fixture smoke passed, including its + failure-recovery and reboot cases. Destroy removed the journaled DNS/token + and Tailscale resources before deleting the VM. No production credential or + production issuer was used. +- Local validation on this runner: `cargo fmt --all -- --check`, strict + all-target Clippy, and all workspace/all-target tests (244 passed) passed for + the configured musl target. The CI integration command + `cargo run --bin skillet -- test run beezelbot --phase base --image + fedora:latest` passed both applies; repeat apply caused no service + start/restart. Workflow-wide ShellCheck and + `bash ublue/butane/tests/bootstrap.sh` passed. A supplementary GNU-target + suite and integration also passed. The integration container needed SIGKILL + after its 10-second stop timeout. +- At this checkpoint, retained-host repeat apply, live process/network/storage + assertions, credential rotation, and post-rotation reboot were outstanding. + They were completed in the following retained-VM acceptance entry. Remaining + live gaps are listed there. + +### Retained clamps VM apply, storage, and repeat-apply acceptance, 2026-10-04 + +- Host-context verification confirmed `/dev/kvm` and the running retained + `clamps-test-smoke` VM. `test vm ready clamps smoke` passed, including signed + boot, captured-binary delivery, base apply, and user-environment checks. +- `test vm provision clamps smoke` completed twice after one manually + interrupted attempt. The successful retries enrolled/verified the test VM in + Tailscale, delivered Pi-hole credentials, and completed the declared clamps + application. No Caddy or Cloudflare operation was requested in this run. +- The fixture smoke passed after provisioning, including pre-reboot failure + recovery and post-reboot checks. `test vm status clamps smoke` confirmed the + recorded domain remained running and owned after the cycle. +- Guest inspection through `test vm ssh` confirmed `/var/lib/data` is the + Btrfs `data` subvolume mounted from the VM disk; Podman graph storage and + Pi-hole, Syncthing, Tailscale, and UniFi use the expected paths under it. + Pi-hole and Syncthing use the bridge network; Tailscale and UniFi use host + networking. UniFi's container user and data-root ownership resolved to the + declared numeric identity. All four service containers were running, and + UniFi reported healthy. +- A further full provisioning apply left the recorded Podman start timestamps + unchanged for all four service containers, confirming no restart on repeat. +- `test vm provision clamps smoke --rotate` succeeded. Pi-hole's container + restarted with the new generated test credential; Syncthing, Tailscale, and + UniFi start times did not change. A normal apply afterward left all four + start times unchanged. After another fixture reboot, all four services and + the fixture container were running, the encrypted Pi-hole credential file + was present, and `skillet-full-apply.service` reported `Result=success`. +- For the ownership check, created a temporary child with UID/GID `12345:12345` + and mode `0600`, then changed only its managed UniFi volume root to + `12345:12345`/`0700`. Host apply restored the volume root to `999:999`/`0750` + and preserved the child's UID/GID/mode. The temporary file was removed, + UniFi was healthy, and `systemctl --failed` was empty afterward. +- Remaining: a controlled interrupted-provisioning recovery with its journal + captured before and after, and a legacy-manifest live import (no legacy VM + run is available). Production delivery/renewal and outside-tailnet denial + remain separate deferred acceptance. diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index ac0334de..b486e859 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -1,7 +1,8 @@ # 1. One VM lifecycle owner; migrate Bash management to Rust -Status: in progress. First workstream in [the prerequisite roadmap](SKILLET-REFACTOR.md). -Further feature milestones wait for that roadmap's completion. +Status: implementation complete; fresh and retained VM acceptance passed. +Legacy-manifest live import and controlled interrupted-recovery acceptance +remain. See the [roadmap](SKILLET-REFACTOR.md) and 2026-10-04 acceptance entry. ## Progress, 2026-10-03 @@ -50,7 +51,10 @@ Workstream 4 has now moved production and disposable-VM guest commands onto the shared verified SSH transport. Workstream 2 must consolidate the interim `boot_policy_for_host` lookup with the canonical capability declaration. Workstation provisioning state now owns provider cleanup journals separately -from the VM manifest and local artifact ownership. +from the VM manifest and local artifact ownership. Live create, readiness, +provisioning, fixture smoke/reboot, external cleanup, and destroy passed on a +fresh disposable VM. Retained-manifest compatibility and interrupted-recovery +acceptance remain open. Native and Flatpak adapter contract tests and workstation checks remain required; live create/ready/update/reboot/destroy and retained-run acceptance have not yet passed. See @@ -63,8 +67,11 @@ run manifest directly rather than invoking the compatibility wrapper and re-reading `run.conf`. It uses the manifest SSH target and checks immutable domain ownership before and after every guest operation. Destroy's external cleanup callback derives its run directory from the recorded identity and -refuses CLI identity mismatches. Live VM cycle and recovery acceptance remain -pending. +refuses CLI identity mismatches. Fresh create/provision/destroy and retained +readiness/provision/reboot acceptance passed. The existing retained VM uses the +current manifest, so legacy-manifest live import remains unverified. A manual +interruption was followed by successful retries, but a controlled recovery +check with journal state captured before and after remains open. ## Read and locate diff --git a/ublue/plan/REFACTOR-02-HOST-PROFILES.md b/ublue/plan/REFACTOR-02-HOST-PROFILES.md index cea09b82..76607591 100644 --- a/ublue/plan/REFACTOR-02-HOST-PROFILES.md +++ b/ublue/plan/REFACTOR-02-HOST-PROFILES.md @@ -1,7 +1,8 @@ # 2. Canonical host profiles and capabilities -Status: implementation complete; retained-VM acceptance is pending. Feature -milestones remain gated by [the prerequisite roadmap](SKILLET-REFACTOR.md). +Status: implementation complete; retained clamps base/full apply acceptance +passed and synthetic-profile tests cover genericity. See the +[prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate @@ -78,5 +79,8 @@ keep the VM readiness orchestrator independent of deployment names. eligibility, smoke selection, and VM boot expectations query the profile. Unknown profiles fail closed. The smoke fixture is a separate hidden test command, not a synthetic host profile. -- Pending: live retained-VM base/full apply checks and the roadmap fresh-VM - cycle. These remain unverified until a named VM/runtime is available. +- Accepted: the fresh disposable clamps run selected the profile and completed + full UI provisioning and fixture smoke. Routine profile tests cover unknown + profiles and the generic baseline. +- No additional real host is required for genericity acceptance; the synthetic + profile tests exercise independent identity and service declarations. diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md index 706e44a2..45037938 100644 --- a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -1,7 +1,8 @@ # 3. Cohesive effect interfaces and unified ownership -Status: implementation complete; named-VM ownership/mount acceptance is -pending. This follows workstream 2 in +Status: implementation complete; live mount/subvolume, service ownership, and +descendant-metadata preservation checks passed. This follows +workstream 2 in [the prerequisite roadmap](SKILLET-REFACTOR.md); further feature milestones wait for the full roadmap gate. @@ -102,5 +103,10 @@ unified; remaining trait aggregates are transitional composition contexts. - Implemented: service state and Podman-secret idempotency/rotation contracts run against both `MockSystem` and its `Recorder` decorator. Unit tests do not mutate the workstation's real services or secrets. -- Pending: live ownership/mount acceptance remains outstanding. Implementation - is complete, but the named-VM exit criterion still needs to pass. +- Accepted: the retained clamps VM showed `/var/lib/data` on the Btrfs `data` + subvolume; service volumes and numeric UniFi data-root ownership matched the + declared paths/identity. Fixture recovery/reboot and credential rotation + passed with the mount. +- Complete: induced root-ownership drift was repaired on the disposable VM; a + child with independent numeric ownership/mode was unchanged, and the + temporary probe was removed afterward. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 0b2cfd6c..9816b103 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -1,7 +1,9 @@ # 4. Thin CLIs and shared workstation provisioning -Status: implementation complete; named-VM and production acceptance are -pending. This follows workstreams 1–3 in +Status: implementation complete; disposable provisioning, credential rotation, +and cleanup passed on fresh and retained test VMs. Controlled interrupted +recovery remains open; production delivery is deferred. This follows +workstreams 1–3 in [the prerequisite roadmap](SKILLET-REFACTOR.md); further feature milestones wait for the full roadmap gate. @@ -193,6 +195,9 @@ and VM delivery have duplicate implementations with different validation. uses the recorded SSH target, and checks backend ownership before and after every guest operation. No compatibility script or legacy `run.conf` reader remains in this delivery path. -- Pending: named-VM acceptance for disposable delivery, retry/rotation and - cleanup, plus production credential acceptance. Live environment checks - remain subject to available hardware and credentials. +- Accepted: fresh test-VM credential delivery, Tailscale enrollment, Cloudflare + DNS/token lifecycle, Caddy staging issuance, denial probes, and disposable + cleanup passed end to end. +- Remaining: a controlled interrupted delivery recovery with journal evidence + on a named test VM. Production checks require production credentials and are + explicitly deferred from this refactoring gate. diff --git a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md index bbf3039c..6b130f07 100644 --- a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md +++ b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md @@ -1,8 +1,7 @@ # 5. Podman configuration with explicit host policy -Status: implementation complete; named-VM acceptance is pending. This follows -workstreams 2–4 in [the prerequisite roadmap](SKILLET-REFACTOR.md); further -feature milestones wait for the full roadmap gate. +Status: implementation and clamps live acceptance complete. See +workstreams 2–4 in the [prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate @@ -44,8 +43,12 @@ apply also owns a global DNS-port policy and converges shared networks repeatedl bytes while checking the root metadata. - Implemented: a representative comparison proves that adding root ownership metadata leaves the existing Quadlet bytes unchanged. -- Pending: named-VM validation for process identity, DNS, service discovery, - and persistence. +- Accepted: live inspection confirmed Pi-hole/Syncthing bridge networking, + Tailscale/UniFi host networking, UniFi's declared process identity, expected + persistent data paths, and healthy/running services. Repeating host + provisioning preserved all four service container start times. An induced + UniFi volume-root ownership drift was repaired without changing child + ownership/mode. Caddy's staging flow also served the declared UI upstreams. ## Implementation sequence diff --git a/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md b/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md index 9e912336..4b308521 100644 --- a/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md +++ b/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md @@ -1,7 +1,8 @@ # 6. Explicit convergence outcomes and failure-safe recording -Status: implementation complete; named-VM acceptance pending. Feature -milestones remain gated by [the prerequisite roadmap](SKILLET-REFACTOR.md). +Status: implementation complete; fixture failure-recovery/reboot, host +credential rotation, and unchanged repeat-apply acceptance passed. See the +[prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate @@ -31,8 +32,9 @@ Recordings were saved only after successful apply and exposed payload hashes. if recording also fails, the returned error retains both causes. - Focused tests cover changed/no-op/stopped states, reload/start/restart interruption and retry, oneshots, secret redaction, failed apply recording, - and simultaneous apply/record failure. Full VM-level failure/reboot acceptance - remains pending. + and simultaneous apply/record failure. The fresh VM fixture passed its + failure-recovery and reboot cases. A further full host apply preserved the + service containers' start timestamps, confirming unchanged repeat apply. ## Implementation sequence diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md index 66c8ff5c..3cc0a521 100644 --- a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -1,7 +1,8 @@ # 7. Test layers, fixtures, build artifacts and CI -Status: implementation complete; fresh-VM acceptance pending. Earlier slices -remain gated by [the prerequisite roadmap](SKILLET-REFACTOR.md). +Status: implementation complete; CI checks and fresh/retained VM acceptance +passed. Legacy-manifest import and controlled interrupted-recovery acceptance +remain open; see the [prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate @@ -33,8 +34,9 @@ missed relevant CI. Shell code was embedded in a production Rust fixture. - CI now selects Rust validation when Skillet, Butane, its helpers, or the workflow changes; it runs workspace formatting, all-target strict Clippy, all-target tests, and the named Fedora container integration command. Shell - lint explicitly includes current extensionless scripts. Local commands pass; - `/dev/kvm` is absent, so fresh-VM acceptance is unverified. + lint explicitly includes current extensionless scripts. The fresh disposable + VM fixture and reboot passed; legacy-manifest compatibility and controlled + interrupted recovery remain unverified live. ## Implementation sequence @@ -90,11 +92,10 @@ missed relevant CI. Shell code was embedded in a production Rust fixture. - CI selection includes relevant Butane/helper changes, all executable shell helpers are linted, and format/Clippy/unit/bootstrap/container commands pass locally. Update AGENTS with exact final commands and general coverage rules. -- A named fresh VM passes create/ready/provision, real convergence fixture, - profile-selected host acceptance, reboot, repeat apply and cleanup. Record - artifact versions and outcomes; external resources created for this run are - removed through the single orchestrator. Prove retained-run compatibility - and interrupted recovery as required by workstream 1. +- A named fresh VM passed create/ready/provision, real convergence fixture, + reboot, and external cleanup. A retained VM passed readiness, full + provisioning, repeat apply, fixture failure recovery, and reboot. Remaining: + legacy-manifest import and controlled interrupted recovery. - Finish only after earlier workstreams' exit criteria also pass. Update the roadmap gate and migration entry point so the next agent can resume feature work without losing deferred acceptance requirements. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 02cfa890..fec3a3ba 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -1,10 +1,11 @@ # Skillet refactoring prerequisite -Status: planned, 2026-10-03. No implementation or new acceptance is claimed. -**Complete these seven workstreams before proceeding with further feature -milestones, service migrations, encryption implementation, or production -cutover.** Preserve the pending tasks in the existing plans. Acceptance and -repairs needed to validate this refactoring may proceed within its scope. +Status: implementation complete; acceptance gate partially complete, +2026-10-04. The seven refactoring slices are implemented and routine local +validation has passed. A fresh disposable clamps VM completed provisioning, +fixture smoke/reboot, staging ACME issuance, and cleanup. Remaining acceptance +is listed in the table and slice plans; keep feature milestones gated until +those checks are resolved or explicitly deferred with evidence. Read `../AGENTS.md`, `../skillet/AGENTS.md`, and [the architecture decision](../design/skillet-architecture.md) first. Then read @@ -20,13 +21,13 @@ assignments to execute concurrently against the same interfaces. | Order | Workstream | Depends on | Status | | --- | --- | --- | --- | -| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Rust owns create and retained-run commands; required live acceptance remains | -| 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Implemented; live acceptance pending | -| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Implementation complete; named-VM ownership/mount acceptance pending | -| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Implementation complete; named-VM and production acceptance pending | -| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Implementation complete; named-VM acceptance pending | -| 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Implemented; VM acceptance pending | -| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Implemented; VM acceptance pending | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Fresh and retained VM cycles passed; legacy-manifest import and controlled interrupted-recovery acceptance remain | +| 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Retained clamps base/full apply passed; canonical-profile and synthetic-profile checks pass | +| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Live Btrfs mount/subvolume, service ownership, and descendant-preservation checks passed | +| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Disposable provisioning, credential rotation, retry, delivery, and cleanup passed; controlled interrupted recovery remains | +| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Live service networks/identity, volume ownership preservation, credential rotation, and unchanged repeat-apply passed on clamps | +| 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Fixture recovery/reboot and host repeat-apply passed | +| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | CI and fresh/retained VM acceptance passed; legacy import and controlled interruption remain | Each earlier slice includes its own regression checks; testing does not wait for workstream 7. Workstream 1 creates the minimal VM/transport contracts used @@ -57,10 +58,15 @@ public command syntax unless a required change is documented with migration. honestly. Do not push without authorization. Update this table and each workstream's progress, design decisions, README usage, and general AGENTS prevention rules in the corresponding implementation slice. -- Completion requires all seven exit criteria and a final fresh VM cycle: - create → ready → provision → fixture and host acceptance → reboot → repeat - apply → destroy, with external cleanup where resources were provisioned. - Also verify retained-manifest compatibility and interrupted recovery. +- Completion requires all seven exit criteria. Fresh and retained clamps VM + cycles, real service/storage inspection, staging Caddy acceptance, fixture + recovery/reboot, provider cleanup, and unchanged host repeat apply have passed. + The remaining required checks are a legacy-manifest live import and a + controlled interrupted-provisioning recovery with journal evidence. There is + no retained legacy VM to import, so close that item only with a suitable + legacy run or explicit migration-test and inventory evidence. Production + delivery/renewal and outside-tailnet denial are explicitly deferred from this + refactor gate; routine CI remains credential-free. Resume [the migration plan](CLAMPS-MIGRATION.md) only after this gate passes. The existing private-UI, storage/credential, UniFi, and TPM plans retain their From 3f750a3c7f7fb4d640d829a47d5d4e349d3e2d6c Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 10:32:19 +0200 Subject: [PATCH 190/252] fix(ci): install required tools --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a00884d3..3e4d4638 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,10 @@ jobs: components: clippy, rustfmt targets: x86_64-unknown-linux-musl + - name: Install musl linker + if: steps.filter.outputs.rust == 'true' + run: sudo apt-get update && sudo apt-get install --yes musl-tools + - name: Rust Cache if: steps.filter.outputs.rust == 'true' uses: Swatinem/rust-cache@v2 From 511447b681b522c5c1c96a8aa9980676637ccf7a Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 11:18:11 +0200 Subject: [PATCH 191/252] docs(skillet): complete refactor acceptance record --- ublue/butane/ACCEPTANCE.md | 37 ++++++++++++-- ublue/plan/REFACTOR-01-VM-LIFECYCLE.md | 49 +++++++++---------- ublue/plan/REFACTOR-02-HOST-PROFILES.md | 8 +-- ublue/plan/REFACTOR-04-CLI-PROVISIONING.md | 15 +++--- ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md | 17 ++++--- ublue/plan/SKILLET-REFACTOR.md | 35 ++++++------- 6 files changed, 94 insertions(+), 67 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 6a9afc57..61429248 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1501,7 +1501,36 @@ host tool; it still uses normal command approvals. `12345:12345`/`0700`. Host apply restored the volume root to `999:999`/`0750` and preserved the child's UID/GID/mode. The temporary file was removed, UniFi was healthy, and `systemctl --failed` was empty afterward. -- Remaining: a controlled interrupted-provisioning recovery with its journal - captured before and after, and a legacy-manifest live import (no legacy VM - run is available). Production delivery/renewal and outside-tailnet denial - remain separate deferred acceptance. +- At this checkpoint, controlled interrupted recovery and legacy-manifest + import remained open. Both passed in the following acceptance entry. + Production delivery/renewal and outside-tailnet denial remain separate + deferred acceptance. + +### Legacy manifest import and interrupted provisioning recovery, 2026-10-05 + +- Legacy import: on the running retained `clamps-test-smoke` VM, temporarily + moved the current `vm.json` aside and ran + `skillet test vm ready clamps smoke --verbose`. Skillet read the legacy + `run.conf`, validated its UUID and disk against the live libvirt domain, + imported the state, reapplied signed-boot/readiness checks, and returned + success. The resulting `vm.json` was mode `0600`, phase `ready`, and retained + the captured/deployed artifact hashes. `run.conf` had the same SHA-256 before + and after; the original manifest backup was removed after verification. +- Interrupted recovery: created the disposable `clamps-test-recovery` VM on + port 2202 and completed readiness. Started + `skillet test vm provision clamps recovery --verbose`, then sent Ctrl-C only + after `tailscale-pending` existed. Before and after interruption the marker + was mode `0600` with the expected test host/environment/instance identity; + `tailscale.json` was absent and the VM manifest remained `ready` with the + same UUID. The command exited on interruption without removing ownership + state. +- Retried the same provision command. It delivered the Tailscale credential, + completed full service apply and enrollment, wrote `tailscale.json`, and + removed `tailscale-pending`. `skillet test smoke clamps --instance recovery` + passed all fixture cases, including reboot. `skillet test vm destroy clamps + recovery --verbose` removed the journaled Tailscale ownership and disposed + the VM. A final VM list showed only the retained `clamps-test-smoke`; its + libvirt domain and disk were untouched. +- No production credentials, production DNS, or Cloudflare APIs were used for + this recovery check. Production delivery/renewal and outside-tailnet denial + remain deferred from the refactoring gate. diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md index b486e859..0c6cdc23 100644 --- a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -1,8 +1,8 @@ # 1. One VM lifecycle owner; migrate Bash management to Rust -Status: implementation complete; fresh and retained VM acceptance passed. -Legacy-manifest live import and controlled interrupted-recovery acceptance -remain. See the [roadmap](SKILLET-REFACTOR.md) and 2026-10-04 acceptance entry. +Status: implementation and required VM acceptance complete. Fresh/retained +cycles, live legacy-manifest import, and interrupted provisioning recovery +passed. See the [roadmap](SKILLET-REFACTOR.md) and 2026-10-05 acceptance entry. ## Progress, 2026-10-03 @@ -47,31 +47,27 @@ runs readiness from one create command. Matching interrupted runs resume from the manifest. The shell `test-vm` interface only translates arguments; the independent Bash installer and hidden CLI bridge commands are retired. No remaining script owns VM creation, reboot, access, inspection, or disposal. -Workstream 4 has now moved production and disposable-VM guest commands onto -the shared verified SSH transport. Workstream 2 must consolidate the interim -`boot_policy_for_host` lookup with the canonical capability declaration. -Workstation provisioning state now owns provider cleanup journals separately -from the VM manifest and local artifact ownership. Live create, readiness, -provisioning, fixture smoke/reboot, external cleanup, and destroy passed on a -fresh disposable VM. Retained-manifest compatibility and interrupted-recovery -acceptance remain open. -Native and Flatpak adapter contract tests and -workstation checks remain required; live create/ready/update/reboot/destroy -and retained-run acceptance have not yet passed. See +Workstream 4 moved production and disposable-VM guest commands onto the shared +verified SSH transport. Boot expectations are resolved by the canonical host +profile. Workstation provisioning state owns provider cleanup journals +separately from the VM manifest and local artifact ownership. Fresh and +retained VM create/readiness/provision/smoke/reboot/cleanup checks passed. See [creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), plus [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). -Progress, 2026-10-04: disposable provisioning now loads and locks the validated -run manifest directly rather than invoking the compatibility wrapper and -re-reading `run.conf`. It uses the manifest SSH target and checks immutable -domain ownership before and after every guest operation. Destroy's external -cleanup callback derives its run directory from the recorded identity and -refuses CLI identity mismatches. Fresh create/provision/destroy and retained -readiness/provision/reboot acceptance passed. The existing retained VM uses the -current manifest, so legacy-manifest live import remains unverified. A manual -interruption was followed by successful retries, but a controlled recovery -check with journal state captured before and after remains open. +Progress, 2026-10-05: on the retained clamps VM, temporarily withheld `vm.json` +and ran `test vm ready clamps smoke --verbose`. Skillet imported the legacy +`run.conf`, validated the recorded UUID and disk against the live domain, +reapplied readiness, and wrote a mode-0600 versioned manifest. The legacy file +and its SHA-256 remained unchanged. + +On a fresh `clamps-test-recovery` VM, interrupted first-time provisioning after +the mode-0600 Tailscale pending journal was written but before a completed +device record existed. The journal and owned VM identity remained intact after +Ctrl-C. Retrying provisioning wrote the device record and removed the pending +marker; fixture smoke/reboot passed, and destroy removed the journaled Tailscale +device and VM artifacts. See the 2026-10-05 acceptance entry. ## Read and locate @@ -82,8 +78,9 @@ check with journal state captured before and after remains open. `secret_delivery.rs` provisioning, external ownership, cleanup, and VM SSH. Review findings: disposal used to bypass external cleanup or require a live -guest; those paths are now fixed. Persisted intent now makes interrupted -creation inspectable; readiness and target selection remain duplicated. +guest; those paths are fixed. Persisted intent makes interrupted creation +inspectable, and the canonical host profile supplies readiness expectations. +Fresh creation, readiness, interrupted retry, and cleanup have passed live. ## Implementation sequence diff --git a/ublue/plan/REFACTOR-02-HOST-PROFILES.md b/ublue/plan/REFACTOR-02-HOST-PROFILES.md index 76607591..6a2e84d8 100644 --- a/ublue/plan/REFACTOR-02-HOST-PROFILES.md +++ b/ublue/plan/REFACTOR-02-HOST-PROFILES.md @@ -19,10 +19,10 @@ back to the baseline, and UI presence is used as a proxy for service eligibility ## Implementation sequence -Workstream 1 introduced explicit boot expectations in -`cli-common::hosts::boot_policy_for_host`. Fold this interim lookup into the -canonical profile declaration alongside composition and service capabilities; -keep the VM readiness orchestrator independent of deployment names. +Workstream 1 introduced explicit boot expectations, now derived from the +canonical `skillet_hosts::HostProfile` alongside composition and service +capabilities. VM readiness receives the selected profile's expectations and +remains independent of deployment names. 1. Add canonical library `skillet_hosts` and move host definitions/composition out of `cli-common`. Define a validated `HostId` and `HostProfile` carrying diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md index 9816b103..4c1e106f 100644 --- a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -1,8 +1,8 @@ # 4. Thin CLIs and shared workstation provisioning -Status: implementation complete; disposable provisioning, credential rotation, -and cleanup passed on fresh and retained test VMs. Controlled interrupted -recovery remains open; production delivery is deferred. This follows +Status: implementation and disposable VM acceptance complete, including +credential rotation, interrupted recovery, and cleanup. Production delivery +is deferred. This follows workstreams 1–3 in [the prerequisite roadmap](SKILLET-REFACTOR.md); further feature milestones wait for the full roadmap gate. @@ -198,6 +198,9 @@ and VM delivery have duplicate implementations with different validation. - Accepted: fresh test-VM credential delivery, Tailscale enrollment, Cloudflare DNS/token lifecycle, Caddy staging issuance, denial probes, and disposable cleanup passed end to end. -- Remaining: a controlled interrupted delivery recovery with journal evidence - on a named test VM. Production checks require production credentials and are - explicitly deferred from this refactoring gate. +- Accepted: a first-time disposable Tailscale credential delivery was + interrupted after its durable pending journal was written. Retry reused that + journal, completed service apply and enrollment, wrote the ownership record, + and cleared pending state. Destroy then removed the journaled provider + device. Production checks require production credentials and are explicitly + deferred from this refactoring gate. diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md index 3cc0a521..f9762245 100644 --- a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -1,8 +1,8 @@ # 7. Test layers, fixtures, build artifacts and CI -Status: implementation complete; CI checks and fresh/retained VM acceptance -passed. Legacy-manifest import and controlled interrupted-recovery acceptance -remain open; see the [prerequisite roadmap](SKILLET-REFACTOR.md). +Status: implementation and required CI/fresh/retained VM acceptance complete. +Live legacy import and controlled interrupted-recovery checks passed; see the +[prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate @@ -34,9 +34,10 @@ missed relevant CI. Shell code was embedded in a production Rust fixture. - CI now selects Rust validation when Skillet, Butane, its helpers, or the workflow changes; it runs workspace formatting, all-target strict Clippy, all-target tests, and the named Fedora container integration command. Shell - lint explicitly includes current extensionless scripts. The fresh disposable - VM fixture and reboot passed; legacy-manifest compatibility and controlled - interrupted recovery remain unverified live. + lint explicitly includes current extensionless scripts. Fresh/retained VM + fixture and reboot passed. The live legacy-manifest import and controlled + interrupted-provisioning recovery also passed; evidence is in the + 2026-10-05 acceptance entry. ## Implementation sequence @@ -94,8 +95,8 @@ missed relevant CI. Shell code was embedded in a production Rust fixture. locally. Update AGENTS with exact final commands and general coverage rules. - A named fresh VM passed create/ready/provision, real convergence fixture, reboot, and external cleanup. A retained VM passed readiness, full - provisioning, repeat apply, fixture failure recovery, and reboot. Remaining: - legacy-manifest import and controlled interrupted recovery. + provisioning, repeat apply, fixture failure recovery, and reboot. Live + legacy-manifest import and controlled interrupted provisioning also passed. - Finish only after earlier workstreams' exit criteria also pass. Update the roadmap gate and migration entry point so the next agent can resume feature work without losing deferred acceptance requirements. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index fec3a3ba..4651e912 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -1,11 +1,11 @@ # Skillet refactoring prerequisite -Status: implementation complete; acceptance gate partially complete, -2026-10-04. The seven refactoring slices are implemented and routine local -validation has passed. A fresh disposable clamps VM completed provisioning, -fixture smoke/reboot, staging ACME issuance, and cleanup. Remaining acceptance -is listed in the table and slice plans; keep feature milestones gated until -those checks are resolved or explicitly deferred with evidence. +Status: complete, 2026-10-05. All seven refactoring slices, routine local +validation, and required fresh/retained VM acceptance have passed. The live +legacy-manifest import and controlled interrupted-provisioning recovery are +recorded in the [acceptance log](../butane/ACCEPTANCE.md#legacy-manifest-import-and-interrupted-provisioning-recovery-2026-10-05). +Production delivery/renewal and outside-tailnet denial remain explicitly +deferred from this refactor gate. Read `../AGENTS.md`, `../skillet/AGENTS.md`, and [the architecture decision](../design/skillet-architecture.md) first. Then read @@ -21,13 +21,13 @@ assignments to execute concurrently against the same interfaces. | Order | Workstream | Depends on | Status | | --- | --- | --- | --- | -| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Fresh and retained VM cycles passed; legacy-manifest import and controlled interrupted-recovery acceptance remain | -| 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Retained clamps base/full apply passed; canonical-profile and synthetic-profile checks pass | -| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Live Btrfs mount/subvolume, service ownership, and descendant-preservation checks passed | -| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Disposable provisioning, credential rotation, retry, delivery, and cleanup passed; controlled interrupted recovery remains | -| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Live service networks/identity, volume ownership preservation, credential rotation, and unchanged repeat-apply passed on clamps | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Fresh/retained cycles, live legacy import, and controlled recovery passed | +| 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Retained apply and synthetic-profile genericity checks passed | +| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Live Btrfs, service ownership, and descendant-preservation checks passed | +| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Delivery, rotation, interrupted retry, provider cleanup, and VM cleanup passed | +| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Live service-network, identity, ownership, rotation, and repeat-apply checks passed | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Fixture recovery/reboot and host repeat-apply passed | -| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | CI and fresh/retained VM acceptance passed; legacy import and controlled interruption remain | +| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | CI and fresh/retained VM acceptance passed | Each earlier slice includes its own regression checks; testing does not wait for workstream 7. Workstream 1 creates the minimal VM/transport contracts used @@ -58,13 +58,10 @@ public command syntax unless a required change is documented with migration. honestly. Do not push without authorization. Update this table and each workstream's progress, design decisions, README usage, and general AGENTS prevention rules in the corresponding implementation slice. -- Completion requires all seven exit criteria. Fresh and retained clamps VM - cycles, real service/storage inspection, staging Caddy acceptance, fixture - recovery/reboot, provider cleanup, and unchanged host repeat apply have passed. - The remaining required checks are a legacy-manifest live import and a - controlled interrupted-provisioning recovery with journal evidence. There is - no retained legacy VM to import, so close that item only with a suitable - legacy run or explicit migration-test and inventory evidence. Production +- Completion evidence includes fresh and retained clamps VM cycles, real + service/storage inspection, staging Caddy acceptance, fixture recovery and + reboot, provider cleanup, unchanged host repeat apply, legacy-manifest import, + and controlled interrupted-provisioning recovery. Production delivery/renewal and outside-tailnet denial are explicitly deferred from this refactor gate; routine CI remains credential-free. From 24c0d6c6b44432455fd206a7094383b981e2f76c Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 14:58:31 +0200 Subject: [PATCH 192/252] refactor(vm): move smoke lifecycle under Rust --- .../cli/src/application_acceptance_tests.rs | 141 ++++ ublue/skillet/crates/cli/src/main.rs | 645 ++++++++++++++++-- ublue/skillet/crates/cli/src/vm.rs | 37 +- .../crates/host-profiles/src/hosts_tests.rs | 72 +- ublue/skillet/crates/host-profiles/src/lib.rs | 5 +- .../crates/host-profiles/src/profile.rs | 163 +++++ .../skillet/crates/vm/src/transport/tests.rs | 30 + ublue/skillet/integration_tests/smoke-ssh.sh | 106 --- 8 files changed, 1019 insertions(+), 180 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/application_acceptance_tests.rs delete mode 100755 ublue/skillet/integration_tests/smoke-ssh.sh diff --git a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs new file mode 100644 index 00000000..f5ee37dc --- /dev/null +++ b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs @@ -0,0 +1,141 @@ +use super::{application_snapshot, application_snapshot_with_probe_timeout, require_data_mount}; +use skillet_hosts::profile_for_name; +use skillet_vm::transport::{GuestCommand, GuestTransport}; +use std::{ + os::unix::process::ExitStatusExt as _, + path::Path, + process::{ExitStatus, Output}, +}; + +struct AcceptanceTransport { + active: bool, + network: String, + missing_listener: Option, + owner: String, + container_id: String, +} + +impl GuestTransport for AcceptanceTransport { + fn execute(&self, request: &GuestCommand<'_>, _: Option<&[u8]>) -> skillet_vm::Result { + let arguments = request.arguments; + let output = match (request.program, arguments) { + ("systemctl", ["is-active", _]) if self.active => b"active\n".to_vec(), + ("systemctl", ["is-active", _]) => b"inactive\n".to_vec(), + ("sudo", ["-n", "podman", "inspect", "--format", _, _]) => { + format!( + "true|bridge|{}|{{\"{}\":{{}}}}|/var/lib/data/syncthing;/var/lib/data/caddy/data;\n", + self.container_id, self.network + ) + .into_bytes() + } + ("stat", ["-c", "%U:%G", _]) => format!("{}\n", self.owner).into_bytes(), + ("sudo", ["-n", "ss", "-H", _, "sport", "=", port]) + if self.missing_listener != port.trim_start_matches(':').parse().ok() => + { + b"LISTEN 0 128 0.0.0.0:port *:*\n".to_vec() + } + ("sha256sum", [_]) => b"abc123 /etc/containers/systemd/test.container\n".to_vec(), + _ => Vec::new(), + }; + let success = !matches!((request.program, arguments), ("systemctl", ["is-active", _]) if !self.active); + Ok(Output { + status: ExitStatus::from_raw(if success { 0 } else { 1 << 8 }), + stdout: output, + stderr: Vec::new(), + }) + } + + fn upload(&self, _: &Path, _: &str) -> skillet_vm::Result<()> { + Ok(()) + } +} + +fn transport() -> AcceptanceTransport { + AcceptanceTransport { + active: true, + network: "beezelbot".into(), + missing_listener: None, + owner: "giacomo:giacomo".into(), + container_id: "container-id".into(), + } +} + +#[test] +fn application_snapshot_accepts_profile_declared_runtime() { + let profile = profile_for_name("beezelbot").unwrap(); + let plan = profile.acceptance_plan(); + let observed = application_snapshot(&transport(), &|| Ok(()), &plan).unwrap(); + assert!(observed + .iter() + .any(|entry| entry.starts_with("container:syncthing:"))); + assert!(observed + .iter() + .any(|entry| entry.starts_with("container:caddy:"))); +} + +#[test] +fn application_snapshot_rejects_stopped_units() { + let mut fake = transport(); + fake.active = false; + let plan = profile_for_name("beezelbot").unwrap().acceptance_plan(); + assert!(application_snapshot_with_probe_timeout( + &fake, + &|| Ok(()), + &plan, + std::time::Duration::ZERO + ) + .unwrap_err() + .to_string() + .contains("not become active")); +} + +#[test] +fn application_snapshot_rejects_wrong_network_and_missing_listener() { + let plan = profile_for_name("beezelbot").unwrap().acceptance_plan(); + let mut wrong_network = transport(); + wrong_network.network = "unrelated".into(); + assert!(application_snapshot(&wrong_network, &|| Ok(()), &plan) + .unwrap_err() + .to_string() + .contains("network attachment")); + + let mut no_listener = transport(); + no_listener.missing_listener = Some(22000); + assert!(application_snapshot_with_probe_timeout( + &no_listener, + &|| Ok(()), + &plan, + std::time::Duration::ZERO + ) + .unwrap_err() + .to_string() + .contains("listener")); +} + +#[test] +fn application_snapshot_rejects_wrong_data_ownership_and_changed_identity() { + let plan = profile_for_name("beezelbot").unwrap().acceptance_plan(); + let mut wrong_owner = transport(); + wrong_owner.owner = "core:core".into(); + assert!(application_snapshot(&wrong_owner, &|| Ok(()), &plan) + .unwrap_err() + .to_string() + .contains("ownership")); + + let before = application_snapshot(&transport(), &|| Ok(()), &plan).unwrap(); + let mut changed = transport(); + changed.container_id = "recreated-container".into(); + let after = application_snapshot(&changed, &|| Ok(()), &plan).unwrap(); + assert_ne!(before, after); +} + +#[test] +fn data_mount_and_persistence_hash_checks_reject_lost_state() { + assert!(require_data_mount("btrfs /data").is_ok()); + assert!(require_data_mount("xfs /").is_err()); + + let transport = transport(); + assert!( + super::verify_guest_hash(&transport, "/var/lib/data/.marker", "expected-hash").is_err() + ); +} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index f664a210..f0098823 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Context, Result}; use clap::Parser; use skillet_cli_common::hosts::ApplyPhase; -use std::{fs, path::PathBuf, process::Command}; +use std::{fs, io::Write as _, path::PathBuf, process::Command}; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; @@ -217,7 +217,7 @@ struct SmokeArgs { /// Recorded disposable VM instance #[arg(long, default_value = "smoke")] instance: String, - /// Explicit disposable SSH target, USER@HOST + /// Optional target override; must match the recorded disposable VM #[arg(long)] target: Option, /// SSH port @@ -226,18 +226,9 @@ struct SmokeArgs { /// SSH private key (defaults to the key generated for `test vm create`) #[arg(long)] identity: Option, -} - -struct SmokeInvocation<'a> { - target: &'a str, - port: u16, - host: &'a str, - credentials_required: bool, - fixture_binary: &'a std::path::Path, - fixture_sha256: &'a str, - host_binary: String, - identity: &'a std::path::Path, - deployed_hashes: &'a skillet_vm::manifest::ArtifactHashes, + /// Also verify and repeat-apply the selected host's provisioned applications + #[arg(long)] + with_applications: bool, } fn main() -> Result<()> { @@ -337,6 +328,7 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { let butane = butane_root()?; let run_identity = skillet_vm::RunIdentity::new(&args.hostname, &args.instance)?; let store = skillet_vm::ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _lock = store.lock(&run_identity)?; let run = store.load(&run_identity)?; if run.phase != skillet_vm::Phase::Ready { return Err(anyhow!( @@ -398,59 +390,583 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { fixture_sha256 = fixture_hash, "Resolved smoke binaries" ); - let script = root.join("integration_tests/smoke-ssh.sh"); - if !script.is_file() { - return Err(anyhow!("smoke runner not found at {}", script.display())); - } - run_smoke_runner( - &script, - SmokeInvocation { - target: &target, - port: run.ssh.port, - host: run.identity.host(), - credentials_required, - fixture_binary: &fixture_artifact, - fixture_sha256: &fixture_hash, - host_binary, - identity: &identity, - deployed_hashes, - }, + let guest_script = root.join("integration_tests/smoke-guest.sh"); + if !guest_script.is_file() { + return Err(anyhow!( + "smoke guest assertions not found at {}", + guest_script.display() + )); + } + SmokeLifecycle { + butane: &butane, + store: &store, + run: &run, + fixture: &fixture_artifact, + fixture_hash: &fixture_hash, + guest_script: &guest_script, + host_binary: &host_binary, + credentials_required, + with_applications: args.with_applications, + } + .run() +} + +struct SmokeLifecycle<'a> { + butane: &'a std::path::Path, + store: &'a skillet_vm::ManifestStore, + run: &'a skillet_vm::VmRun, + fixture: &'a std::path::Path, + fixture_hash: &'a str, + guest_script: &'a std::path::Path, + host_binary: &'a str, + credentials_required: bool, + with_applications: bool, +} + +impl SmokeLifecycle<'_> { + fn run(&self) -> Result<()> { + use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + transport::{HostKeyPolicy, OwnershipCheckedTransport}, + }; + let backend = VirshBackend::for_run(self.run, &self.butane.join("bin/virsh"))?; + let ownership_run = self.run.clone(); + let ownership = || -> skillet_vm::Result<()> { + backend + .inspect(&ownership_run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned VM domain is absent".into()))? + .validate_owned(&ownership_run) + }; + ownership()?; + + let base = + skillet_vm::transport::SshTransport::new(self.run.ssh.clone(), HostKeyPolicy::Verify)?; + let transport = OwnershipCheckedTransport::new(&base, &ownership); + self.install_and_verify(&transport)?; + let boot_before = guest_text(&transport, "cat", &["/proc/sys/kernel/random/boot_id"])?; + if self.with_applications { + self.write_persistence_marker(&transport)?; + } + self.run_guest_phase(&transport, "before-reboot")?; + + let run = self.store.mark_reboot_pending(&self.run.identity)?; + ownership()?; + backend.reboot(&run)?; + ownership()?; + Self::wait_for_new_boot(&run, &ownership, &boot_before)?; + self.run_guest_phase(&transport, "after-reboot")?; + + if self.with_applications { + self.check_applications(&transport, &ownership)?; + smoke_guest_command( + &transport, + "sudo", + &["-n", "rm", "-f", "--", &self.persistence_marker_path()], + )?; + } + + vm::ready_locked(self.butane, self.store, &self.run.identity)?; + if self.store.load(&self.run.identity)?.phase != skillet_vm::Phase::Ready { + return Err(anyhow!( + "VM readiness did not restore the Ready phase after smoke" + )); + } + Ok(()) + } + + fn install_and_verify( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + ) -> Result<()> { + for (source, destination) in [ + (self.fixture, "/var/tmp/skillet-smoke-fixture"), + (self.guest_script, "/var/tmp/skillet-smoke-guest.sh"), + ] { + transport.upload(source, destination)?; + } + smoke_guest_command( + transport, + "sudo", + &[ + "-n", + "install", + "-m", + "0755", + "/var/tmp/skillet-smoke-fixture", + "/var/usrlocal/bin/skillet-smoke-fixture", + ], + )?; + verify_guest_hash( + transport, + "/var/usrlocal/bin/skillet-smoke-fixture", + self.fixture_hash, + )?; + let deployed = self + .run + .deployed + .as_ref() + .ok_or_else(|| anyhow!("VM has no deployed artifact hashes"))?; + verify_guest_hash(transport, "/var/usrlocal/bin/skillet", &deployed.generic)?; + verify_guest_hash( + transport, + &format!("/var/usrlocal/bin/skillet-{}", self.run.identity.host()), + &deployed.host, + ) + } + + fn run_guest_phase( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + phase: &str, + ) -> Result<()> { + let credentials = if self.credentials_required { + "yes" + } else { + "no" + }; + smoke_guest_command( + transport, + "sudo", + &[ + "-n", + "bash", + "/var/tmp/skillet-smoke-guest.sh", + phase, + self.host_binary, + credentials, + ], + ) + } + + fn persistence_marker_path(&self) -> String { + format!( + "/var/lib/data/.skillet-smoke-{}-{}.marker", + self.run.identity.host(), + self.run.identity.instance() + ) + } + + fn write_persistence_marker( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + ) -> Result<()> { + use skillet_vm::transport::GuestCommand; + const MARKER: &[u8] = b"skillet disposable application persistence check\n"; + smoke_guest_command( + transport, + "sudo", + &["-n", "rm", "-f", "--", &self.persistence_marker_path()], + )?; + let output = transport.execute( + &GuestCommand { + program: "sudo", + arguments: &["-n", "tee", &self.persistence_marker_path()], + }, + Some(MARKER), + )?; + if !output.status.success() { + return Err(anyhow!("writing disposable persistence marker failed")); + } + Ok(()) + } + + fn verify_persistence_marker( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + ) -> Result<()> { + use sha2::{Digest as _, Sha256}; + let expected = hex::encode(Sha256::digest( + b"skillet disposable application persistence check\n", + )); + verify_guest_hash(transport, &self.persistence_marker_path(), &expected) + } + + fn check_applications( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + ownership: &dyn Fn() -> skillet_vm::Result<()>, + ) -> Result<()> { + let profile = skillet_hosts::profile_for_name(self.run.identity.host()) + .ok_or_else(|| anyhow!("unknown host acceptance profile"))?; + let plan = profile.acceptance_plan(); + if plan.requires_data_mount { + let mount = guest_text( + transport, + "findmnt", + &["-n", "-o", "FSTYPE,FSROOT", "--mountpoint", "/var/lib/data"], + )?; + require_data_mount(&mount)?; + } + self.verify_persistence_marker(transport)?; + let first = application_snapshot(transport, ownership, &plan)?; + self.repeat_host_apply(transport, &profile)?; + let second = application_snapshot(transport, ownership, &plan)?; + if first != second { + return Err(anyhow!( + "repeat host apply changed declared application runtime state" + )); + } + tracing::info!( + services = ?plan.services.iter().map(|service| service.unit.as_str()).collect::>(), + "Host application acceptance passed" + ); + println!( + "Application acceptance passed for: {}", + plan.services + .iter() + .map(|service| service.unit.as_str()) + .collect::>() + .join(", ") + ); + Ok(()) + } + + fn repeat_host_apply( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + profile: &skillet_hosts::HostProfile, + ) -> Result<()> { + if profile.requires_full_apply_credentials() { + smoke_guest_command( + transport, + "sudo", + &["-n", "systemctl", "restart", "skillet-full-apply.service"], + )?; + } else { + smoke_guest_command( + transport, + "sudo", + &["-n", self.host_binary, "apply", "--phase", "full"], + )?; + } + if profile.services.iter().any(|service| service.ui.is_some()) { + smoke_guest_command( + transport, + "sudo", + &["-n", "systemctl", "restart", "skillet-caddy-apply.service"], + )?; + } + Ok(()) + } + + fn wait_for_new_boot( + run: &skillet_vm::VmRun, + ownership: &dyn Fn() -> skillet_vm::Result<()>, + boot_before: &str, + ) -> Result<()> { + use skillet_vm::transport::{HostKeyPolicy, OwnershipCheckedTransport}; + use std::time::{Duration, Instant}; + let mut probe = + skillet_vm::transport::SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + probe.timeout = Duration::from_secs(15); + let checked = OwnershipCheckedTransport::new(&probe, ownership); + let deadline = Instant::now() + Duration::from_mins(4); + loop { + ownership()?; + match guest_text(&checked, "cat", &["/proc/sys/kernel/random/boot_id"]) { + Ok(boot_after) if boot_after != boot_before => return Ok(()), + Ok(_) | Err(_) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_secs(5)); + } + Ok(_) => return Err(anyhow!("VM did not return from reboot within 240 seconds")), + Err(error) => { + ownership()?; + if Instant::now() >= deadline { + return Err(anyhow!( + "VM did not return from reboot within 240 seconds: {error}" + )); + } + std::thread::sleep(Duration::from_secs(5)); + } + } + } + } +} + +fn application_snapshot( + transport: &impl skillet_vm::transport::GuestTransport, + ownership: &dyn Fn() -> skillet_vm::Result<()>, + plan: &skillet_hosts::HostAcceptancePlan, +) -> Result> { + application_snapshot_with_probe_timeout( + transport, + ownership, + plan, + std::time::Duration::from_mins(2), ) } -fn run_smoke_runner(script: &std::path::Path, invocation: SmokeInvocation<'_>) -> Result<()> { - let status = std::process::Command::new("bash") - .arg(script) - .args([ - "--target", - invocation.target, - "--disposable-target", - "--port", - ]) - .arg(invocation.port.to_string()) - .args(["--host", invocation.host]) - .args([ - "--credentials-required", - if invocation.credentials_required { - "yes" - } else { - "no" +fn application_snapshot_with_probe_timeout( + transport: &impl skillet_vm::transport::GuestTransport, + ownership: &dyn Fn() -> skillet_vm::Result<()>, + plan: &skillet_hosts::HostAcceptancePlan, + probe_timeout: std::time::Duration, +) -> Result> { + let mut snapshot = Vec::new(); + for service in &plan.services { + ownership()?; + let unit_state = check_unit_active(transport, &service.unit, probe_timeout)?; + if unit_state != "active" { + return Err(anyhow!( + "declared service unit {} is not active", + service.unit + )); + } + snapshot.push(format!("unit:{}:{unit_state}", service.unit)); + if let Some(container) = &service.container { + snapshot.push(check_container(transport, service, container)?); + } + for listener in &service.listeners { + check_listener(transport, *listener, probe_timeout)?; + snapshot.push(format!( + "listener:{:?}:{}", + listener.protocol, listener.port + )); + } + if let Some(health) = check_health(transport, service.health_probe)? { + snapshot.push(health); + } + } + Ok(snapshot) +} + +fn check_unit_active( + transport: &impl skillet_vm::transport::GuestTransport, + unit: &str, + timeout: std::time::Duration, +) -> Result { + use std::time::{Duration, Instant}; + let deadline = Instant::now() + timeout; + loop { + let output = transport.execute( + &skillet_vm::transport::GuestCommand { + program: "systemctl", + arguments: &["is-active", unit], }, - ]) - .args(["--fixture-binary"]) - .arg(invocation.fixture_binary) - .args(["--fixture-sha256", invocation.fixture_sha256]) - .args(["--generic-sha256", &invocation.deployed_hashes.generic]) - .args(["--host-sha256", &invocation.deployed_hashes.host]) - .args(["--host-binary"]) - .arg(invocation.host_binary) - .args(["--identity"]) - .arg(invocation.identity) - .status() - .context("starting disposable-VM smoke runner failed")?; - if !status.success() { + None, + )?; + let last_state = String::from_utf8(output.stdout) + .context("systemd service state was not UTF-8")? + .trim() + .to_string(); + if output.status.success() && last_state == "active" { + return Ok(last_state); + } + if Instant::now() >= deadline { + return Err(anyhow!( + "declared service unit {unit} did not become active within 120 seconds (last state: {last_state})" + )); + } + std::thread::sleep(Duration::from_secs(2)); + } +} + +fn require_data_mount(observed: &str) -> Result<()> { + if observed != "btrfs /data" { + return Err(anyhow!( + "host data mount differs from the profile expectation" + )); + } + Ok(()) +} + +fn check_container( + transport: &impl skillet_vm::transport::GuestTransport, + service: &skillet_hosts::AcceptanceService, + container: &str, +) -> Result { + use std::fmt::Write as _; + let inspect = guest_text( + transport, + "sudo", + &[ + "-n", + "podman", + "inspect", + "--format", + "{{.State.Running}}|{{.HostConfig.NetworkMode}}|{{.Id}}|{{json .NetworkSettings.Networks}}|{{range .Mounts}}{{.Source}};{{end}}", + container, + ], + )?; + let fields = inspect.splitn(5, '|').collect::>(); + if fields.len() != 5 || fields[0] != "true" { + return Err(anyhow!("declared container {container} is not running")); + } + if let Some(expected) = &service.network_mode { + let actual_networks: serde_json::Value = serde_json::from_str(fields[3]) + .context("Podman returned invalid network inspection JSON")?; + let network_matches = if expected == "host" { + fields[1] == "host" + } else { + actual_networks + .as_object() + .is_some_and(|networks| networks.contains_key(expected)) + }; + if !network_matches { + return Err(anyhow!( + "container {container} has an unexpected network attachment" + )); + } + } + let mut state = format!( + "container:{container}:{}:{}:{}", + fields[1], fields[2], fields[3] + ); + for path in &service.bind_paths { + if !fields[4].split(';').any(|source| source == path) { + return Err(anyhow!( + "container {container} does not mount declared path {path}" + )); + } + if let Some(owner_expectation) = &service.owner { + use skillet_hosts::AcceptanceOwner; + let (format, expected) = match owner_expectation { + AcceptanceOwner::Named { user, group } => ("%U:%G", format!("{user}:{group}")), + AcceptanceOwner::Numeric { uid, gid } => ("%u:%g", format!("{uid}:{gid}")), + }; + let owner = guest_text(transport, "stat", &["-c", format, path])?; + if owner != expected { + return Err(anyhow!( + "data path ownership differs from declaration: {path} (expected {expected}, found {owner})" + )); + } + write!(state, ";bind:{path}:{owner}")?; + } + } + let unit_name = service + .unit + .strip_suffix(".service") + .ok_or_else(|| anyhow!("container service unit has an invalid name"))?; + let config_path = format!("/etc/containers/systemd/{unit_name}.container"); + let config_hash = guest_text(transport, "sha256sum", &[&config_path])?; + write!(state, ";config:{config_hash}")?; + Ok(state) +} + +fn check_listener( + transport: &impl skillet_vm::transport::GuestTransport, + listener: skillet_hosts::AcceptanceListener, + timeout: std::time::Duration, +) -> Result<()> { + use skillet_hosts::ListenerProtocol; + use std::time::{Duration, Instant}; + let protocol = match listener.protocol { + ListenerProtocol::Tcp => "-lnt", + ListenerProtocol::Udp => "-lnu", + }; + let port = format!(":{}", listener.port); + let deadline = Instant::now() + timeout; + loop { + let listening = guest_text( + transport, + "sudo", + &["-n", "ss", "-H", protocol, "sport", "=", &port], + )?; + if !listening.is_empty() { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(anyhow!( + "declared listener on port {} did not appear within 120 seconds", + listener.port + )); + } + std::thread::sleep(Duration::from_secs(2)); + } +} + +fn check_health( + transport: &impl skillet_vm::transport::GuestTransport, + probe: Option, +) -> Result> { + use skillet_hosts::HealthProbe; + match probe { + Some(HealthProbe::Pihole) => { + smoke_guest_command( + transport, + "sudo", + &["-n", "podman", "exec", "pihole", "pihole", "status"], + )?; + Ok(Some("health:pihole:running".into())) + } + Some(HealthProbe::Tailscale) => { + let status = guest_text( + transport, + "sudo", + &[ + "-n", + "podman", + "exec", + "tailscale", + "tailscale", + "status", + "--json", + ], + )?; + let status: serde_json::Value = + serde_json::from_str(&status).context("Tailscale returned invalid status JSON")?; + if status + .get("BackendState") + .and_then(serde_json::Value::as_str) + != Some("Running") + { + return Err(anyhow!("Tailscale is not connected")); + } + Ok(Some("health:tailscale:running".into())) + } + None => Ok(None), + } +} + +fn smoke_guest_command( + transport: &impl skillet_vm::transport::GuestTransport, + program: &str, + arguments: &[&str], +) -> Result<()> { + let output = transport.execute( + &skillet_vm::transport::GuestCommand { program, arguments }, + None, + )?; + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + if !output.status.success() { + return Err(anyhow!( + "guest smoke command {program} failed with status {:?}", + output.status.code() + )); + } + Ok(()) +} + +fn guest_text( + transport: &impl skillet_vm::transport::GuestTransport, + program: &str, + arguments: &[&str], +) -> Result { + let output = transport.execute( + &skillet_vm::transport::GuestCommand { program, arguments }, + None, + )?; + if !output.status.success() { + return Err(anyhow!( + "guest probe {program} failed with status {:?}", + output.status.code() + )); + } + String::from_utf8(output.stdout) + .context("guest probe output was not UTF-8") + .map(|value| value.trim().to_owned()) +} + +fn verify_guest_hash( + transport: &impl skillet_vm::transport::GuestTransport, + path: &str, + expected: &str, +) -> Result<()> { + let output = guest_text(transport, "sha256sum", &[path])?; + if output.split_whitespace().next() != Some(expected) { return Err(anyhow!( - "disposable-VM smoke scenario failed with status {status}" + "guest artifact at {path} does not match the recorded SHA-256" )); } Ok(()) @@ -722,6 +1238,7 @@ mod tests { "clamps", "--target", "core@192.0.2.5", + "--with-applications", ]); assert!(parsed.is_ok()); } @@ -765,3 +1282,7 @@ mod tests { #[cfg(test)] #[path = "smoke_artifact_tests.rs"] mod smoke_artifact_tests; + +#[cfg(test)] +#[path = "application_acceptance_tests.rs"] +mod application_acceptance_tests; diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 7d903cfb..a1df68e9 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -393,7 +393,17 @@ pub(super) fn ready(args: &VmTargetArgs) -> Result<()> { let identity = RunIdentity::new(&args.hostname, &args.instance)?; let store = ManifestStore::new(&butane.join("runs"), current_uid())?; let _lock = store.lock(&identity)?; - let run = store.load(&identity)?; + ready_locked(&butane, &store, &identity) +} + +/// Run readiness while the caller holds the run lock. Smoke uses this after +/// reboot so it can keep the same lock across the complete lifecycle. +pub(super) fn ready_locked( + butane: &std::path::Path, + store: &ManifestStore, + identity: &RunIdentity, +) -> Result<()> { + let run = store.load(identity)?; let boot = skillet_hosts::boot_policy_for_host(identity.host()) .ok_or_else(|| anyhow!("unknown readiness profile: {}", identity.host()))?; let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; @@ -401,7 +411,7 @@ pub(super) fn ready(args: &VmTargetArgs) -> Result<()> { .inspect(&run)? .ok_or_else(|| anyhow!("owned domain is absent"))? .validate_owned(&run)?; - let mut run = store.import(&identity)?; + let mut run = store.import(identity)?; let ownership_run = run.clone(); let mut probe = skillet_vm::transport::SshTransport::new( run.ssh.clone(), @@ -420,8 +430,8 @@ pub(super) fn ready(args: &VmTargetArgs) -> Result<()> { }; skillet_vm::readiness::ready( &mut run, - &store, - &store.run_dir(&identity), + store, + &store.run_dir(identity), &policy, &skillet_vm::readiness::ReadinessIo { probe: &probe, @@ -489,11 +499,20 @@ pub(super) fn update(args: &VmDestroyArgs) -> Result<()> { skillet_vm::transport::HostKeyPolicy::Verify, )?; run = store.import(&identity)?; - backend - .inspect(&run)? - .ok_or_else(|| anyhow!("owned domain disappeared during the build"))? - .validate_owned(&run)?; - let hashes = skillet_vm::delivery::deliver(&run, &transport, host, generic)?; + let ownership_run = run.clone(); + let ownership = || -> skillet_vm::Result<()> { + backend + .inspect(&ownership_run)? + .ok_or_else(|| { + skillet_vm::Error::Invalid("owned domain disappeared during update".into()) + })? + .validate_owned(&ownership_run) + }; + ownership()?; + let checked_transport = + skillet_vm::transport::OwnershipCheckedTransport::new(&transport, &ownership); + let hashes = skillet_vm::delivery::deliver(&run, &checked_transport, host, generic)?; + ownership()?; run.deployed = Some(hashes); store.save(&run)?; println!( diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 8277208c..b296ea26 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -1,6 +1,7 @@ use super::{ profile::{ - HostId, HostProfile, HostService, NetworkPolicy, ServiceConfig, UiServiceDeclaration, + AcceptanceOwner, HostId, HostProfile, HostService, NetworkPolicy, ServiceConfig, + UiServiceDeclaration, }, tailscale_config, ui_config_for_host, TAILSCALE_AUTH_KEY_CREDENTIAL, }; @@ -200,6 +201,75 @@ fn host_ui_declarations_include_only_the_services_each_host_runs() { assert!(ui_config_for_host("unknown-host").is_none()); } +#[test] +fn application_acceptance_tracks_each_profiles_declared_services() { + use super::{profile_for_name, HealthProbe, ListenerProtocol}; + + let clamps = profile_for_name("clamps").unwrap().acceptance_plan(); + let clamps_names = clamps + .services + .iter() + .map(|service| service.unit.as_str()) + .collect::>(); + assert_eq!( + clamps_names, + [ + "pihole.service", + "tailscale.service", + "syncthing.service", + "unifi.service", + "btrbk.timer", + "caddy.service" + ] + ); + let pihole = clamps + .services + .iter() + .find(|service| service.unit == "pihole.service") + .unwrap(); + assert_eq!(pihole.health_probe, Some(HealthProbe::Pihole)); + assert_eq!(pihole.owner, None); + assert!(pihole + .listeners + .iter() + .any(|listener| listener.port == 53 && listener.protocol == ListenerProtocol::Udp)); + let syncthing = clamps + .services + .iter() + .find(|service| service.unit == "syncthing.service") + .unwrap(); + assert_eq!( + syncthing.owner, + Some(AcceptanceOwner::Named { + user: "giacomo".into(), + group: "giacomo".into() + }) + ); + let unifi = clamps + .services + .iter() + .find(|service| service.unit == "unifi.service") + .unwrap(); + assert_eq!( + unifi.owner, + Some(AcceptanceOwner::Numeric { uid: 999, gid: 999 }) + ); + + let beezelbot = profile_for_name("beezelbot").unwrap().acceptance_plan(); + assert_eq!( + beezelbot + .services + .iter() + .map(|service| service.unit.as_str()) + .collect::>(), + ["syncthing.service", "caddy.service"] + ); + assert!(beezelbot + .services + .iter() + .all(|service| service.network_mode.as_deref() == Some("beezelbot"))); +} + #[test] fn host_ids_validate_ascii_dns_label_syntax() { for valid in ["a", "clamps", "remote-node-2"] { diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index 03cf2322..e2701657 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -11,8 +11,9 @@ use thiserror::Error; pub mod profile; pub use profile::{ - declared_profiles, profile_for_host, profile_for_name, CredentialConsumer, HostId, HostProfile, - HostService, ServiceConfig, UiServiceDeclaration, + declared_profiles, profile_for_host, profile_for_name, AcceptanceListener, AcceptanceOwner, + AcceptanceService, CredentialConsumer, HealthProbe, HostAcceptancePlan, HostId, HostProfile, + HostService, ListenerProtocol, ServiceConfig, UiServiceDeclaration, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index 2596212e..4a543687 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -110,7 +110,66 @@ pub struct HostProfile { pub services: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HostAcceptancePlan { + pub requires_data_mount: bool, + pub services: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcceptanceService { + pub unit: String, + pub container: Option, + pub network_mode: Option, + pub bind_paths: Vec, + pub owner: Option, + pub listeners: Vec, + pub health_probe: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AcceptanceOwner { + Named { user: String, group: String }, + Numeric { uid: u32, gid: u32 }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AcceptanceListener { + pub port: u16, + pub protocol: ListenerProtocol, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ListenerProtocol { + Tcp, + Udp, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HealthProbe { + Pihole, + Tailscale, +} + impl HostProfile { + /// Runtime checks follow the services and UI capabilities declared by + /// this profile, so fixture acceptance cannot stand in for application + /// acceptance. + pub fn acceptance_plan(&self) -> HostAcceptancePlan { + let mut services = self + .services + .iter() + .map(|service| acceptance_service(self.id.as_str(), service)) + .collect::>(); + if self.services.iter().any(|service| service.ui.is_some()) { + services.push(caddy_acceptance(self.id.as_str())); + } + HostAcceptancePlan { + requires_data_mount: self.requires_data_mount, + services, + } + } + pub fn supports_service(&self, name: &str) -> bool { self.services.iter().any(|service| service.name() == name) } @@ -211,6 +270,110 @@ impl HostProfile { } } +fn acceptance_service(profile: &str, service: &HostService) -> AcceptanceService { + match service.config { + ServiceConfig::Pihole { .. } => AcceptanceService { + unit: "pihole.service".into(), + container: Some("pihole".into()), + network_mode: Some(profile.into()), + bind_paths: vec![ + "/var/lib/data/pihole/etc".into(), + "/var/lib/data/pihole/log".into(), + ], + // Pi-hole may change ownership of its writable bind directories + // during startup. The host profile does not declare numeric + // ownership for these paths, so acceptance validates their mounts + // and runtime without inventing an owner contract. + owner: None, + listeners: [ListenerProtocol::Tcp, ListenerProtocol::Udp] + .map(|protocol| AcceptanceListener { port: 53, protocol }) + .into(), + health_probe: Some(HealthProbe::Pihole), + }, + ServiceConfig::Syncthing { + data_path, + data_owner, + data_group, + .. + } => AcceptanceService { + unit: "syncthing.service".into(), + container: Some("syncthing".into()), + network_mode: Some(profile.into()), + bind_paths: vec![data_path.into()], + owner: Some(AcceptanceOwner::Named { + user: data_owner.into(), + group: data_group.into(), + }), + listeners: [ListenerProtocol::Tcp, ListenerProtocol::Udp] + .map(|protocol| AcceptanceListener { + port: 22000, + protocol, + }) + .into(), + health_probe: None, + }, + ServiceConfig::Unifi => AcceptanceService { + unit: "unifi.service".into(), + container: Some("unifi".into()), + network_mode: Some("host".into()), + bind_paths: vec!["/var/lib/data/unifi".into()], + owner: Some(AcceptanceOwner::Numeric { uid: 999, gid: 999 }), + listeners: vec![ + AcceptanceListener { + port: 8080, + protocol: ListenerProtocol::Tcp, + }, + AcceptanceListener { + port: 8443, + protocol: ListenerProtocol::Tcp, + }, + AcceptanceListener { + port: 3478, + protocol: ListenerProtocol::Udp, + }, + AcceptanceListener { + port: 10001, + protocol: ListenerProtocol::Udp, + }, + ], + health_probe: None, + }, + ServiceConfig::Tailscale { state_path } => AcceptanceService { + unit: "tailscale.service".into(), + container: Some("tailscale".into()), + network_mode: Some("host".into()), + bind_paths: vec![state_path.into()], + owner: None, + listeners: Vec::new(), + health_probe: Some(HealthProbe::Tailscale), + }, + ServiceConfig::Btrbk { .. } => AcceptanceService { + unit: "btrbk.timer".into(), + container: None, + network_mode: None, + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + }, + } +} + +fn caddy_acceptance(profile: &str) -> AcceptanceService { + AcceptanceService { + unit: "caddy.service".into(), + container: Some("caddy".into()), + network_mode: Some(profile.into()), + bind_paths: vec!["/var/lib/data/caddy/data".into()], + owner: None, + listeners: vec![AcceptanceListener { + port: 443, + protocol: ListenerProtocol::Tcp, + }], + health_probe: None, + } +} + const CLAMPS_DNS: &[(&str, &str)] = &[("192.168.1.100", "my.custom.domain")]; const CLAMPS_SYNCTHING_UI: UiServiceDeclaration = UiServiceDeclaration { name: "syncthing", diff --git a/ublue/skillet/crates/vm/src/transport/tests.rs b/ublue/skillet/crates/vm/src/transport/tests.rs index 90db4fe2..12f2e978 100644 --- a/ublue/skillet/crates/vm/src/transport/tests.rs +++ b/ublue/skillet/crates/vm/src/transport/tests.rs @@ -73,6 +73,36 @@ fn ownership_loss_after_operation_is_reported() { assert_eq!(*operations.lock().unwrap(), ["execute"]); } +#[test] +fn ownership_loss_between_delivery_operations_blocks_the_next_operation() { + let operations = Arc::new(Mutex::new(Vec::new())); + let transport = FakeTransport(operations.clone()); + let checks = std::cell::Cell::new(0); + let validate = || { + checks.set(checks.get() + 1); + if checks.get() >= 3 { + return Err(Error::Invalid("ownership changed".into())); + } + Ok(()) + }; + let checked = OwnershipCheckedTransport::new(&transport, &validate); + checked + .execute( + &GuestCommand { + program: "install", + arguments: &[], + }, + None, + ) + .unwrap(); + let result = checked.upload(Path::new("unused"), "/var/tmp/file"); + assert!(result + .unwrap_err() + .to_string() + .contains("ownership changed")); + assert_eq!(*operations.lock().unwrap(), ["execute"]); +} + fn target(dir: &Path) -> SshTarget { let identity = dir.join("id_ed25519"); let known_hosts = dir.join("known_hosts"); diff --git a/ublue/skillet/integration_tests/smoke-ssh.sh b/ublue/skillet/integration_tests/smoke-ssh.sh deleted file mode 100755 index dedd771a..00000000 --- a/ublue/skillet/integration_tests/smoke-ssh.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -target= -fixture_binary= -fixture_sha256= -generic_sha256= -host_sha256= -host_binary= -host= -credentials_required= -port=22 -identity= -disposable=false -while (($#)); do - case "$1" in - --target) target=${2:?}; shift 2 ;; - --fixture-binary) fixture_binary=${2:?}; shift 2 ;; - --fixture-sha256) fixture_sha256=${2:?}; shift 2 ;; - --generic-sha256) generic_sha256=${2:?}; shift 2 ;; - --host-sha256) host_sha256=${2:?}; shift 2 ;; - --host-binary) host_binary=${2:?}; shift 2 ;; - --host) host=${2:?}; shift 2 ;; - --credentials-required) credentials_required=${2:?}; shift 2 ;; - --port) port=${2:?}; shift 2 ;; - --identity) identity=${2:?}; shift 2 ;; - --disposable-target) disposable=true; shift ;; - *) echo "unknown argument: $1" >&2; exit 2 ;; - esac -done - -if [[ -z $target || -z $fixture_binary || -z $fixture_sha256 || -z $generic_sha256 || -z $host_sha256 || -z $host_binary || -z $host || -z $credentials_required || $disposable != true ]]; then - echo 'usage: smoke-ssh.sh --target USER@DISPOSABLE_VM --host HOST --credentials-required yes|no [--port PORT] [--identity PRIVATE_KEY] --disposable-target --fixture-binary LOCAL_FIXTURE --fixture-sha256 HASH --generic-sha256 HASH --host-sha256 HASH --host-binary REMOTE_SKILLET_HOST' >&2 - exit 2 -fi -if [[ ! $target =~ ^[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+$ ]]; then - echo 'target must be an explicit USER@HOST or USER@IPv4 address' >&2 - exit 2 -fi -if [[ ! $host =~ ^[a-z][a-z0-9-]*$ || ! $credentials_required =~ ^(yes|no)$ ]]; then - echo 'invalid host profile or credential capability' >&2 - exit 2 -fi -if [[ $target == *@"$host" || $target == *@"$host".* ]] || - { [[ $target == *@localhost || $target == *@127.0.0.1 ]] && [[ $port == 22 ]]; }; then - echo "refusing the production host identity $host or a local SSH service on port 22" >&2 - exit 2 -fi -if [[ ! -f $fixture_binary || ! $fixture_sha256 =~ ^[a-f0-9]{64}$ || ! $generic_sha256 =~ ^[a-f0-9]{64}$ || ! $host_sha256 =~ ^[a-f0-9]{64}$ || $host_binary != /* || ! $host_binary =~ ^/[A-Za-z0-9_./-]+$ ]]; then - echo 'binary paths must name a local file and an absolute remote path' >&2 - exit 2 -fi -if [[ ! $port =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then - echo 'port must be between 1 and 65535' >&2 - exit 2 -fi -if [[ -n $identity && ! -f $identity ]]; then - echo 'identity must name an existing private-key file' >&2 - exit 2 -fi - -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -ssh_opts=(-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -p "$port") -scp_opts=(-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -P "$port") -if [[ -n $identity ]]; then - known_hosts="$(dirname -- "$identity")/known_hosts" - ssh_opts+=(-i "$identity" -o IdentitiesOnly=yes -o UserKnownHostsFile="$known_hosts") - scp_opts+=(-i "$identity" -o IdentitiesOnly=yes -o UserKnownHostsFile="$known_hosts") -fi -scp -O "${scp_opts[@]}" "$fixture_binary" "$target:/var/tmp/skillet-smoke-fixture" -scp -O "${scp_opts[@]}" "$script_dir/smoke-guest.sh" "$target:/var/tmp/skillet-smoke-guest.sh" -ssh "${ssh_opts[@]}" "$target" 'sudo install -m 0755 /var/tmp/skillet-smoke-fixture /var/usrlocal/bin/skillet-smoke-fixture' -fixture_remote_sha256=$(ssh "${ssh_opts[@]}" "$target" 'sha256sum /var/usrlocal/bin/skillet-smoke-fixture | cut -d " " -f 1') -if [[ $fixture_remote_sha256 != "$fixture_sha256" ]]; then - echo 'transferred fixture binary does not match the selected Cargo artifact' >&2 - exit 1 -fi -generic_remote_sha256=$(ssh "${ssh_opts[@]}" "$target" 'sha256sum /var/usrlocal/bin/skillet | cut -d " " -f 1') -if [[ $generic_remote_sha256 != "$generic_sha256" ]]; then - echo 'installed generic binary does not match the ready VM manifest' >&2 - exit 1 -fi -# shellcheck disable=SC2029 -host_remote_sha256=$(ssh "${ssh_opts[@]}" "$target" "sha256sum '$host_binary' | cut -d ' ' -f 1") -if [[ $host_remote_sha256 != "$host_sha256" ]]; then - echo 'installed host binary does not match the ready VM manifest' >&2 - exit 1 -fi -boot_before=$(ssh "${ssh_opts[@]}" "$target" 'cat /proc/sys/kernel/random/boot_id') -# The remote path is validated above and intentionally expanded on the client. -# shellcheck disable=SC2029 -ssh "${ssh_opts[@]}" "$target" "sudo bash /var/tmp/skillet-smoke-guest.sh before-reboot '$host_binary' '$credentials_required'" -ssh "${ssh_opts[@]}" "$target" 'sudo systemctl reboot' || true - -deadline=$((SECONDS + 240)) -while (( SECONDS < deadline )); do - boot_after=$(ssh "${ssh_opts[@]}" "$target" 'cat /proc/sys/kernel/random/boot_id' 2>/dev/null || true) - if [[ -n $boot_after && $boot_after != "$boot_before" ]]; then - # shellcheck disable=SC2029 - ssh "${ssh_opts[@]}" "$target" "sudo bash /var/tmp/skillet-smoke-guest.sh after-reboot '$host_binary' '$credentials_required'" - exit 0 - fi - sleep 5 -done -echo 'VM did not return after reboot within 240 seconds' >&2 -exit 1 From 4b92b5833d353d50354a3bfe6ebee7e02f929cc9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 14:58:43 +0200 Subject: [PATCH 193/252] test(core): align resource fakes with runtime contracts --- ublue/skillet/crates/btrbk/src/tests.rs | 85 +++++++++- ublue/skillet/crates/core/src/system/tests.rs | 14 ++ ublue/skillet/crates/core/src/test_utils.rs | 154 ++++++++++++++++-- ublue/skillet/crates/pihole/src/tests.rs | 7 + ublue/skillet/crates/syncthing/src/tests.rs | 11 ++ ublue/skillet/crates/unifi/src/tests.rs | 16 +- 6 files changed, 273 insertions(+), 14 deletions(-) diff --git a/ublue/skillet/crates/btrbk/src/tests.rs b/ublue/skillet/crates/btrbk/src/tests.rs index c088a864..afd889a6 100644 --- a/ublue/skillet/crates/btrbk/src/tests.rs +++ b/ublue/skillet/crates/btrbk/src/tests.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; #[test] fn renders_only_caller_selected_subvolume() { let files = MockFiles::new(); + setup_data_storage(&files, true); let system = MockSystem::new(); apply( &system, @@ -34,9 +35,11 @@ fn rejects_parent_absolute_and_traversal_paths() { "with space", "bad\nvalue", ] { + let files = MockFiles::new(); + setup_data_storage(&files, false); let error = apply( &MockSystem::new(), - &MockFiles::new(), + &files, &BtrbkConfig { snapshot_subvolumes: vec![PathBuf::from(invalid)], }, @@ -67,6 +70,7 @@ fn empty_config_is_an_opt_out() { #[test] fn wrong_data_mount_fails_before_any_state_is_written() { let files = MockFiles::new(); + setup_data_storage(&files, true); files .fail_btrfs_mount_check .store(true, std::sync::atomic::Ordering::SeqCst); @@ -97,6 +101,7 @@ fn read(files: &MockFiles, path: &str) -> String { #[test] fn repeated_apply_keeps_managed_state_and_timer_running() { let files = MockFiles::new(); + setup_data_storage(&files, true); let system = MockSystem::new(); let config = BtrbkConfig { snapshot_subvolumes: vec![PathBuf::from("syncthing")], @@ -110,3 +115,81 @@ fn repeated_apply_keeps_managed_state_and_timer_running() { 1 ); } + +#[test] +fn ordinary_directory_cannot_be_used_as_snapshot_subvolume() { + let files = MockFiles::new(); + setup_data_storage(&files, false); + files + .directories + .lock() + .unwrap() + .insert("/var/lib/data/syncthing".into()); + let result = apply( + &MockSystem::new(), + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ); + assert!(matches!(result, Err(BtrbkError::File(_)))); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +#[test] +fn missing_data_mount_fails_before_state_is_written() { + let files = MockFiles::new(); + let result = apply( + &MockSystem::new(), + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ); + assert!(result.is_err()); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +#[test] +fn data_mount_on_a_different_device_from_var_is_rejected() { + let files = MockFiles::new(); + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/root", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/data", + "/data", + "btrfs", + ); + files.record_btrfs_subvolume(std::path::Path::new("/var/lib/data/syncthing")); + let result = apply( + &MockSystem::new(), + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ); + assert!(result.is_err()); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +fn setup_data_storage(files: &MockFiles, include_source: bool) { + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/test", + "/data", + "btrfs", + ); + if include_source { + files.record_btrfs_subvolume(std::path::Path::new("/var/lib/data/syncthing")); + } +} diff --git a/ublue/skillet/crates/core/src/system/tests.rs b/ublue/skillet/crates/core/src/system/tests.rs index 89a1588e..d1272366 100644 --- a/ublue/skillet/crates/core/src/system/tests.rs +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -96,12 +96,26 @@ fn service_resource_contract(resource: &impl super::ServiceResource) { assert!(!resource.service_is_active("contract.service").unwrap()); resource.service_start("contract.service").unwrap(); assert!(resource.service_is_active("contract.service").unwrap()); + resource.service_reload("contract.service").unwrap(); + assert!(resource.service_is_active("contract.service").unwrap()); resource.service_restart("contract.service").unwrap(); assert!(resource.service_is_active("contract.service").unwrap()); resource.service_stop("contract.service").unwrap(); assert!(!resource.service_is_active("contract.service").unwrap()); } +#[test] +fn mock_reload_preserves_active_state_and_rejects_inactive_or_missing_units() { + use crate::{system::ServiceResource, test_utils::MockSystem}; + let system = MockSystem::new(); + assert!(system.service_reload("missing.service").is_err()); + system.service_enable("inactive.service").unwrap(); + assert!(system.service_reload("inactive.service").is_err()); + system.service_start("active.service").unwrap(); + system.service_reload("active.service").unwrap(); + assert!(system.service_is_active("active.service").unwrap()); +} + fn podman_secret_resource_contract(resource: &impl super::PodmanSecretResource) { assert!(resource.ensure_podman_secret("contract", "first").unwrap()); let first = resource.podman_secret_id("contract").unwrap(); diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index d64aaa3b..c732f02b 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -21,7 +21,7 @@ pub struct MockSystem { pub fail_reload_once: Arc, pub restart_count: Arc, pub start_count: Arc, - pub services: Arc>>, // name -> state (started, stopped, restarted) + pub services: Arc>>, // name -> state } impl MockSystem { @@ -190,7 +190,7 @@ impl ServiceResource for MockSystem { .unwrap_or_else(std::sync::PoisonError::into_inner) .get(name) .map(String::as_str), - Some("started" | "restarted") + Some("started" | "restarted" | "reloaded") )) } @@ -227,11 +227,20 @@ impl ServiceResource for MockSystem { } fn service_reload(&self, name: &str) -> Result<(), SystemError> { - self.services + let mut services = self + .services .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(name.to_string(), "reloaded".to_string()); - Ok(()) + .unwrap_or_else(std::sync::PoisonError::into_inner); + match services.get(name).map(String::as_str) { + Some("started" | "restarted" | "reloaded") => { + services.insert(name.to_string(), "reloaded".to_string()); + Ok(()) + } + Some(state) => Err(SystemError::Command(format!( + "cannot reload inactive service {name} (state: {state})" + ))), + None => Err(SystemError::Command(format!("service {name} is missing"))), + } } fn service_enable(&self, name: &str) -> Result<(), SystemError> { @@ -260,11 +269,21 @@ impl ServiceResource for MockSystem { pub type FileMetadata = (Option, Ownership); pub type DirectoryMetadata = (Option, Ownership); pub type DirectoryMetadataMap = Arc>>; +pub type BtrfsMountMap = Arc>>; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BtrfsMount { + pub device: String, + pub root: String, + pub filesystem: String, +} pub struct MockFiles { pub files: Arc>>>, pub metadata: Arc>>, pub directories: Arc>>, + pub btrfs_mounts: BtrfsMountMap, + pub btrfs_subvolumes: Arc>>, pub directory_metadata: DirectoryMetadataMap, pub fail_btrfs_mount_check: Arc, pub fail_file_write_once: Arc, @@ -277,6 +296,8 @@ impl MockFiles { files: Arc::new(Mutex::new(HashMap::new())), metadata: Arc::new(Mutex::new(HashMap::new())), directories: Arc::new(Mutex::new(HashSet::new())), + btrfs_mounts: Arc::new(Mutex::new(HashMap::new())), + btrfs_subvolumes: Arc::new(Mutex::new(HashSet::new())), directory_metadata: Arc::new(Mutex::new(HashMap::new())), fail_btrfs_mount_check: Arc::new(AtomicBool::new(false)), fail_file_write_once: Arc::new(AtomicBool::new(false)), @@ -291,26 +312,135 @@ impl Default for MockFiles { } } +impl MockFiles { + /// Declare a mount-table entry for storage contract tests. + pub fn record_btrfs_mount(&self, path: &Path, device: &str, root: &str, filesystem: &str) { + let path = path.display().to_string(); + self.directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(path.clone()); + self.btrfs_mounts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + path, + BtrfsMount { + device: device.to_string(), + root: root.to_string(), + filesystem: filesystem.to_string(), + }, + ); + } + + /// Declare an existing Btrfs subvolume, including its directory ancestors. + pub fn record_btrfs_subvolume(&self, path: &Path) { + let path = path.display().to_string(); + self.btrfs_subvolumes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(path.clone()); + let mut directories = self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut current = Path::new(&path); + while let Some(parent) = current.parent() { + directories.insert(parent.display().to_string()); + current = parent; + } + directories.insert(path); + } +} + impl StorageResource for MockFiles { fn require_btrfs_subvolume_mount( &self, path: &Path, - _backing_mount: &Path, - _subvolume_root: &str, + backing_mount: &Path, + subvolume_root: &str, ) -> Result<(), FileError> { if self.fail_btrfs_mount_check.load(Ordering::SeqCst) { Err(FileError::WrongMount(path.display().to_string())) } else { - Ok(()) + let mounts = self + .btrfs_mounts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let path_text = path.display().to_string(); + let mount = mounts + .get(&path_text) + .ok_or_else(|| FileError::MountMissing(path_text.clone()))?; + let backing = mounts.get(&backing_mount.display().to_string()); + if mount.filesystem == "btrfs" + && mount.root == subvolume_root + && backing.is_some_and(|backing| { + backing.device == mount.device && backing.filesystem == "btrfs" + }) + { + Ok(()) + } else { + Err(FileError::WrongMount(path_text)) + } } } - fn require_btrfs_subvolume(&self, _path: &Path) -> Result<(), FileError> { - Ok(()) + fn require_btrfs_subvolume(&self, path: &Path) -> Result<(), FileError> { + if self + .btrfs_subvolumes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&path.display().to_string()) + { + Ok(()) + } else { + Err(FileError::NotASubvolume(path.display().to_string())) + } } fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { - FileMutationResource::ensure_directory(self, path, None, &Ownership::default()) + let path_text = path.display().to_string(); + if self + .btrfs_subvolumes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&path_text) + { + return Ok(false); + } + if self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&path_text) + || self + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&path_text) + { + return Err(FileError::NotASubvolume(path_text)); + } + let parent = path + .parent() + .ok_or_else(|| FileError::InvalidPath(path_text.clone()))?; + if !self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&parent.display().to_string()) + { + return Err(FileError::ParentMissing(path_text)); + } + self.btrfs_subvolumes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(path_text.clone()); + self.directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(path_text); + Ok(true) } } diff --git a/ublue/skillet/crates/pihole/src/tests.rs b/ublue/skillet/crates/pihole/src/tests.rs index 09d1346d..da9b43df 100644 --- a/ublue/skillet/crates/pihole/src/tests.rs +++ b/ublue/skillet/crates/pihole/src/tests.rs @@ -10,6 +10,13 @@ use std::collections::BTreeMap; fn pihole_uses_dual_stack_dns_network_and_registry_updates() { let system = MockSystem::new(); let files = MockFiles::new(); + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/test", + "/data", + "btrfs", + ); system .ensure_podman_secret("pihole_web_password", "dummy") .unwrap(); diff --git a/ublue/skillet/crates/syncthing/src/tests.rs b/ublue/skillet/crates/syncthing/src/tests.rs index fe6e1308..3d59825f 100644 --- a/ublue/skillet/crates/syncthing/src/tests.rs +++ b/ublue/skillet/crates/syncthing/src/tests.rs @@ -5,6 +5,7 @@ use skillet_core::test_utils::{MockFiles, MockSystem}; fn syncthing_uses_persistent_data_and_shared_dns_network() { let system = MockSystem::new(); let files = MockFiles::new(); + setup_data_mount(&files); apply( &system, @@ -46,3 +47,13 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { "the GUI must only be reachable through the private reverse proxy" ); } + +fn setup_data_mount(files: &MockFiles) { + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/test", + "/data", + "btrfs", + ); +} diff --git a/ublue/skillet/crates/unifi/src/tests.rs b/ublue/skillet/crates/unifi/src/tests.rs index 3b0f4593..c704afd5 100644 --- a/ublue/skillet/crates/unifi/src/tests.rs +++ b/ublue/skillet/crates/unifi/src/tests.rs @@ -9,6 +9,7 @@ use std::sync::atomic::Ordering; fn unifi_uses_host_network_and_persistent_numeric_owned_data() { let system = MockSystem::new(); let files = MockFiles::new(); + setup_data_mount(&files); apply(&system, &files).unwrap(); @@ -44,17 +45,30 @@ fn unifi_uses_host_network_and_persistent_numeric_owned_data() { fn unifi_rejects_a_missing_data_mount_before_writing_state() { let system = MockSystem::new(); let files = MockFiles::new(); + setup_data_mount(&files); files.fail_btrfs_mount_check.store(true, Ordering::SeqCst); + let initial_directories = files.directories.lock().unwrap().clone(); assert!(apply(&system, &files).is_err()); - assert!(files.directories.lock().unwrap().is_empty()); + assert_eq!(*files.directories.lock().unwrap(), initial_directories); assert!(files.files.lock().unwrap().is_empty()); } +fn setup_data_mount(files: &MockFiles) { + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/test", + "/data", + "btrfs", + ); +} + #[test] fn repeated_unifi_apply_does_not_restart_an_unchanged_container() { let system = MockSystem::new(); let files = MockFiles::new(); + setup_data_mount(&files); apply(&system, &files).unwrap(); let restart_count = system.restart_count.load(Ordering::SeqCst); From 0985c1d61f55ff6fff2945beec759f8883825be2 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 14:58:57 +0200 Subject: [PATCH 194/252] ci: run bootstrap state regression suite --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e4d4638..887c013b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,3 +102,7 @@ jobs: run: | cd ublue/skillet cargo run --bin skillet -- test run beezelbot --phase base --image fedora:latest + + - name: Run bootstrap state regressions + if: steps.filter.outputs.rust == 'true' + run: bash ublue/butane/tests/bootstrap.sh From 161ba63e4954aa6675f8987a534e6f479ad4f89f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 14:59:15 +0200 Subject: [PATCH 195/252] docs: track refactor follow-up acceptance --- ublue/AGENTS.md | 3 + ublue/butane/ACCEPTANCE.md | 30 +++ ublue/butane/README.md | 39 ++-- ublue/design/private-ui-access.md | 5 +- ublue/design/secrets.md | 6 +- ublue/design/skillet-architecture.md | 21 +- ublue/design/smoke-vms.md | 6 +- ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md | 31 ++- ublue/plan/REFACTOR-FOLLOWUP.md | 184 ++++++++++++++++++ ublue/plan/SKILLET-REFACTOR.md | 12 +- ublue/skillet/AGENTS.md | 46 ++--- ublue/skillet/README.md | 25 ++- 12 files changed, 335 insertions(+), 73 deletions(-) create mode 100644 ublue/plan/REFACTOR-FOLLOWUP.md diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 89672f7a..e48f4a81 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -81,6 +81,9 @@ from `.github/workflows/ci.yml`. For shell changes, run the workflow's ShellCheck commands; for Python or Chef changes, run the corresponding Ruff/Mypy or Cookstyle commands. +- When Butane bootstrap inputs or tests change, also run + `bash ublue/butane/tests/bootstrap.sh` from the repository root. CI must + execute state-regression scripts as tests, not only lint their source. - For changes to container behavior, also run the affected host's real-runtime smoke test against a named disposable VM as required by `skillet/AGENTS.md`. - If a required check cannot run because a tool, runtime, or external service diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 61429248..b8346f0e 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,35 @@ # Clamps VM acceptance log +## Refactor follow-up, 2026-10-05 + +- Passed `cargo fmt --all -- --check`, strict workspace Clippy, workspace + all-target tests, the CI Fedora container command + (`cargo run --bin skillet -- test run beezelbot --phase base --image + fedora:latest`), bootstrap state regressions, and the workflow ShellCheck + command. Exact commands are in `../AGENTS.md` and `../skillet/AGENTS.md`. +- Existing `clamps/smoke` ownership was verified with + `cargo run --release -p skillet -- test vm status clamps smoke`. `test vm + update clamps smoke` delivered current binaries, and `test vm ready clamps + smoke` passed. Then `cargo run --release -p skillet -- test smoke clamps + --instance smoke` passed fixture convergence, pre/post-reboot assertions, + new boot detection, captured binary verification, and readiness recovery. +- The opt-in profile acceptance implementation has injected coverage for + active services, container network, bind paths, named versus numeric host + ownership, listeners, health probes, repeat-apply identity, data mount, and + reboot persistence. Live attempts exposed incorrect assumptions that were + corrected: Pi-hole owns its writable directories at runtime, and Syncthing's + declared host account differs from its container UID. The final live + `--with-applications` pass remains pending because the current VM lacks + credential-dependent Caddy provisioning. Reprovisioning reached the + KeePassXC password prompt; it was stopped without entering a password or + making provider changes. Resume after unlocking KeePassXC, then rerun + `cargo run --release -p skillet -- test vm provision clamps smoke --with-ui` + followed by `cargo run --release -p skillet -- test smoke clamps --instance + smoke --with-applications`. +- Remote CI is pending until these working-tree changes are pushed. Production + renewal, external-network denial, and interrupted-reboot fault injection + remain outside this local acceptance result. + ## Refactoring and disposable UI revalidation, 2026-10-04 - Passed `cargo fmt --all -- --check`, strict workspace Clippy, and diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 0ab6d9fc..5d3a529d 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -26,15 +26,15 @@ executables for a normal host install. The VM launcher removes the two large file entries from its temporary Ignition copy and delivers those same binaries over SSH after the first boot. -On Bazzite, the helper uses the existing Flatpak virt-manager installation -when native libvirt is absent. The `bin/virsh` and `bin/virt-install` wrappers -run those tools in the Flatpak sandbox. The helper starts separate Flatpak -libvirt daemons under `/run/user/UID/skvm`, grants them access to `butane/`, -and uses QEMU user networking to forward the selected localhost SSH port. -The selected backend is recorded in `runs/NAME/run.conf`. Check that `/dev/kvm` -is accessible on the host. Native libvirt uses the existing `qemu:///session` -connection when available, so those VMs appear in virt-manager's user session. -The helper does not install host software. +The VM command selects native user-session libvirt when its tools and daemons +are available, then uses the existing Flatpak virt-manager runtime otherwise. +Flatpak libvirt daemons use `/run/user/UID/skvm` and QEMU user networking to +forward the selected localhost SSH port. The selected backend, connection URI, +and runtime directory are recorded in `runs/NAME/vm.json`. Use +`test vm status HOST INSTANCE` to inspect the selected connection. Native runs +normally use `qemu:///session`; a fallback runtime directory is also recorded +in the manifest. Check that `/dev/kvm` is accessible on the host. The helper +does not install host software. On Rocky Linux, if QEMU startup reports `cannot limit core file size ... to 18446744073709551615`, set `max_core = 0` in @@ -131,15 +131,10 @@ the default is `images/coreos.qcow2` when present. Both VM backends forward the SSH port on `127.0.0.1`; native libvirt uses passt and Flatpak uses QEMU user networking. -When native libvirt is available, `bin/test-vm` starts a separate user daemon -with `XDG_RUNTIME_DIR=/run/user/UID/skillet-test-libvirt`. For manual virsh -inspection of a native helper-created VM, set that variable on each call. -For Flatpak runs, use the same isolated runtime when invoking `virsh` by hand: - -```bash -TEST_VM_LIBVIRT_RUNTIME_DIR="/run/user/$(id -u)/skvm" \ - ./bin/virsh -c qemu:///session list --all -``` +Use `skillet test vm status HOST INSTANCE` and `skillet test vm logs HOST +INSTANCE` for supported VM inspection. The run manifest is the authority for +the libvirt URI and runtime directory; do not guess these values from the VM +name. Connect with: @@ -184,11 +179,9 @@ ssh -i runs/NAME/ssh/id_ed25519 -p PORT giacomo@127.0.0.1 \ Other commands check the recorded host, domain UUID, and disk before acting. `destroy` removes only the selected test-owned resources. -The VM and its disk remain available after readiness checks. Cleanup is -explicit: inspect the domain and disk, then call -`./bin/test-vm clamps destroy example` for a helper-created run. For a -plain launcher run, shut down and undefine the named test domain with virsh, -then remove only its recorded `runs/NAME` directory after checking `domblklist`. +The VM and its disk remain available after readiness checks. Destroy runs with +`skillet test vm destroy HOST INSTANCE` so external enrollment/DNS cleanup and +the owned libvirt resources follow the recorded recovery journal. Run the local bootstrap state regression checks with `bash tests/bootstrap.sh`. They exercise mocked deployment states and do not create or modify a VM. diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index 104a411f..867e6f14 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -87,8 +87,9 @@ restrictions. Shared Butane units call the generic `skillet` CLI and read the stable host profile from `/etc/skillet/host`; VM hostnames remain independent. Host-specific apply units and credential gates live in host-specific Butane includes. Manual delivery accepts production or test environments. -Cloudflare token/DNS lifecycle and opt-in VM provisioning are implemented; -real ACME and HTTPS acceptance remain pending. See the +Cloudflare token/DNS lifecycle and opt-in VM provisioning are implemented. +Staging ACME and HTTPS acceptance passed on 2026-10-02; production renewal and +outside-tailnet access remain pending. See the [implementation plan](../plan/GENERIC-PRIVATE-UIS.md). Skillet configures host-network Tailscale and manages tagged enrollment and diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index b1180ad6..95e90a57 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -4,7 +4,8 @@ UI provisioning reads a mandatory Cloudflare Zone ID and optional relative UI prefix from the selected environment in KeePassXC, fetches the zone domain, and derives service names from the host's declarations, and uses shared credential delivery. Cloudflare token issuance, DNS lifecycle, -and smoke VM cleanup are implemented; live ACME acceptance remains pending. +and smoke VM cleanup are implemented. Staging ACME and HTTPS acceptance passed +on 2026-10-02; production renewal remains pending. See the [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md). Decision: use KeePassXC for durable secrets and read and write its KDBX database @@ -137,7 +138,8 @@ unique test names prevent record collisions, not access to other records. On destroy, Skillet issues a short-lived cleanup token, removes only records with the instance ownership marker, and revokes disposable tokens. Keep literal zone names and records outside this public repository. The token creator -remains on the workstation. Live ACME and HTTPS acceptance is still pending. +remains on the workstation. Staging ACME and HTTPS acceptance passed on +2026-10-02; production renewal and external access remain pending. VM Pi-hole secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, expiry, and owned record IDs in run metadata, without token values. Existing diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index e3137a90..4edc8a0f 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -3,9 +3,12 @@ Decision, 2026-10-03: complete the seven refactoring workstreams before further service migrations, encryption implementation, or production cutover. Existing service behavior and unfinished acceptance requirements remain authoritative. -Status: implementation is complete for workstreams 1–7. Workstream 3's named-VM -ownership and mount acceptance remains pending; required named-VM and production -acceptance has not passed. +Status: follow-up implementation is complete; interrupted-reboot fault +injection, fully provisioned host acceptance, and remote CI remain pending. +Rust-owned fixture smoke and retained VM update passed locally; see the +[follow-up plan](../plan/REFACTOR-FOLLOWUP.md) and +[acceptance log](../butane/ACCEPTANCE.md). Production acceptance remains +explicitly deferred. Implementation progress: `skillet_vm` now supplies validated run identity and versioned manifests with explicit, non-destructive legacy import. Rust status @@ -26,8 +29,11 @@ provider mutations, and recovery cleanup; the CLI opens the vault, loads the canonical VM manifest, and dispatches typed operations. Disposable provisioning holds the VM run lock and revalidates recorded backend ownership around each guest operation through a shared checked transport. Transport's bounded I/O -stays in memory; payload copies are zeroized after stdin transfer. Named-VM -and production acceptance remain pending. +stays in memory; payload copies are zeroized after stdin transfer. Earlier +fixture and provisioning acceptance passed. Rust now owns smoke reboot and +capability-selected application acceptance. The latter has passing injected +tests; the final live pass still requires test UI credentials delivered after +KeePassXC is unlocked. Production acceptance remains deferred. Workstream 2 adds `skillet_hosts` as the single host capability declaration. It owns host composition and supplies boot, service, network, UI, storage, and @@ -44,8 +50,9 @@ missing or invalid subordinate ranges fail closed. File and directory mutations share the typed named-or-numeric `Ownership` contract, including numeric IDs that do not require a matching host account. Entry points pass required systemd credentials to host composition. Host composition retains -aggregate effect interfaces during migration. Named-VM ownership and mount -acceptance remains pending. +aggregate effect interfaces during migration. The completion audit added +stricter Btrfs and service-state behavior to the fakes; those contract +corrections are tracked by the [follow-up plan](../plan/REFACTOR-FOLLOWUP.md). Keep application crates as reusable recipes. Separate canonical host profiles and composition from guest runtime adapters, CLI parsing, workstation diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 34f41e17..8791a130 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -56,7 +56,11 @@ Ignition. The generated UUID is part of native XML and Flatpak creation. Native definition and start are separate manifest phases; failed starts retain the defined domain for inspection and retry. Native domain XML renders through the Rust XML library, preserving disk ownership, fw_cfg and passt networking. Unit -and adapter checks pass; live migration acceptance remains deferred. +and adapter checks pass. Rust now owns fixture smoke delivery, guarded guest +operations, reboot detection, and readiness recovery. The named fixture smoke +and retained update passed on 2026-10-05. Fully provisioned application +acceptance remains pending in the [follow-up plan](../plan/REFACTOR-FOLLOWUP.md) +and [acceptance log](../butane/ACCEPTANCE.md). Tailscale enrollment intent, enrolled-device identity, and Cloudflare DNS/token ownership metadata are persisted by `skillet_workstation::provisioning_state`. diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md index f9762245..0ba346f8 100644 --- a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -1,12 +1,16 @@ # 7. Test layers, fixtures, build artifacts and CI -Status: implementation and required CI/fresh/retained VM acceptance complete. -Live legacy import and controlled interrupted-recovery checks passed; see the -[prerequisite roadmap](SKILLET-REFACTOR.md). +Status: follow-up implementation complete, live acceptance remains open, +2026-10-05. Rust-owned smoke, storage/service fake contracts, bootstrap CI +execution, and capability-selected application acceptance are implemented. +The named fixture smoke and retained update passed; fully provisioned +application acceptance and interrupted-reboot fault injection remain open in +the [follow-up plan](REFACTOR-FOLLOWUP.md). Live legacy import and controlled +interrupted-provisioning recovery passed; see the [prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate -- `../skillet/integration_tests/{smoke-ssh,smoke-guest}.sh`, the synthetic host +- `../skillet/integration_tests/smoke-guest.sh`, the synthetic host fixture, CLI container runner and `src/test_entrypoint.sh`. - Revised resource fakes, VM manifest/transport, profiles, provisioning and recording interfaces from workstreams 1–6. @@ -18,7 +22,24 @@ were conflated; target/artifact selection assumed default paths/instances; fakes disagreed with adapter behavior; extensionless helpers and Butane-only changes missed relevant CI. Shell code was embedded in a production Rust fixture. -## Progress, 2026-10-04 +## Follow-up progress, 2026-10-05 + +- Rust now owns the complete fixture smoke sequence under one VM lock and + checked transport. A named `clamps/smoke` run passed fixture convergence, + reboot detection, captured artifact verification, and readiness recovery. +- Update delivery uses ownership-checked transport. The named retained VM + accepted a current binary update and subsequent readiness check. +- Storage/service fakes distinguish Btrfs mounts/subvolumes and preserve + service reload state. Bootstrap regressions run in CI and passed locally. +- Capability-selected application acceptance and injected failure tests are + implemented. Live checks corrected ownership expectations: Syncthing data + uses its declared host account; its numeric container UID is separate. A + final live application pass awaits test UI provisioning through KeePassXC. +- Workspace formatting, strict Clippy, workspace tests, the Fedora container + integration, bootstrap state tests, and workflow ShellCheck passed locally. + Remote CI is pending until push. + +## Earlier progress, 2026-10-04 - Moved the synthetic application out of the production CLI module into the dedicated `skillet-smoke-fixture` package and binary. Its guest entrypoint is diff --git a/ublue/plan/REFACTOR-FOLLOWUP.md b/ublue/plan/REFACTOR-FOLLOWUP.md new file mode 100644 index 00000000..5bb93e3f --- /dev/null +++ b/ublue/plan/REFACTOR-FOLLOWUP.md @@ -0,0 +1,184 @@ +# Refactor follow-up: close the completion audit gaps + +Status: implementation substantially complete, 2026-10-05. Code and local +regression coverage for all six findings are in place. Rust-owned fixture smoke +and retained-VM update passed on the named clamps VM. Capability-selected host +application acceptance is implemented and its local tests pass; live acceptance +remains pending because test UI provisioning requires an interactive KeePassXC +unlock. Remote CI evidence also remains pending until these changes are pushed. +Keep the refactoring gate open until those checks are recorded. + +## Progress + +| Step | Implementation | Evidence still needed | +| --- | --- | --- | +| 1. Rust-owned smoke lifecycle | Implemented; named `clamps/smoke` fixture and reboot acceptance passed | Interrupted-reboot recovery fault scenario | +| 2. Update delivery guard | Implemented; ownership-loss/retry tests and retained-VM update passed | None locally; remote CI pending | +| 3. Fake contracts | Implemented; workspace regression suite passes | None locally; remote CI pending | +| 4. Bootstrap CI | Implemented; CI runs state regressions and local command passes | Observe remote CI after push | +| 5. Host application acceptance | Implemented from canonical profile capabilities; local tests pass | Named fully provisioned VM with vault-dependent UI services, repeat apply, and reboot acceptance | +| 6. Documentation | Reconciled implementation, commands, design status, and acceptance evidence | Final review and remote CI result | + +## Start here + +Read `../AGENTS.md`, `../skillet/AGENTS.md`, `SKILLET-REFACTOR.md`, +`REFACTOR-07-TEST-INFRASTRUCTURE.md`, and the architecture, smoke-VM, secrets, +and private-UI designs. Inspect current source and working-tree changes before +editing; file locations below identify responsibilities, not frozen interfaces. + +Work in the order below, with focused commits after validation. Keep public +command syntax and retained manifests compatible. Use existing native/Flatpak +backends and toolchains; install nothing on the workstation. Libraries use +`thiserror`; never embed shell programs in Rust. Routine tests need no vault, +provider access, or hypervisor. Live checks use an explicitly named disposable +VM and the existing vault-unlock workflow. If unlock would block, continue +independent work and record the live check as pending. + +## 1. Move smoke operations under the Rust lifecycle owner + +Inspect `skillet/crates/cli/src/main.rs` (`run_smoke`, `SmokeLifecycle`), +`crates/vm/src/{transport,readiness,backend,manifest}.rs`, and +`integration_tests/smoke-guest.sh`. The former `smoke-ssh.sh` lifecycle owner +has been removed. + +- Hold the per-run lock for the complete smoke workflow. Load SSH trust, + identity, phase, and deployed artifact hashes from the validated manifest. + Keep optional CLI overrides as equality checks against those values. +- Use `OwnershipCheckedTransport` for every upload and guest command. Validate + live UUID/disk ownership after artifact compilation and before guest contact. + Refuse later remote operations as soon as ownership validation fails. +- Move fixture/script delivery, installed-artifact verification, reboot, and + bounded return detection into shared Rust orchestration. Guest assertion + scripts may remain standalone; they receive explicit validated inputs and + must not own SSH, lifecycle retries, or reboot. +- Persist the non-ready checkpoint before reboot. Verify that a new boot + occurred and the selected profile's readiness checks pass before restoring + Ready. Preserve diagnostics and retry state after failed reboot/acceptance. + Reuse existing locked helpers without recursively acquiring the same lock. +- Retired `smoke-ssh.sh`; its caller and AGENTS runtime instructions now use + the authoritative Rust command. + +Exit: tests show ownership loss before/after a remote operation prevents later +operations; the run lock excludes competing mutations; reboot failure leaves +the run non-ready; successful smoke uses recorded host-key verification and +the selected instance. A named VM passes fixture checks and reboot through the +Rust owner. No remaining smoke wrapper owns SSH/reboot/wait sequencing. + +## 2. Guard binary update delivery + +Inspect `skillet/crates/cli/src/vm.rs` (`update`) and +`crates/vm/src/{delivery,transport}.rs`. + +- Retain the existing run lock and build-result artifact selection. Wrap update + delivery in the shared ownership-checked transport after the build. +- Revalidate ownership before saving deployed hashes. Preserve captured hashes + and the previous deployment record if transfer or ownership validation fails. +- Add a regression scenario where ownership disappears between delivery calls: + no subsequent upload/install occurs and success is not recorded. Also cover + a partial transfer followed by a successful retry. + +Exit: all update guest operations use the same checks as readiness/provisioning; +successful delivery verifies installed bytes/metadata before recording hashes. +A retained disposable VM can update and rerun smoke with the new hashes. + +## 3. Make storage and service fakes honor their contracts + +Inspect `skillet/crates/core/src/test_utils.rs`, storage/service traits and +real adapters, their contract tests, and host/storage/btrbk tests. + +- Model directories, Btrfs subvolumes, and mounted subvolumes distinctly. Require + existence and the requested backing mount/subvolume root; ordinary directories + must not satisfy subvolume requirements. Expose explicit fixture setup instead + of default success. Update host fixtures to declare their storage prerequisites. +- Preserve directory metadata and existing failure injection. Make unsupported + fake operations return an explicit error. +- Reload must preserve a running service's active state. Inspect the real + adapter's inactive/missing-service behavior and reproduce it in the fake; + reload is not an implicit start. +- Add meaningful contract checks for missing paths, wrong resource type, + mismatched mount/root, repeated convergence, failed effects, and reload state. + Ensure recorder-wrapped fakes preserve those results. Do not mutate the + workstation's mounts or services to exercise contracts. + +Exit: full injected host composition still passes with explicit valid fixtures; +invalid storage fails; btrbk cannot accept a plain directory as its snapshot +source; a successful reload does not falsely make a running service inactive. + +## 4. Execute bootstrap regressions in CI + +Inspect `../../.github/workflows/ci.yml`, `butane/tests/bootstrap.sh`, and the +workflow's existing input filters and shell checks. + +- Add an explicit step running `bash ublue/butane/tests/bootstrap.sh` from the + repository root. Keep it credential-free and independent of libvirt. +- Ensure changes to bootstrap scripts, included units/configuration, test + fixtures, and the workflow select the job. Retain lint coverage for executable + helpers regardless of extension. +- Run the same bootstrap command locally and update the exact required-check + commands in AGENTS if necessary. + +Exit: CI executes the state regressions, rather than merely linting them, and +shared bootstrap input changes trigger that coverage. Record local evidence; +remote CI remains unverified until a run is observed. + +## 5. Add actual host acceptance selected by capabilities + +Inspect `skillet/crates/host-profiles`, host composition, service declarations, +the new Rust smoke owner, and `integration_tests/smoke-guest.sh`. + +- Keep synthetic fixture acceptance and actual application acceptance explicit + in commands/output. Define reusable acceptance expectations from canonical + profile/service declarations; do not hard-code one host or duplicate its + recipe configuration in an unrelated test registry. +- Check declared systemd/container services, mounts and ownership, container + network mode, published listeners, and applicable application health. Use + focused guest commands or a standalone guest assertion helper, invoked only + through the guarded Rust transport. Redact credentials and private UI names. +- Compare managed bytes/metadata and relevant runtime identity around repeat + apply. After reboot, check the declared services and persistent application + data again. Exclude logs, caches, and counters from equality comparisons. +- Make credential-dependent application checks an explicit provisioned mode. + Missing prerequisites fail that mode clearly; fixture success must not imply + application success. External ACME/enrollment checks stay opt-in. +- Unit-test selection with differing/synthetic capability sets and injected + observations. Cover stopped services, wrong mounts/network/listeners, changed + managed state, and lost persistence without live providers in routine CI. + +Exit: a named provisioned clamps VM passes checks for its declared services, +repeat apply, and reboot persistence. A second profile/capability combination +proves selection is generic in routine tests. Report exactly which services +were checked and which opt-in checks were omitted. Keep production renewal, +outside-tailnet access, and other previously deferred checks deferred. + +## 6. Reconcile documentation and close the gate + +- Update the roadmap and affected slice progress to reflect implemented versus + pending work. Reconcile `design/skillet-architecture.md`, `design/smoke-vms.md`, + secrets/private-UI acceptance statements, and migration entry points. +- Correct recording format version documentation against emitted format 2, + manifest-bound smoke override instructions, vault requirements, and native + libvirt runtime instructions. Remove manual cleanup advice that bypasses + provider journals or the Rust lifecycle owner. +- Update the crate map and record general prevention rules in AGENTS: every + lifecycle entry point shares locking/ownership checks; fakes preserve resource + type/state; CI executes required regression suites; fixture acceptance cannot + substitute for declared application acceptance. Avoid incident-specific rules. +- Record exact commands, code/artifact versions, outcomes, and unavailable checks + in `butane/ACCEPTANCE.md`. Preserve earlier evidence. Remove completed slice + plans only after decisions/evidence and remaining tasks have durable homes; + update links when removing them. + +Exit: commands and design claims match the implementation. No required check is +described as passed without evidence. All five preceding exits pass before the +roadmap is marked complete; explicitly deferred checks remain visible. + +## Final validation and handoff + +Run the applicable local CI checks: workspace format, pedantic all-target +Clippy, all-target tests, the Fedora base container integration command in +AGENTS, bootstrap regressions, and workflow ShellCheck for changed helpers. +Run named-VM checks for smoke, retained update, actual host repeat apply, and +reboot. Preserve failed-run diagnostics and ownership journals for retry. + +Use focused commits, do not push without authorization, and report the completed +items, interface changes, validation evidence, and any remaining blockers. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 4651e912..aeec8b34 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -1,12 +1,20 @@ # Skillet refactoring prerequisite -Status: complete, 2026-10-05. All seven refactoring slices, routine local -validation, and required fresh/retained VM acceptance have passed. The live +Status: reopened, 2026-10-05. The implementation follow-up is in place; +interrupted-reboot fault injection, fully provisioned application acceptance, +and remote CI evidence remain open. Track them in +[the six-point follow-up](REFACTOR-FOLLOWUP.md) before resuming migrations. +Previous routine validation and fresh/retained VM runs remain evidence. The live legacy-manifest import and controlled interrupted-provisioning recovery are recorded in the [acceptance log](../butane/ACCEPTANCE.md#legacy-manifest-import-and-interrupted-provisioning-recovery-2026-10-05). Production delivery/renewal and outside-tailnet denial remain explicitly deferred from this refactor gate. +The workstream table below records earlier acceptance, not closure of the +follow-up acceptance gate. Lifecycle and fake-contract corrections are now +implemented; workstream 7 still requires a fully provisioned application run +and remote CI. Documentation reconciliation spans all workstreams. + Read `../AGENTS.md`, `../skillet/AGENTS.md`, and [the architecture decision](../design/skillet-architecture.md) first. Then read the designs and source files listed in the selected workstream. Start from the diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 1fcd79e9..9e7814f5 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -28,7 +28,7 @@ This document defines the architectural mandates and project structure for `skil - **Formatting & Linting**: Always run `cargo fmt` and `cargo clippy` after making changes to ensure code quality and consistency. **Clippy MUST be run with `pedantic` lints enabled (configured in `Cargo.toml`).** - **Verification**: Always run both: - **Unit Tests**: `cargo test --workspace --all-targets`. - - **Runtime Smoke**: Run `integration_tests/smoke-ssh.sh` against an explicitly named disposable VM with real systemd and Podman for affected container resources. Record the guest state snapshots and failure diagnostics. + - **Runtime Smoke**: Run `cargo run --release -p skillet -- test smoke --instance ` against an explicitly named disposable VM with real systemd and Podman for affected container resources. Record the guest state snapshots and failure diagnostics. - Tests for host-dependent observations must inject or simulate those observations. Do not require a particular workstation port, account, service, or external resource to happen to be available for a unit test. @@ -118,6 +118,10 @@ tracks existing violations and their migration, not completed implementation. continue with later operations when an ownership check fails. Address destructive runtime actions by the recorded immutable identity, not only a reusable name. +- Every VM guest-operation entry point uses the same per-run lock and validates + recorded UUID/disk ownership before and after each remote operation. Guest + assertion scripts may inspect application state, but lifecycle orchestration, + SSH, reboot, retries, and readiness remain with the Rust owner. - Persist external cleanup intent before provider mutations. Bind each ownership journal to the complete typed host, environment, and instance identity; reject mismatches on retry and preserve journals when cleanup is @@ -148,7 +152,7 @@ Skillet uses a multi-layered testing approach to ensure reliability and idempote 1. **Trait-based Abstraction**: Core resources (`FileResource`, `SystemResource`) are defined as traits. This allows for easy mocking using `MockFiles` and `MockSystem` in unit tests. 2. **Diagnostic Recorder**: `apply --record PATH` can capture attempted resource operations for investigation. Recording equality is not an acceptance check. -3. **Real Runtime Smoke**: The disposable VM check exercises generated Quadlets, systemd startup, Podman secrets, configuration changes, repeated apply, failure recovery and reboot persistence. It compares managed bytes and metadata plus container identity and observed application state. +3. **Real Runtime Smoke**: `test smoke` exercises the namespaced synthetic fixture; `--with-applications` additionally checks the actual services selected from the canonical host profile. The Rust lifecycle owner guards SSH, upload, reboot, retry, and readiness. Application mode compares managed configuration and metadata, runtime identity, declared network/listeners, and a data marker across repeat apply and reboot. ## Project Structure @@ -157,29 +161,25 @@ The project is organized as a Cargo workspace: ```text skillet/ ├── Cargo.toml # Workspace configuration -├── AGENTS.md # This file (Project mandates) +├── AGENTS.md # Project mandates └── crates/ - ├── core/ # skillet_core: Low-level idempotent primitives - │ ├── src/ - │ │ ├── lib.rs - │ │ ├── files.rs # File management (Traits + Impl) - │ │ ├── files/ - │ │ │ └── tests.rs # Unit tests for files - │ │ ├── system.rs # User/Group management - │ │ └── system/ - │ │ └── tests.rs # Unit tests for system - │ └── tests/ # Integration tests - ├── hardening/ # skillet_hardening: Configuration logic (modules) - │ ├── src/ - │ │ ├── lib.rs # Hardening logic using core primitives - │ │ └── tests.rs # Unit tests for hardening logic - │ └── tests/ - ├── cli/ # skillet: The main binary executable - │ └── src/ - │ └── main.rs # CLI entry point (uses anyhow, clap) - ├── cli-common/ # skillet_cli_common: Shared CLI logic + ├── core/ # Effect interfaces and idempotent primitives + ├── host-profiles/ # Canonical host capabilities and composition + ├── vm/ # Manifest, backend, transport and lifecycle + ├── workstation/ # Vault, provider, delivery and enrollment + ├── podman/ # Typed Podman configuration/runtime model + ├── btrbk/ # Caller-selected Btrfs snapshot configuration + ├── caddy/ # Private UI routes and reverse proxy configuration + ├── pihole/ # Pi-hole service configuration + ├── syncthing/ # Syncthing service configuration + ├── unifi/ # UniFi service configuration + ├── hardening/ # Shared baseline configuration + ├── test-fixture/ # Synthetic smoke-only host binary + ├── cli-common/ # Shared guest apply/credential handling + ├── cli/ # Workstation and guest CLI entry point └── hosts/ - └── beezelbot/ # skillet-beezelbot: Host-specific binary for beezelbot + ├── clamps/ # skillet-clamps host entry point + └── beezelbot/ # skillet-beezelbot host entry point ``` ## Module Design diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 060cef0d..4e001f45 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -71,16 +71,22 @@ Then run the clamps smoke scenario: cargo run --release -p skillet -- test smoke clamps ``` -The smoke command defaults to that target, port, and generated key. Override -them with `--target`, `--port`, and `--identity` when using a separately -provisioned VM. It builds a separate `skillet-smoke-fixture` executable from -Cargo's reported artifact for the running Skillet target and profile. Before -running it, the guest verifies the fixture transfer and confirms both installed -Skillet binaries match the ready VM manifest. Shared systemd +The smoke command reads the target, port, key and recorded host key from the +selected VM manifest. Optional `--target`, `--port`, and `--identity` values +must match that manifest; they cannot select another machine. It builds a +separate `skillet-smoke-fixture` executable from Cargo's reported artifact for +the running Skillet target and profile. Before running it, the guest verifies +the fixture transfer and confirms both installed Skillet binaries match the +ready VM manifest. Shared systemd apply units use the generic binary and the stable host profile in `/etc/skillet/host`. Repeated smoke runs reset only the namespaced `/var/lib/skillet-smoke` fixture and its managed files. The VM remains available for inspection and reruns. +Add `--with-applications` to require a provisioned VM and check the actual +services declared by that host profile. This checks service units, container +state/network/mounts, configured listeners, available service health probes, +unchanged runtime/configuration after repeat apply, and a data marker across +reboot. The default command checks the synthetic fixture only. After editing host code, use `cargo run --release -p skillet -- test vm update clamps smoke` to rebuild and install the current binary. `ready` reinstalls the artifact captured when the @@ -311,7 +317,8 @@ requires `curl` on the workstation. See the Cloudflare credentials and DNS records are removed before the smoke Tailscale device and VM are destroyed. Keep the Cloudflare token creator on the workstation; Tailscale enrollment needs no Cloudflare or Tailscale DNS -permissions. Live ACME and HTTPS acceptance remains pending. See the +permissions. Staging ACME and HTTPS acceptance passed on 2026-10-02; production +renewal and access checks from outside the tailnet remain deferred. See the [VM lifecycle](../design/smoke-vms.md). ## Development checks @@ -322,9 +329,11 @@ permissions. Live ACME and HTTPS acceptance remains pending. See the - Container integration: `cargo run --bin skillet -- test run beezelbot --phase base --image fedora:latest` (also run for `clamps` after changing shared container behavior). +- Bootstrap state regressions: `bash ublue/butane/tests/bootstrap.sh` from the + repository root. - ShellCheck covers `*.sh` plus the extensionless scripts named in the CI workflow, including VM and workstation helpers. -- `apply --record PATH` writes version 1 YAML with host, overall result, and +- `apply --record PATH` writes version 2 YAML with host, overall result, and per-operation outcomes. It stores no content, hashes, secret values, or raw effect errors, and is written after failed applies as well. Older unversioned operation arrays remain diagnostic artifacts and are never loaded as state. From fc62cdbabd9b5919c004ca16681759f08d36b290 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 15:27:44 +0200 Subject: [PATCH 196/252] fix(workstation): probe Caddy through its bridge address --- .../crates/workstation/src/ui_provisioning.rs | 35 ++++++++++++++++++- .../workstation/src/ui_provisioning/tests.rs | 9 ++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs index ca1a9686..b7f376de 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -284,11 +284,12 @@ fn verify_non_tailnet_denial( timeout: Duration, interval: Duration, ) -> Result<(), UiProvisioningError> { + let caddy_address = caddy_container_address(guest)?; let mut verified_names = 0; for site in &sites.services { for hostname in std::iter::once(&site.hostname).chain(&site.aliases) { let started = std::time::Instant::now(); - let resolve = format!("{hostname}:443:127.0.0.1"); + let resolve = format!("{hostname}:443:{caddy_address}"); let url = format!("https://{hostname}/"); loop { let output = guest.execute( @@ -332,6 +333,38 @@ fn verify_non_tailnet_denial( Ok(()) } +fn caddy_container_address( + guest: &impl GuestTransport, +) -> Result { + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &[ + "-n", + "podman", + "inspect", + "--format", + "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + "caddy", + ], + }, + None, + )?; + if !output.status.success() { + return Err(UiProvisioningError::Invalid( + "could not inspect the Caddy container address for the local denial probe".into(), + )); + } + let address = String::from_utf8(output.stdout).map_err(|error| { + UiProvisioningError::Invalid(format!("Caddy address is not UTF-8: {error}")) + })?; + address.trim().parse().map_err(|error| { + UiProvisioningError::Invalid(format!( + "Caddy returned an invalid container IPv4 address: {error}" + )) + }) +} + pub struct PersistentUiDelivery<'a> { pub host: &'a str, pub policy: ProvisioningPolicy, diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index 79cad3ad..5ec0723c 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -91,7 +91,10 @@ impl GuestTransport for FakeGuest { .collect(), input.map(<[u8]>::to_vec), )); - let stdout = if command.program == "/usr/bin/curl" && self.denies_curl.get() { + let stdout = if command.program == "/usr/bin/sudo" && command.arguments.contains(&"inspect") + { + b"172.26.26.5\n".to_vec() + } else if command.program == "/usr/bin/curl" && self.denies_curl.get() { b"Access denied by Skillet tailnet policy\n403".to_vec() } else if command.program == "/usr/bin/curl" { b"unexpected response".to_vec() @@ -740,6 +743,10 @@ fn disposable_ui_delivery_installs_both_inputs_activates_probes_all_names_then_r .first() .is_some_and(|program| program == "/usr/bin/curl") && call.0.iter().any(|argument| argument == "--resolve") + && call + .0 + .iter() + .any(|argument| argument.ends_with(":172.26.26.5")) })); assert_eq!( provider.revoked.borrow().as_slice(), From b5d67168a6898b80b0da4334a5315f4c637d54be Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 15:27:59 +0200 Subject: [PATCH 197/252] fix(test): check the managed Btrfs timer unit --- ublue/skillet/crates/host-profiles/src/hosts_tests.rs | 2 +- ublue/skillet/crates/host-profiles/src/profile.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index b296ea26..04779231 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -218,7 +218,7 @@ fn application_acceptance_tracks_each_profiles_declared_services() { "tailscale.service", "syncthing.service", "unifi.service", - "btrbk.timer", + "skillet-btrbk.timer", "caddy.service" ] ); diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index 4a543687..5a17c0c6 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -348,7 +348,7 @@ fn acceptance_service(profile: &str, service: &HostService) -> AcceptanceService health_probe: Some(HealthProbe::Tailscale), }, ServiceConfig::Btrbk { .. } => AcceptanceService { - unit: "btrbk.timer".into(), + unit: "skillet-btrbk.timer".into(), container: None, network_mode: None, bind_paths: Vec::new(), From 5f856ff2da07f9f1bef904cc4ebd9a1824d1b986 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 15:29:39 +0200 Subject: [PATCH 198/252] docs: record live application acceptance --- ublue/butane/ACCEPTANCE.md | 30 +++++++++++-------- ublue/design/skillet-architecture.md | 11 +++---- ublue/design/smoke-vms.md | 9 +++--- ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md | 19 ++++++------ ublue/plan/REFACTOR-FOLLOWUP.md | 15 +++++----- ublue/plan/SKILLET-REFACTOR.md | 6 ++-- 6 files changed, 48 insertions(+), 42 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index b8346f0e..dd51c4d4 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2,6 +2,18 @@ ## Refactor follow-up, 2026-10-05 +- The first live UI retry exposed a host/Podman rootless networking mismatch: + the VM could not connect to Caddy through `127.0.0.1`, while direct access to + Caddy's dynamically inspected bridge address returned the intended 403. The + denial probe now discovers the current Caddy container IPv4 at runtime. + Disposable provisioning then passed and reused its journaled state. +- Passed `cargo run --release -p skillet -- test smoke clamps --instance + smoke --with-applications` on the provisioned named VM. After fixture reboot, + Skillet verified active Pi-hole, Tailscale, Syncthing, UniFi, + `skillet-btrbk.timer`, and Caddy; container network/mount state, declared + ownership, listeners, Pi-hole and Tailscale health; unchanged runtime after + repeat apply; and marker persistence across reboot. The VM returned to + `Ready`. Application acceptance derives from the host profile. - Passed `cargo fmt --all -- --check`, strict workspace Clippy, workspace all-target tests, the CI Fedora container command (`cargo run --bin skillet -- test run beezelbot --phase base --image @@ -13,19 +25,11 @@ smoke` passed. Then `cargo run --release -p skillet -- test smoke clamps --instance smoke` passed fixture convergence, pre/post-reboot assertions, new boot detection, captured binary verification, and readiness recovery. -- The opt-in profile acceptance implementation has injected coverage for - active services, container network, bind paths, named versus numeric host - ownership, listeners, health probes, repeat-apply identity, data mount, and - reboot persistence. Live attempts exposed incorrect assumptions that were - corrected: Pi-hole owns its writable directories at runtime, and Syncthing's - declared host account differs from its container UID. The final live - `--with-applications` pass remains pending because the current VM lacks - credential-dependent Caddy provisioning. Reprovisioning reached the - KeePassXC password prompt; it was stopped without entering a password or - making provider changes. Resume after unlocking KeePassXC, then rerun - `cargo run --release -p skillet -- test vm provision clamps smoke --with-ui` - followed by `cargo run --release -p skillet -- test smoke clamps --instance - smoke --with-applications`. +- Injected tests cover active services, container network, bind paths, named + versus numeric host ownership, listeners, health probes, repeat-apply + identity, data mount, and reboot persistence. Live checks corrected the + initial ownership expectations: Pi-hole may change writable directory owners, + and Syncthing's declared host account differs from its container UID. - Remote CI is pending until these working-tree changes are pushed. Production renewal, external-network denial, and interrupted-reboot fault injection remain outside this local acceptance result. diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 4edc8a0f..0b78cd49 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -3,9 +3,10 @@ Decision, 2026-10-03: complete the seven refactoring workstreams before further service migrations, encryption implementation, or production cutover. Existing service behavior and unfinished acceptance requirements remain authoritative. -Status: follow-up implementation is complete; interrupted-reboot fault -injection, fully provisioned host acceptance, and remote CI remain pending. -Rust-owned fixture smoke and retained VM update passed locally; see the +Status: follow-up implementation and fully provisioned host acceptance are +complete; interrupted-reboot fault injection and remote CI remain pending. +Rust-owned fixture smoke, retained VM update, and capability-selected +application acceptance passed locally; see the [follow-up plan](../plan/REFACTOR-FOLLOWUP.md) and [acceptance log](../butane/ACCEPTANCE.md). Production acceptance remains explicitly deferred. @@ -32,8 +33,8 @@ guest operation through a shared checked transport. Transport's bounded I/O stays in memory; payload copies are zeroized after stdin transfer. Earlier fixture and provisioning acceptance passed. Rust now owns smoke reboot and capability-selected application acceptance. The latter has passing injected -tests; the final live pass still requires test UI credentials delivered after -KeePassXC is unlocked. Production acceptance remains deferred. +tests and a named-VM repeat-apply/reboot acceptance. Production acceptance +remains deferred. Workstream 2 adds `skillet_hosts` as the single host capability declaration. It owns host composition and supplies boot, service, network, UI, storage, and diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 8791a130..3a0b252d 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -57,10 +57,11 @@ definition and start are separate manifest phases; failed starts retain the defined domain for inspection and retry. Native domain XML renders through the Rust XML library, preserving disk ownership, fw_cfg and passt networking. Unit and adapter checks pass. Rust now owns fixture smoke delivery, guarded guest -operations, reboot detection, and readiness recovery. The named fixture smoke -and retained update passed on 2026-10-05. Fully provisioned application -acceptance remains pending in the [follow-up plan](../plan/REFACTOR-FOLLOWUP.md) -and [acceptance log](../butane/ACCEPTANCE.md). +operations, reboot detection, and readiness recovery. The named fixture smoke, +retained update, and capability-selected application repeat-apply/reboot +acceptance passed on 2026-10-05. Interrupted-reboot fault injection remains +pending in the [follow-up plan](../plan/REFACTOR-FOLLOWUP.md) and +[acceptance log](../butane/ACCEPTANCE.md). Tailscale enrollment intent, enrolled-device identity, and Cloudflare DNS/token ownership metadata are persisted by `skillet_workstation::provisioning_state`. diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md index 0ba346f8..830a8a96 100644 --- a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -1,12 +1,13 @@ # 7. Test layers, fixtures, build artifacts and CI -Status: follow-up implementation complete, live acceptance remains open, -2026-10-05. Rust-owned smoke, storage/service fake contracts, bootstrap CI -execution, and capability-selected application acceptance are implemented. -The named fixture smoke and retained update passed; fully provisioned -application acceptance and interrupted-reboot fault injection remain open in -the [follow-up plan](REFACTOR-FOLLOWUP.md). Live legacy import and controlled -interrupted-provisioning recovery passed; see the [prerequisite roadmap](SKILLET-REFACTOR.md). +Status: implementation and named application acceptance passed, remote CI and +interrupted-reboot fault injection remain open, 2026-10-05. Rust-owned smoke, +storage/service fake contracts, bootstrap CI execution, and capability-selected +application acceptance are implemented. The named fixture smoke, retained +update, and fully provisioned application acceptance passed; interrupted-reboot +fault injection remains open in the [follow-up plan](REFACTOR-FOLLOWUP.md). +Live legacy import and controlled interrupted-provisioning recovery passed; +see the [prerequisite roadmap](SKILLET-REFACTOR.md). ## Read and locate @@ -33,8 +34,8 @@ missed relevant CI. Shell code was embedded in a production Rust fixture. service reload state. Bootstrap regressions run in CI and passed locally. - Capability-selected application acceptance and injected failure tests are implemented. Live checks corrected ownership expectations: Syncthing data - uses its declared host account; its numeric container UID is separate. A - final live application pass awaits test UI provisioning through KeePassXC. + uses its declared host account; its numeric container UID is separate. The + fully provisioned named-VM application, repeat-apply, and reboot checks pass. - Workspace formatting, strict Clippy, workspace tests, the Fedora container integration, bootstrap state tests, and workflow ShellCheck passed locally. Remote CI is pending until push. diff --git a/ublue/plan/REFACTOR-FOLLOWUP.md b/ublue/plan/REFACTOR-FOLLOWUP.md index 5bb93e3f..9d6ba2aa 100644 --- a/ublue/plan/REFACTOR-FOLLOWUP.md +++ b/ublue/plan/REFACTOR-FOLLOWUP.md @@ -1,12 +1,11 @@ # Refactor follow-up: close the completion audit gaps -Status: implementation substantially complete, 2026-10-05. Code and local -regression coverage for all six findings are in place. Rust-owned fixture smoke -and retained-VM update passed on the named clamps VM. Capability-selected host -application acceptance is implemented and its local tests pass; live acceptance -remains pending because test UI provisioning requires an interactive KeePassXC -unlock. Remote CI evidence also remains pending until these changes are pushed. -Keep the refactoring gate open until those checks are recorded. +Status: implementation and named-VM acceptance substantially complete, +2026-10-05. Code and local regression coverage for all six findings are in +place. Rust-owned fixture smoke, retained-VM update, and capability-selected +application acceptance passed on the named clamps VM. Interrupted-reboot fault +injection and remote CI evidence remain pending. Keep the refactoring gate open +until those checks are recorded. ## Progress @@ -16,7 +15,7 @@ Keep the refactoring gate open until those checks are recorded. | 2. Update delivery guard | Implemented; ownership-loss/retry tests and retained-VM update passed | None locally; remote CI pending | | 3. Fake contracts | Implemented; workspace regression suite passes | None locally; remote CI pending | | 4. Bootstrap CI | Implemented; CI runs state regressions and local command passes | Observe remote CI after push | -| 5. Host application acceptance | Implemented from canonical profile capabilities; local tests pass | Named fully provisioned VM with vault-dependent UI services, repeat apply, and reboot acceptance | +| 5. Host application acceptance | Implemented from canonical profile capabilities; unit and named fully provisioned VM repeat-apply/reboot acceptance passed | None locally; remote CI pending | | 6. Documentation | Reconciled implementation, commands, design status, and acceptance evidence | Final review and remote CI result | ## Start here diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index aeec8b34..216352cd 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -1,8 +1,8 @@ # Skillet refactoring prerequisite -Status: reopened, 2026-10-05. The implementation follow-up is in place; -interrupted-reboot fault injection, fully provisioned application acceptance, -and remote CI evidence remain open. Track them in +Status: reopened, 2026-10-05. The implementation follow-up and named fully +provisioned application acceptance are complete; interrupted-reboot fault +injection and remote CI evidence remain open. Track them in [the six-point follow-up](REFACTOR-FOLLOWUP.md) before resuming migrations. Previous routine validation and fresh/retained VM runs remain evidence. The live legacy-manifest import and controlled interrupted-provisioning recovery are From 9fb43fd0c51235081b2008e2b1a1f045dff39f47 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 15:53:26 +0200 Subject: [PATCH 199/252] test(vm): script interrupted reboot recovery --- ublue/butane/ACCEPTANCE.md | 23 +++++++ ublue/design/skillet-architecture.md | 9 +-- ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md | 21 ++++-- ublue/plan/REFACTOR-FOLLOWUP.md | 17 ++--- ublue/plan/SKILLET-REFACTOR.md | 12 ++-- ublue/skillet/README.md | 17 +++++ ublue/skillet/crates/cli/src/main.rs | 11 +++ ublue/skillet/crates/cli/src/vm.rs | 10 ++- .../interrupted-reboot-recovery.sh | 69 +++++++++++++++++++ 9 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 ublue/skillet/integration_tests/interrupted-reboot-recovery.sh diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index dd51c4d4..6249f6db 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,28 @@ # Clamps VM acceptance log +## Interrupted reboot checkpoint recovery, 2026-10-05 + +- Added `bash ../skillet/integration_tests/interrupted-reboot-recovery.sh HOST INSTANCE` + to exercise the persisted checkpoint and recovery across separate Skillet + processes. The first smoke run intentionally exits after recording + `Started`, before asking libvirt to reboot; the script verifies that phase, + runs `test vm ready`, verifies `Ready`, then runs an ordinary fixture smoke + reboot. The fault point is hidden from normal CLI help and is only available + to the disposable-VM smoke command. +- Passed on the existing `clamps/smoke` VM without the optional application + check. The interrupted run remained `Started`; separate readiness restored + `Ready`; the normal fixture reboot passed and the VM returned to `Ready`. +- The optional `--with-applications` variant also reached the injected + interruption and recovered readiness, but its final application check + failed because `/var/lib/data/syncthing` was `core:core` after reboot while + the profile declares `giacomo:giacomo`. Readiness restored the VM to + `Ready`. Investigate this ownership divergence before treating another live + application acceptance run as passing. +- Passed workspace formatting check, `cargo test -p skillet --no-run`, + `cargo clippy -p skillet --all-targets -- -D warnings`, ShellCheck for the + recovery script, and `git diff --check`. The complete workspace suite and + remote CI have not been rerun for this change. + ## Refactor follow-up, 2026-10-05 - The first live UI retry exposed a host/Podman rootless networking mismatch: diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md index 0b78cd49..e3e9d6cc 100644 --- a/ublue/design/skillet-architecture.md +++ b/ublue/design/skillet-architecture.md @@ -3,10 +3,11 @@ Decision, 2026-10-03: complete the seven refactoring workstreams before further service migrations, encryption implementation, or production cutover. Existing service behavior and unfinished acceptance requirements remain authoritative. -Status: follow-up implementation and fully provisioned host acceptance are -complete; interrupted-reboot fault injection and remote CI remain pending. -Rust-owned fixture smoke, retained VM update, and capability-selected -application acceptance passed locally; see the +Status: follow-up implementation and scripted interrupted-reboot recovery are +complete; remote CI and a Syncthing data ownership divergence found during +repeat application acceptance remain open. Rust-owned fixture smoke, retained +VM update, and the earlier capability-selected application acceptance passed +locally; see the [follow-up plan](../plan/REFACTOR-FOLLOWUP.md) and [acceptance log](../butane/ACCEPTANCE.md). Production acceptance remains explicitly deferred. diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md index 830a8a96..d9ffcb15 100644 --- a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -1,11 +1,16 @@ # 7. Test layers, fixtures, build artifacts and CI -Status: implementation and named application acceptance passed, remote CI and -interrupted-reboot fault injection remain open, 2026-10-05. Rust-owned smoke, -storage/service fake contracts, bootstrap CI execution, and capability-selected -application acceptance are implemented. The named fixture smoke, retained -update, and fully provisioned application acceptance passed; interrupted-reboot -fault injection remains open in the [follow-up plan](REFACTOR-FOLLOWUP.md). +Status: implementation and named fixture acceptance passed; remote CI and a +Syncthing ownership divergence found during repeat application acceptance +remain open, 2026-10-05. Rust-owned smoke, storage/service fake contracts, +bootstrap CI execution, and capability-selected application acceptance are +implemented. The named fixture smoke and retained update passed. Scripted +interrupted-reboot recovery passed; see the +[acceptance log](../butane/ACCEPTANCE.md#interrupted-reboot-checkpoint-recovery-2026-10-05). +An optional repeated application check found `/var/lib/data/syncthing` owned by +`core:core` after reboot rather than its declared `giacomo:giacomo`; resolve it +before treating application acceptance as stable. See the +[follow-up plan](REFACTOR-FOLLOWUP.md). Live legacy import and controlled interrupted-provisioning recovery passed; see the [prerequisite roadmap](SKILLET-REFACTOR.md). @@ -39,6 +44,10 @@ missed relevant CI. Shell code was embedded in a production Rust fixture. - Workspace formatting, strict Clippy, workspace tests, the Fedora container integration, bootstrap state tests, and workflow ShellCheck passed locally. Remote CI is pending until push. +- A scripted interruption after persisting the pre-reboot checkpoint was + recovered by a separate readiness process, then ordinary fixture reboot + acceptance passed. The optional application variant exposed a Syncthing data + ownership mismatch after reboot; the VM was restored to Ready. ## Earlier progress, 2026-10-04 diff --git a/ublue/plan/REFACTOR-FOLLOWUP.md b/ublue/plan/REFACTOR-FOLLOWUP.md index 9d6ba2aa..de2ed566 100644 --- a/ublue/plan/REFACTOR-FOLLOWUP.md +++ b/ublue/plan/REFACTOR-FOLLOWUP.md @@ -1,22 +1,23 @@ # Refactor follow-up: close the completion audit gaps -Status: implementation and named-VM acceptance substantially complete, +Status: implementation and local VM acceptance substantially complete, 2026-10-05. Code and local regression coverage for all six findings are in -place. Rust-owned fixture smoke, retained-VM update, and capability-selected -application acceptance passed on the named clamps VM. Interrupted-reboot fault -injection and remote CI evidence remain pending. Keep the refactoring gate open -until those checks are recorded. +place. Rust-owned fixture smoke, retained-VM update, and scripted interrupted +reboot recovery passed on the named clamps VM. A repeat application check +exposed Syncthing data ownership diverging after reboot; investigate that before +considering live application acceptance stable. Remote CI evidence remains +pending. Keep the refactoring gate open until these items are resolved. ## Progress | Step | Implementation | Evidence still needed | | --- | --- | --- | -| 1. Rust-owned smoke lifecycle | Implemented; named `clamps/smoke` fixture and reboot acceptance passed | Interrupted-reboot recovery fault scenario | +| 1. Rust-owned smoke lifecycle | Implemented; named `clamps/smoke` fixture/reboot and scripted interrupted-checkpoint recovery passed | None locally; remote CI pending | | 2. Update delivery guard | Implemented; ownership-loss/retry tests and retained-VM update passed | None locally; remote CI pending | | 3. Fake contracts | Implemented; workspace regression suite passes | None locally; remote CI pending | | 4. Bootstrap CI | Implemented; CI runs state regressions and local command passes | Observe remote CI after push | -| 5. Host application acceptance | Implemented from canonical profile capabilities; unit and named fully provisioned VM repeat-apply/reboot acceptance passed | None locally; remote CI pending | -| 6. Documentation | Reconciled implementation, commands, design status, and acceptance evidence | Final review and remote CI result | +| 5. Host application acceptance | Implemented from canonical profile capabilities; prior named-VM acceptance passed | Repeat acceptance found `/var/lib/data/syncthing` owned by `core:core` after reboot, versus declared `giacomo:giacomo`; diagnose and rerun | +| 6. Documentation | Updated implementation, commands, design status, and fault-injection evidence | Final review and remote CI result | ## Start here diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 216352cd..6e6cd7c6 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -1,8 +1,9 @@ # Skillet refactoring prerequisite -Status: reopened, 2026-10-05. The implementation follow-up and named fully -provisioned application acceptance are complete; interrupted-reboot fault -injection and remote CI evidence remain open. Track them in +Status: reopened, 2026-10-05. The implementation follow-up and scripted +interrupted-reboot recovery are complete. Repeat application acceptance +exposed a Syncthing data ownership divergence after reboot, and remote CI +evidence remains open. Track them in [the six-point follow-up](REFACTOR-FOLLOWUP.md) before resuming migrations. Previous routine validation and fresh/retained VM runs remain evidence. The live legacy-manifest import and controlled interrupted-provisioning recovery are @@ -12,8 +13,9 @@ deferred from this refactor gate. The workstream table below records earlier acceptance, not closure of the follow-up acceptance gate. Lifecycle and fake-contract corrections are now -implemented; workstream 7 still requires a fully provisioned application run -and remote CI. Documentation reconciliation spans all workstreams. +implemented; workstream 7 still requires investigation of repeat application +ownership after reboot and remote CI. Documentation reconciliation spans all +workstreams. Read `../AGENTS.md`, `../skillet/AGENTS.md`, and [the architecture decision](../design/skillet-architecture.md) first. Then read diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 4e001f45..bc2b9050 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -87,6 +87,23 @@ services declared by that host profile. This checks service units, container state/network/mounts, configured listeners, available service health probes, unchanged runtime/configuration after repeat apply, and a data marker across reboot. The default command checks the synthetic fixture only. + +To exercise recovery when Skillet stops after saving the pre-reboot checkpoint, +run the scripted interruption, readiness recovery, and normal reboot cycle: + +```bash +bash integration_tests/interrupted-reboot-recovery.sh clamps smoke +``` + +The script expects an existing, ready disposable VM. It deliberately expects +the first smoke command to exit at the injected checkpoint, verifies the +manifest is no longer `Ready`, runs readiness in a separate process, verifies +`Ready` is restored, then runs the regular smoke reboot and acceptance. Add +`--with-applications` for hosts with provisioned services. On clamps, the +current application check has a known Syncthing ownership divergence after +reboot; see the [acceptance log](../butane/ACCEPTANCE.md#interrupted-reboot-checkpoint-recovery-2026-10-05) +before enabling that option. + After editing host code, use `cargo run --release -p skillet -- test vm update clamps smoke` to rebuild and install the current binary. `ready` reinstalls the artifact captured when the diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index f0098823..eda4d6de 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -229,6 +229,9 @@ struct SmokeArgs { /// Also verify and repeat-apply the selected host's provisioned applications #[arg(long)] with_applications: bool, + /// Stop after persisting the pre-reboot checkpoint to exercise recovery + #[arg(long, hide = true)] + interrupt_before_reboot: bool, } fn main() -> Result<()> { @@ -407,6 +410,7 @@ fn run_smoke(args: &SmokeArgs) -> Result<()> { host_binary: &host_binary, credentials_required, with_applications: args.with_applications, + interrupt_before_reboot: args.interrupt_before_reboot, } .run() } @@ -421,6 +425,7 @@ struct SmokeLifecycle<'a> { host_binary: &'a str, credentials_required: bool, with_applications: bool, + interrupt_before_reboot: bool, } impl SmokeLifecycle<'_> { @@ -450,6 +455,11 @@ impl SmokeLifecycle<'_> { self.run_guest_phase(&transport, "before-reboot")?; let run = self.store.mark_reboot_pending(&self.run.identity)?; + if self.interrupt_before_reboot { + return Err(anyhow!( + "injected interruption after persisting the pre-reboot checkpoint" + )); + } ownership()?; backend.reboot(&run)?; ownership()?; @@ -1239,6 +1249,7 @@ mod tests { "--target", "core@192.0.2.5", "--with-applications", + "--interrupt-before-reboot", ]); assert!(parsed.is_ok()); } diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index a1df68e9..74bc04d7 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -26,8 +26,14 @@ pub(super) fn status(args: &VmTargetArgs) -> Result<()> { })?; domain.validate_owned(&run)?; println!( - "Name: {}\nUUID: {}\nState: {}\nSSH: {}@{}:{}", - domain.name, domain.uuid, domain.state, run.ssh.user, run.ssh.address, run.ssh.port + "Name: {}\nUUID: {}\nState: {}\nLifecycle: {:?}\nSSH: {}@{}:{}", + domain.name, + domain.uuid, + domain.state, + run.phase, + run.ssh.user, + run.ssh.address, + run.ssh.port ); for disk in domain.disks { println!("Disk: {}", disk.display()); diff --git a/ublue/skillet/integration_tests/interrupted-reboot-recovery.sh b/ublue/skillet/integration_tests/interrupted-reboot-recovery.sh new file mode 100644 index 00000000..58591f44 --- /dev/null +++ b/ublue/skillet/integration_tests/interrupted-reboot-recovery.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf 'Usage: %s HOST INSTANCE [--with-applications]\n' "$0" >&2 +} + +if [[ $# -lt 2 || $# -gt 3 ]]; then + usage + exit 2 +fi + +host=$1 +instance=$2 +with_applications=${3:-} +if [[ -n $with_applications && $with_applications != --with-applications ]]; then + usage + exit 2 +fi + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +skillet_dir=$(cd -- "$script_dir/.." && pwd) +cd "$skillet_dir" + +skillet() { + cargo run --quiet --release -p skillet -- "$@" +} + +application_args=() +if [[ $with_applications == --with-applications ]]; then + application_args+=(--with-applications) +fi + +printf 'Injecting interruption after the smoke checkpoint for %s/%s\n' "$host" "$instance" +output_file=$(mktemp) +trap 'rm -f -- "$output_file"' EXIT +set +e +skillet test smoke "$host" --instance "$instance" \ + "${application_args[@]}" --interrupt-before-reboot 2>&1 | tee "$output_file" +result=${PIPESTATUS[0]} +set -e +output=$(<"$output_file") +if [[ $result -eq 0 ]]; then + printf 'Fault injection unexpectedly completed successfully\n' >&2 + exit 1 +fi +if [[ $result -eq 0 ]] || [[ $output != *'injected interruption after persisting the pre-reboot checkpoint'* ]]; then + printf 'Smoke failed for a reason other than the requested injected interruption\n' >&2 + exit 1 +fi + +status=$(skillet test vm status "$host" "$instance") +printf '%s\n' "$status" +if [[ $status != *'Lifecycle: Started'* ]]; then + printf 'Interrupted run did not remain in the recoverable Started phase\n' >&2 + exit 1 +fi + +printf 'Recovering readiness after the interruption\n' +skillet test vm ready "$host" "$instance" +status=$(skillet test vm status "$host" "$instance") +printf '%s\n' "$status" +if [[ $status != *'Lifecycle: Ready'* ]]; then + printf 'Readiness did not restore the Ready phase\n' >&2 + exit 1 +fi + +printf 'Running the normal smoke reboot after recovery\n' +skillet test smoke "$host" --instance "$instance" "${application_args[@]}" From 43438e3c5c9e64c2862fad9498adcd60ab0fcb4f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 17:43:50 +0200 Subject: [PATCH 200/252] fix(syncthing): map container identity to data owner --- ublue/butane/ACCEPTANCE.md | 10 +- ublue/design/syncthing.md | 7 +- ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md | 13 +- ublue/plan/REFACTOR-FOLLOWUP.md | 10 +- .../crates/host-profiles/src/hosts_tests.rs | 4 +- ublue/skillet/crates/host-profiles/src/lib.rs | 8 +- .../crates/host-profiles/src/profile.rs | 12 +- ublue/skillet/crates/syncthing/src/lib.rs | 38 ++++-- ublue/skillet/crates/syncthing/src/tests.rs | 112 +++++++++++++++++- 9 files changed, 173 insertions(+), 41 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 6249f6db..f06f03dd 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -15,9 +15,13 @@ - The optional `--with-applications` variant also reached the injected interruption and recovered readiness, but its final application check failed because `/var/lib/data/syncthing` was `core:core` after reboot while - the profile declares `giacomo:giacomo`. Readiness restored the VM to - `Ready`. Investigate this ownership divergence before treating another live - application acceptance run as passing. + the profile declares `giacomo:giacomo`. Syncthing's journal also showed + permission denied opening its config lock. The profile now separates the + host data owner from container UID/GID 1000 and maps that container identity + to the host account through Podman's typed namespace mapping. Regression + coverage is added; live acceptance of this fix remains pending. The current + workstation shell cannot access the selected Flatpak libvirt backend because + `flatpak` is unavailable in this execution environment. - Passed workspace formatting check, `cargo test -p skillet --no-run`, `cargo clippy -p skillet --all-targets -- -D warnings`, ShellCheck for the recovery script, and `git diff --check`. The complete workspace suite and diff --git a/ublue/design/syncthing.md b/ublue/design/syncthing.md index bef763fb..ea65e8ca 100644 --- a/ublue/design/syncthing.md +++ b/ublue/design/syncthing.md @@ -10,8 +10,11 @@ the peer-transfer ports. See the [private UI design](private-ui-access.md). Mount `/var/lib/data/syncthing` at `/var/syncthing`. Keep the upstream directory layout there, including `config/` and synced folders, so a planned state transfer can retain the device identity and folder definitions. The -directory is a Btrfs subvolume owned by the existing `giacomo` account (UID/GID -1000), matching Chef. Never copy rupik's live identity into a disposable VM. +directory is a Btrfs subvolume owned by the declared host account. Syncthing +runs as container UID/GID 1000; Podman's user namespace maps that container +identity to the host account and its primary group. Keep host ownership, +container identity, and namespace mapping explicit and separate. Never copy +rupik's live identity into a disposable VM. The bridged container can limit LAN discovery compared with host networking. This follows the existing Chef port-published container and keeps service-name diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md index d9ffcb15..a81bcbf9 100644 --- a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -1,15 +1,16 @@ # 7. Test layers, fixtures, build artifacts and CI -Status: implementation and named fixture acceptance passed; remote CI and a -Syncthing ownership divergence found during repeat application acceptance -remain open, 2026-10-05. Rust-owned smoke, storage/service fake contracts, +Status: implementation and named fixture acceptance passed; remote CI and live +Syncthing acceptance after correcting the declared host/container identity +mapping remain open, 2026-10-05. Rust-owned smoke, storage/service fake contracts, bootstrap CI execution, and capability-selected application acceptance are implemented. The named fixture smoke and retained update passed. Scripted interrupted-reboot recovery passed; see the [acceptance log](../butane/ACCEPTANCE.md#interrupted-reboot-checkpoint-recovery-2026-10-05). -An optional repeated application check found `/var/lib/data/syncthing` owned by -`core:core` after reboot rather than its declared `giacomo:giacomo`; resolve it -before treating application acceptance as stable. See the +An optional repeated application check exposed a mismatch between the host +account owning `/var/lib/data/syncthing` and Syncthing's container identity. +The profile now declares the container UID/GID separately and maps them to the +host owner; live acceptance after this change remains. See the [follow-up plan](REFACTOR-FOLLOWUP.md). Live legacy import and controlled interrupted-provisioning recovery passed; see the [prerequisite roadmap](SKILLET-REFACTOR.md). diff --git a/ublue/plan/REFACTOR-FOLLOWUP.md b/ublue/plan/REFACTOR-FOLLOWUP.md index de2ed566..61b8cb28 100644 --- a/ublue/plan/REFACTOR-FOLLOWUP.md +++ b/ublue/plan/REFACTOR-FOLLOWUP.md @@ -3,10 +3,10 @@ Status: implementation and local VM acceptance substantially complete, 2026-10-05. Code and local regression coverage for all six findings are in place. Rust-owned fixture smoke, retained-VM update, and scripted interrupted -reboot recovery passed on the named clamps VM. A repeat application check -exposed Syncthing data ownership diverging after reboot; investigate that before -considering live application acceptance stable. Remote CI evidence remains -pending. Keep the refactoring gate open until these items are resolved. +reboot recovery passed on the named clamps VM. Syncthing now declares its host +owner separately from its container UID/GID and maps them with the shared +Podman user-namespace API. Its live application acceptance must be rerun before +considering this finding resolved. Remote CI evidence remains pending. ## Progress @@ -16,7 +16,7 @@ pending. Keep the refactoring gate open until these items are resolved. | 2. Update delivery guard | Implemented; ownership-loss/retry tests and retained-VM update passed | None locally; remote CI pending | | 3. Fake contracts | Implemented; workspace regression suite passes | None locally; remote CI pending | | 4. Bootstrap CI | Implemented; CI runs state regressions and local command passes | Observe remote CI after push | -| 5. Host application acceptance | Implemented from canonical profile capabilities; prior named-VM acceptance passed | Repeat acceptance found `/var/lib/data/syncthing` owned by `core:core` after reboot, versus declared `giacomo:giacomo`; diagnose and rerun | +| 5. Host application acceptance | Implemented from canonical profile capabilities; Syncthing host/container identity mapping corrected with regression coverage | Named-VM Syncthing apply, repeat apply, and reboot acceptance after the mapping change | | 6. Documentation | Updated implementation, commands, design status, and fault-injection evidence | Final review and remote CI result | ## Start here diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 04779231..71e63f2d 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -314,8 +314,8 @@ fn synthetic_profile_derives_network_and_ui_from_its_identity() { data_path: "/srv/sync", data_owner: "sync", data_group: "sync", - uid: 1234, - gid: 1234, + container_uid: 1000, + container_gid: 1000, }, ui: Some(UiServiceDeclaration { name: "sync", diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index e2701657..b5e702ad 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -181,8 +181,8 @@ fn apply_syncthing( data_path, data_owner, data_group, - uid, - gid, + container_uid, + container_gid, } = service.config else { return Err(ApplyError::FixtureInput( @@ -196,8 +196,8 @@ fn apply_syncthing( data_path: data_path.to_string(), data_owner: data_owner.to_string(), data_group: data_group.to_string(), - uid, - gid, + container_uid, + container_gid, network_name: profile.id.as_str().to_string(), }, )?; diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index 5a17c0c6..a343a498 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -48,8 +48,8 @@ pub enum ServiceConfig { data_path: &'static str, data_owner: &'static str, data_group: &'static str, - uid: u32, - gid: u32, + container_uid: u32, + container_gid: u32, }, Unifi, Tailscale { @@ -418,8 +418,8 @@ fn clamps() -> HostProfile { data_path: "/var/lib/data/syncthing", data_owner: "giacomo", data_group: "giacomo", - uid: 1000, - gid: 1000, + container_uid: 1000, + container_gid: 1000, }, ui: Some(CLAMPS_SYNCTHING_UI), }, @@ -454,8 +454,8 @@ fn beezelbot() -> HostProfile { data_path: "/var/lib/data/syncthing", data_owner: "giacomo", data_group: "giacomo", - uid: 1000, - gid: 1000, + container_uid: 1000, + container_gid: 1000, }, ui: Some(BEEZELBOT_SYNCTHING_UI), }], diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index d4721c07..cfcea2c7 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -5,8 +5,8 @@ use skillet_core::system::{ AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, }; use skillet_podman::{ - self, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, - PortPublication, ProcessIdentity, Volume, + self, HostUser, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, + PortPublication, ProcessIdentity, UserNamespaceMapping, Volume, }; use std::collections::BTreeMap; use std::path::Path; @@ -21,14 +21,20 @@ pub enum SyncthingError { File(#[from] FileError), #[error("Podman error: {0}")] Podman(#[from] PodmanError), + #[error("configured Syncthing data owner user `{0}` does not exist")] + UnknownDataOwner(String), + #[error("configured Syncthing data owner group `{0}` does not exist")] + UnknownDataGroup(String), + #[error("configured Syncthing data group `{group}` is not the primary group of `{owner}`")] + NonPrimaryDataGroup { owner: String, group: String }, } pub struct SyncthingConfig { pub data_path: String, pub data_owner: String, pub data_group: String, - pub uid: u32, - pub gid: u32, + pub container_uid: u32, + pub container_gid: u32, pub network_name: String, } @@ -38,6 +44,19 @@ where F: FileMutationResource + FileReadResource + StorageResource + ?Sized, { info!("Applying Syncthing configuration..."); + let user = system + .user_by_name(&config.data_owner)? + .ok_or_else(|| SyncthingError::UnknownDataOwner(config.data_owner.clone()))?; + let group = system + .group_by_name(&config.data_group)? + .ok_or_else(|| SyncthingError::UnknownDataGroup(config.data_group.clone()))?; + if user.primary_gid != group.gid { + return Err(SyncthingError::NonPrimaryDataGroup { + owner: config.data_owner, + group: config.data_group, + }); + } + let data_path = Path::new(&config.data_path); files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; files.ensure_btrfs_subvolume(data_path)?; @@ -62,8 +81,6 @@ where vec![ "AutoUpdate=registry".to_string(), "ContainerName=syncthing".to_string(), - format!("Environment=PGID={}", config.gid), - format!("Environment=PUID={}", config.uid), ], ); extra_config.insert( @@ -112,8 +129,13 @@ where }, ], storage_dependency: Some(MountDependency::shared_service_data()), - process_identity: ProcessIdentity::ImageDefault, - namespace_mapping: None, + process_identity: ProcessIdentity::Numeric { + uid: config.container_uid, + gid: config.container_gid, + }, + namespace_mapping: Some(UserNamespaceMapping { + host_user: HostUser::Name(config.data_owner), + }), volumes, secrets: Vec::new(), config_revisions: Vec::new(), diff --git a/ublue/skillet/crates/syncthing/src/tests.rs b/ublue/skillet/crates/syncthing/src/tests.rs index 3d59825f..bfab9c9c 100644 --- a/ublue/skillet/crates/syncthing/src/tests.rs +++ b/ublue/skillet/crates/syncthing/src/tests.rs @@ -1,11 +1,37 @@ use super::*; -use skillet_core::test_utils::{MockFiles, MockSystem}; +use skillet_core::{ + system::{GroupIdentity, UserIdentity}, + test_utils::{MockFiles, MockSystem}, +}; #[test] fn syncthing_uses_persistent_data_and_shared_dns_network() { let system = MockSystem::new(); + system.user_identities.lock().unwrap().insert( + "giacomo".to_string(), + UserIdentity { + name: "giacomo".to_string(), + uid: 1042, + primary_gid: 2047, + }, + ); + system.group_identities.lock().unwrap().insert( + "giacomo".to_string(), + GroupIdentity { + name: "giacomo".to_string(), + gid: 2047, + }, + ); let files = MockFiles::new(); setup_data_mount(&files); + files.files.lock().unwrap().insert( + "/etc/subuid".to_string(), + b"giacomo:100000:65536\n".to_vec(), + ); + files.files.lock().unwrap().insert( + "/etc/subgid".to_string(), + b"giacomo:200000:65536\n".to_vec(), + ); apply( &system, @@ -14,8 +40,8 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { data_path: "/var/lib/data/syncthing".to_string(), data_owner: "giacomo".to_string(), data_group: "giacomo".to_string(), - uid: 1000, - gid: 1000, + container_uid: 1000, + container_gid: 1000, network_name: "clamps".to_string(), }, ) @@ -29,8 +55,9 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { for directive in [ "AutoUpdate=registry", "ContainerName=syncthing", - "Environment=PGID=1000", - "Environment=PUID=1000", + "User=1000:1000", + "UIDMap=1000:1042:1", + "GIDMap=1000:2047:1", "Network=clamps.network", "PublishPort=[::]:22000:22000/tcp", "PublishPort=0.0.0.0:22000:22000/tcp", @@ -40,6 +67,8 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { ] { assert!(quadlet.contains(directive), "missing directive {directive}"); } + assert!(!quadlet.contains("Environment=PGID=")); + assert!(!quadlet.contains("Environment=PUID=")); assert!( !quadlet .lines() @@ -48,6 +77,79 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { ); } +#[test] +fn syncthing_requires_declared_host_account_identities() { + let system = MockSystem::new(); + let files = MockFiles::new(); + setup_data_mount(&files); + + let result = apply( + &system, + &files, + SyncthingConfig { + data_path: "/var/lib/data/syncthing".to_string(), + data_owner: "giacomo".to_string(), + data_group: "giacomo".to_string(), + container_uid: 1000, + container_gid: 1000, + network_name: "clamps".to_string(), + }, + ); + + assert!(matches!(result, Err(SyncthingError::UnknownDataOwner(name)) if name == "giacomo")); + assert!(!files + .directories + .lock() + .unwrap() + .contains("/var/lib/data/syncthing")); +} + +#[test] +fn syncthing_rejects_a_non_primary_host_group_for_namespace_mapping() { + let system = MockSystem::new(); + system.user_identities.lock().unwrap().insert( + "giacomo".to_string(), + UserIdentity { + name: "giacomo".to_string(), + uid: 1042, + primary_gid: 2047, + }, + ); + system.group_identities.lock().unwrap().insert( + "data".to_string(), + GroupIdentity { + name: "data".to_string(), + gid: 2048, + }, + ); + let files = MockFiles::new(); + setup_data_mount(&files); + + let result = apply( + &system, + &files, + SyncthingConfig { + data_path: "/var/lib/data/syncthing".to_string(), + data_owner: "giacomo".to_string(), + data_group: "data".to_string(), + container_uid: 1000, + container_gid: 1000, + network_name: "clamps".to_string(), + }, + ); + + assert!(matches!( + result, + Err(SyncthingError::NonPrimaryDataGroup { owner, group }) + if owner == "giacomo" && group == "data" + )); + assert!(!files + .directories + .lock() + .unwrap() + .contains("/var/lib/data/syncthing")); +} + fn setup_data_mount(files: &MockFiles) { files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/test", "/", "btrfs"); files.record_btrfs_mount( From 126004f7b35b59a0ee119c5eb2d133e58c5a49bf Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 17:52:41 +0200 Subject: [PATCH 201/252] fix(cli): improve application smoke diagnostics --- .../cli/src/application_acceptance_tests.rs | 8 +- ublue/skillet/crates/cli/src/main.rs | 48 ++++++- ublue/skillet/crates/cli/src/vm.rs | 118 ++++++++++++++---- 3 files changed, 141 insertions(+), 33 deletions(-) diff --git a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs index f5ee37dc..21071396 100644 --- a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs +++ b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs @@ -28,7 +28,9 @@ impl GuestTransport for AcceptanceTransport { ) .into_bytes() } - ("stat", ["-c", "%U:%G", _]) => format!("{}\n", self.owner).into_bytes(), + ("id", ["-u", "giacomo"]) => b"1042\n".to_vec(), + ("getent", ["group", "giacomo"]) => b"giacomo:x:2047:\n".to_vec(), + ("stat", ["-c", "%u:%g", _]) => format!("{}\n", self.owner).into_bytes(), ("sudo", ["-n", "ss", "-H", _, "sport", "=", port]) if self.missing_listener != port.trim_start_matches(':').parse().ok() => { @@ -55,7 +57,7 @@ fn transport() -> AcceptanceTransport { active: true, network: "beezelbot".into(), missing_listener: None, - owner: "giacomo:giacomo".into(), + owner: "1042:2047".into(), container_id: "container-id".into(), } } @@ -116,7 +118,7 @@ fn application_snapshot_rejects_wrong_network_and_missing_listener() { fn application_snapshot_rejects_wrong_data_ownership_and_changed_identity() { let plan = profile_for_name("beezelbot").unwrap().acceptance_plan(); let mut wrong_owner = transport(); - wrong_owner.owner = "core:core".into(); + wrong_owner.owner = "1000:1000".into(); assert!(application_snapshot(&wrong_owner, &|| Ok(()), &plan) .unwrap_err() .to_string() diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index eda4d6de..adcaf708 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -612,8 +612,15 @@ impl SmokeLifecycle<'_> { self.repeat_host_apply(transport, &profile)?; let second = application_snapshot(transport, ownership, &plan)?; if first != second { + let changed = first + .iter() + .zip(&second) + .filter(|(before, after)| before != after) + .map(|(before, _)| application_state_label(before)) + .collect::>(); return Err(anyhow!( - "repeat host apply changed declared application runtime state" + "repeat host apply changed declared application runtime state: {}", + changed.join(", ") )); } tracing::info!( @@ -693,6 +700,17 @@ impl SmokeLifecycle<'_> { } } +fn application_state_label(state: &str) -> &str { + let mut fields = state.split(':'); + match fields.next() { + Some("container" | "unit" | "config" | "bind" | "listener") => { + fields.next().unwrap_or("unknown") + } + Some(name) => name, + None => "unknown", + } +} + fn application_snapshot( transport: &impl skillet_vm::transport::GuestTransport, ownership: &dyn Fn() -> skillet_vm::Result<()>, @@ -831,7 +849,19 @@ fn check_container( if let Some(owner_expectation) = &service.owner { use skillet_hosts::AcceptanceOwner; let (format, expected) = match owner_expectation { - AcceptanceOwner::Named { user, group } => ("%U:%G", format!("{user}:{group}")), + AcceptanceOwner::Named { user, group } => { + let uid = guest_text(transport, "id", &["-u", user])? + .parse::() + .context("resolving declared data owner UID")?; + let group_record = guest_text(transport, "getent", &["group", group])?; + let gid = group_record + .split(':') + .nth(2) + .ok_or_else(|| anyhow!("resolving declared data owner group GID"))? + .parse::() + .context("parsing declared data owner group GID")?; + ("%u:%g", format!("{uid}:{gid}")) + } AcceptanceOwner::Numeric { uid, gid } => ("%u:%g", format!("{uid}:{gid}")), }; let owner = guest_text(transport, "stat", &["-c", format, path])?; @@ -958,9 +988,19 @@ fn guest_text( None, )?; if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let command = std::iter::once(program) + .chain(arguments.iter().copied()) + .collect::>() + .join(" "); return Err(anyhow!( - "guest probe {program} failed with status {:?}", - output.status.code() + "guest probe `{command}` failed with status {:?}{}", + output.status.code(), + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } )); } String::from_utf8(output.stdout) diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 74bc04d7..15597057 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -303,40 +303,106 @@ pub(super) fn logs(args: &VmTargetArgs) -> Result<()> { let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; require_running_domain(&backend, &run)?; let transport = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Enroll)?; - for command in [ - GuestCommand { - program: "sudo", - arguments: &["-n", "rpm-ostree", "status"], - }, - GuestCommand { - program: "sudo", - arguments: &[ + for (description, command) in [ + ( + "deployment status", + GuestCommand { + program: "sudo", + arguments: &["-n", "rpm-ostree", "status"], + }, + ), + ( + "bootstrap and base-apply journal", + GuestCommand { + program: "sudo", + arguments: &[ + "-n", + "journalctl", + "-b", + "-u", + "ucore-bootstrap.service", + "-u", + "skillet-apply.service", + "--no-pager", + "-n", + "200", + ], + }, + ), + ] { + print_vm_diagnostic(&backend, &run, &transport, &command, description)?; + } + + if let Some(profile) = skillet_hosts::profile_for_name(identity.host()) { + let plan = profile.acceptance_plan(); + let credential_consumers = profile.credential_consumers(); + for service in plan + .services + .iter() + .filter(|service| service.container.is_some()) + .filter(|service| { + !credential_consumers + .iter() + .any(|consumer| consumer.unit == service.unit) + }) + { + let arguments = [ "-n", "journalctl", "-b", "-u", - "ucore-bootstrap.service", - "-u", - "skillet-apply.service", + service.unit.as_str(), "--no-pager", "-n", - "200", - ], - }, - ] { - require_running_domain(&backend, &run)?; - let output = transport.execute(&command, None)?; - require_owned_domain(&backend, &run)?; - std::io::stdout().write_all(&output.stdout)?; - std::io::stderr().write_all(&output.stderr)?; - if !output.status.success() { - return Err(anyhow!( - "guest diagnostic command {} failed with status {:?}", - command.program, - output.status.code() - )); + "120", + ]; + let command = GuestCommand { + program: "sudo", + arguments: &arguments, + }; + print_vm_diagnostic( + &backend, + &run, + &transport, + &command, + &format!("{} journal", service.unit), + )?; } } + + let command = GuestCommand { + program: "sudo", + arguments: &[ + "-n", + "podman", + "ps", + "-a", + "--format", + "{{.Names}}|{{.Status}}|{{.Image}}", + ], + }; + print_vm_diagnostic(&backend, &run, &transport, &command, "Podman containers")?; + Ok(()) +} + +fn print_vm_diagnostic( + backend: &VirshBackend, + run: &VmRun, + transport: &impl GuestTransport, + command: &GuestCommand<'_>, + description: &str, +) -> Result<()> { + require_running_domain(backend, run)?; + let output = transport.execute(command, None)?; + require_owned_domain(backend, run)?; + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + if !output.status.success() { + return Err(anyhow!( + "guest diagnostic {description} failed with status {:?}", + output.status.code() + )); + } Ok(()) } From 71eb002eff4301846402dacdad85753613fb97d6 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 5 Oct 2026 23:12:21 +0200 Subject: [PATCH 202/252] docs(plan): park Syncthing VM acceptance --- ublue/butane/ACCEPTANCE.md | 5 ++--- ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md | 3 ++- ublue/plan/REFACTOR-FOLLOWUP.md | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index f06f03dd..c484b09d 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -19,9 +19,8 @@ permission denied opening its config lock. The profile now separates the host data owner from container UID/GID 1000 and maps that container identity to the host account through Podman's typed namespace mapping. Regression - coverage is added; live acceptance of this fix remains pending. The current - workstation shell cannot access the selected Flatpak libvirt backend because - `flatpak` is unavailable in this execution environment. + coverage is added; live acceptance of this fix is parked until workstation + access to the Flatpak libvirt backend returns. - Passed workspace formatting check, `cargo test -p skillet --no-run`, `cargo clippy -p skillet --all-targets -- -D warnings`, ShellCheck for the recovery script, and `git diff --check`. The complete workspace suite and diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md index a81bcbf9..41f601ea 100644 --- a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -10,7 +10,8 @@ interrupted-reboot recovery passed; see the An optional repeated application check exposed a mismatch between the host account owning `/var/lib/data/syncthing` and Syncthing's container identity. The profile now declares the container UID/GID separately and maps them to the -host owner; live acceptance after this change remains. See the +host owner; live acceptance after this change is parked until Flatpak libvirt +access returns. See the [follow-up plan](REFACTOR-FOLLOWUP.md). Live legacy import and controlled interrupted-provisioning recovery passed; see the [prerequisite roadmap](SKILLET-REFACTOR.md). diff --git a/ublue/plan/REFACTOR-FOLLOWUP.md b/ublue/plan/REFACTOR-FOLLOWUP.md index 61b8cb28..0f805de3 100644 --- a/ublue/plan/REFACTOR-FOLLOWUP.md +++ b/ublue/plan/REFACTOR-FOLLOWUP.md @@ -5,8 +5,9 @@ Status: implementation and local VM acceptance substantially complete, place. Rust-owned fixture smoke, retained-VM update, and scripted interrupted reboot recovery passed on the named clamps VM. Syncthing now declares its host owner separately from its container UID/GID and maps them with the shared -Podman user-namespace API. Its live application acceptance must be rerun before -considering this finding resolved. Remote CI evidence remains pending. +Podman user-namespace API. Its live application acceptance is parked until +Flatpak libvirt access returns; then rerun named-VM apply, repeat-apply, and +reboot acceptance. Remote CI evidence remains pending. ## Progress @@ -16,7 +17,7 @@ considering this finding resolved. Remote CI evidence remains pending. | 2. Update delivery guard | Implemented; ownership-loss/retry tests and retained-VM update passed | None locally; remote CI pending | | 3. Fake contracts | Implemented; workspace regression suite passes | None locally; remote CI pending | | 4. Bootstrap CI | Implemented; CI runs state regressions and local command passes | Observe remote CI after push | -| 5. Host application acceptance | Implemented from canonical profile capabilities; Syncthing host/container identity mapping corrected with regression coverage | Named-VM Syncthing apply, repeat apply, and reboot acceptance after the mapping change | +| 5. Host application acceptance | Implemented from canonical profile capabilities; Syncthing host/container identity mapping corrected with regression coverage | PARKED: named-VM Syncthing apply, repeat apply, and reboot acceptance until Flatpak libvirt access returns | | 6. Documentation | Updated implementation, commands, design status, and fault-injection evidence | Final review and remote CI result | ## Start here From c8f5d7ee4765f0c4c9ff72984fe0fb665e1755c8 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 6 Oct 2026 11:01:13 +0200 Subject: [PATCH 203/252] docs(clamps): audit parity and plan Cloudflare DDNS port --- ublue/butane/ACCEPTANCE.md | 18 ++++ ublue/design/cloudflare-ddns.md | 31 ++++++ ublue/design/monitoring.md | 10 ++ ublue/design/private-ui-access.md | 11 +- ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md | 131 +++++++++++++++++++++++ ublue/plan/CLAMPS-MIGRATION.md | 31 ++++-- ublue/plan/CLAMPS-PARITY.md | 47 ++++++++ ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md | 4 +- ublue/plan/CLAMPS-UNIFI.md | 8 +- ublue/plan/REFACTOR-FOLLOWUP.md | 6 ++ ublue/plan/SKILLET-REFACTOR.md | 21 ++-- 11 files changed, 288 insertions(+), 30 deletions(-) create mode 100644 ublue/design/cloudflare-ddns.md create mode 100644 ublue/design/monitoring.md create mode 100644 ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md create mode 100644 ublue/plan/CLAMPS-PARITY.md diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index c484b09d..0f6008d6 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,23 @@ # Clamps VM acceptance log +## Migration documentation audit, 2026-10-06 + +- Reconciled the refactor/migration status against current source and recorded + acceptance. Refactor code is committed; Syncthing ownership-fix VM acceptance + stays parked until Flatpak libvirt access returns. Remote CI remains pending. +- Added the source-level Rupik parity checklist and DDNS implementation plan. + The active Chef path includes DDNS and Datadog. Chef's Tailscale exit-node + advertisement has no corresponding option in current Skillet composition. + The legacy NAT/second updater recipe is outside the default include path; + its effective live state remains unknown. +- Recorded the user's decision to retain Datadog. DDNS remains planned; + Datadog port and isolated UniFi backup/restore follow it. No provider mutation, + guest operation, production data transfer, or new runtime acceptance occurred. +- Prior ownership-fix validation passed formatting, GNU-target strict workspace + Clippy, and Syncthing/host-profile/Podman tests. The full workspace run had 10 + provider-fixture failures (`Operation not permitted` creating local HTTP + listeners) in this sandbox. Do not treat that run as a full-suite pass. + ## Interrupted reboot checkpoint recovery, 2026-10-05 - Added `bash ../skillet/integration_tests/interrupted-reboot-recovery.sh HOST INSTANCE` diff --git a/ublue/design/cloudflare-ddns.md b/ublue/design/cloudflare-ddns.md new file mode 100644 index 00000000..90b74d2c --- /dev/null +++ b/ublue/design/cloudflare-ddns.md @@ -0,0 +1,31 @@ +# Public address DDNS + +Decision, 2026-10-06: retain Rupik's public-address DDNS capability. Plan to +reuse the existing Chef image family in a rootful Quadlet container, with +caller opt-in and private configuration supplied through Skillet credentials. +This preserves the existing updater instead of introducing another guest +Cloudflare API client. Implementation is pending; verify the chosen image's +config schema and Podman secret-file consumption before committing to its +runtime integration. + +The DDNS container owns only its explicitly configured public-address records. +The existing workstation UI lifecycle owns tailnet machine records and UI +CNAMEs. Keep those record sets disjoint. One production writer owns the retained +DDNS records during cutover; stopping Rupik's writer precedes starting Clamps'. + +Match Chef's initial policy: A enabled, AAAA disabled, TTL 300, unknown-record +purging disabled. Preserve each record's approved proxy status. Do not derive +DDNS names from the UI namespace or enable IPv6 without a usable public address +and an explicit decision. + +The workstation issuer supplies a separate zone-scoped DDNS child token; +the token creator stays on the workstation. Deliver the complete config as an +encrypted credential and mount its Podman secret at the updater's config path. +Avoid a plaintext token-bearing config file on persistent host storage. Podman's +own disk secret copy remains covered by the planned storage encryption policy. +Use existing atomic delivery, deferred activation, and lifecycle ownership. + +Disposable tests use explicitly isolated record names and bounded credentials. +Journal and clean only test-owned resources; disposal never removes retained +production DDNS records. See the [implementation plan](../plan/CLAMPS-CLOUDFLARE-DDNS.md) +and [existing token/DNS lifecycle](cloudflare-ui-lifecycle.md). diff --git a/ublue/design/monitoring.md b/ublue/design/monitoring.md new file mode 100644 index 00000000..57bfa008 --- /dev/null +++ b/ublue/design/monitoring.md @@ -0,0 +1,10 @@ +# Fleet monitoring + +Decision, 2026-10-06: keep Datadog when migrating Rupik to Clamps. Preserve the +required host, process, network, filesystem, and service checks after auditing +the effective Chef configuration. Caddy replaces nginx, so nginx-specific +monitoring cannot be copied unchanged. + +Local metrics and Grafana may be added later; that decision and implementation +are outside the current migration. Datadog implementation is pending and follows +the DDNS milestone. Keep credentials and private check targets in the vault. diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index 867e6f14..5f163b14 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -74,8 +74,8 @@ planes. The names and tailnet IPs are public DNS data. ## Status and references The [Cloudflare ownership lifecycle](cloudflare-ui-lifecycle.md) now issues -credentials automatically and cleans DNS after token expiry. Live ACME -acceptance remains pending; no manually created VM token is required. +credentials automatically and cleans owned DNS even when a disposable token has +expired. Staging ACME acceptance passed; no manually created VM token is required. Host UI service declarations now drive a shared versioned Caddy payload. Provisioning derives `..` from the selected @@ -98,9 +98,10 @@ on 2026-09-30. Caddy now has a generic Quadlet configuration for the UI services declared by the host, using bridge DNS, persistent certificate storage, Cloudflare DNS-01, and source-address restrictions for Tailscale's IPv4 and IPv6 ranges. Its hostname and token credentials are separate from normal full apply. -Syncthing no longer publishes its GUI port on the host. Caddy, ACME staging, -DNS record lifecycle, client reachability, certificate renewal, and interrupted -recovery still need live VM acceptance. +Syncthing no longer publishes its GUI port on the host. Staging Caddy/ACME and +disposable token/DNS cleanup passed live acceptance. Real-client DNS/HTTPS, +production renewal, and denial from a separate network remain open in the UI +plan. The Syncthing ownership-fix runtime check is separately parked. References: [Tailscale DNS](https://tailscale.com/docs/reference/dns-in-tailscale/), [Cloudflare DNS-only records](https://developers.cloudflare.com/dns/proxy-status/), diff --git a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md new file mode 100644 index 00000000..f2169572 --- /dev/null +++ b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md @@ -0,0 +1,131 @@ +# Cloudflare DDNS port + +Status: planned, 2026-10-06. Next missing Clamps service from the +[parity audit](CLAMPS-PARITY.md). No DDNS service, vault schema, or delivery command +described below is implemented yet. Refactor implementation is committed; +Syncthing live acceptance is parked and remote CI evidence is pending. Planning +can proceed; keep unavailable runtime acceptance explicit during implementation. + +Read `../AGENTS.md`, `../skillet/AGENTS.md`, `../design/cloudflare-ddns.md`, +`../design/secrets.md`, `../design/cloudflare-ui-lifecycle.md`, and +`../design/smoke-vms.md`. Install nothing on the workstation. Use Rust +resources/crates for orchestration and provider APIs; never embed shell programs. + +## Source contract + +`site-cookbooks/rupik/recipes/cloudflare_ddns.rb` is included by Rupik's default +recipe. It supplies one Cloudflare zone and private subdomain configuration; +A is enabled, AAAA disabled, TTL 300, and `purgeUnknownRecords=false`. +`site-cookbooks/cloudflare_ddns/recipes/default.rb` runs the image family +`docker.io/timothyjmiller/cloudflare-ddns` with a JSON config at `/config.json`. +It also creates a host account and sets PUID/PGID environment variables. Do not +assume these variables control the process or reproduce unnecessary host accounts. + +The live image version, polling cadence, full record/proxy configuration, and +any second updater on Rupik need private inventory before production activation. +The legacy `rupik::network` cron is not selected by the default include path. + +## 1. Establish the private config and image contract + +- Inspect official image source/docs for the selected version, JSON schema, + config-file access, refresh cadence, runtime identity, and credential support. + Pin an evaluated image version/digest in the implementation; do not invent its + behavior from the Chef variables. Record findings in the design. +- Initial support is one selected environment zone, obtained from existing + `skillet/environments//dns/cloudflare-zone-id`. Verify the retained + DDNS records actually belong to it; an additional zone needs an explicit schema + extension, never automatic token-scope broadening. +- Planned host config entry: + `skillet/environments//hosts//cloudflare/ddns-config`. + Define a versioned JSON schema with relative record names and explicit approved + proxy state; initial address/TTL/purge policy matches Chef. Resolve record names + directly below the selected zone, independently of `dns/ui-domain`. +- Validate names, duplicate/conflicting records, and overlap with the host's UI + records before token issuance or delivery. Existing unrelated records require + an explicit takeover decision. Do not populate production values in this repo. + +Exit: documented image/config contract and pure validation/rendering tests using +reserved example names. Secret-file consumption is confirmed before activation +work. Private source inventory remains explicit if the host is unavailable. + +## 2. Declare the service and compose its Quadlet + +- Add a DDNS capability and credential consumer to the canonical host profile; + Clamps opts in, other callers may opt in. Keep declarations free of real DNS + values. Service/UI/credential eligibility derives from that same declaration. +- Add an independently credential-gated DDNS apply phase/unit, following the + existing Caddy separation. Base/full convergence must continue to work before + DDNS credentials exist; declaring the service must not add its credential to + the ordinary full-apply prerequisites. Keep this dispatch shared across hosts. +- Add the small service module using existing Podman and convergence resources. + Require the shared mounted service-data/Podman store. Use a rootful container, + restart/boot policy, network-online ordering, and the evaluated image. + Avoid a private-UI declaration or host port publications. +- Planned credential/Podman secret name: `cloudflare_ddns_config`. Parse and + validate the delivered payload before installing the secret. Mount the secret + at `/config.json` with the numeric UID/GID/mode proved by the image contract; + explicitly model process identity and namespace mapping if needed. +- If the image cannot consume this secret file, stop this slice and document a + revised integration rather than writing token-bearing config to disk silently. +- Connect changed config/secret revisions to the existing restart/retry behavior. + Unchanged convergence must preserve container identity. Missing credentials must + leave DDNS inactive without breaking unrelated service apply. + +Exit: CI covers caller opt-in, required credentials, secret mount, runtime identity, +missing data mount, no-op repeat apply, rotation, and failed-activation retry. +Routine CI requires no vault, external IP discovery, Cloudflare, or hypervisor. + +## 3. Issue and deliver the DDNS credential + +- Reuse the workstation Cloudflare SDK/account-token issuer and atomic KDBX writer. + Planned token path: + `skillet/environments//hosts//cloudflare/ddns-token`. + Persistent policy uses-or-creates a separate zone-scoped Zone Read/DNS Write + token; it never delivers the token creator or reuses the Caddy token implicitly. +- Build the image-specific JSON in memory from validated config and child token. + Deliver through existing encrypted SSH/systemd credential handling, then create + the Podman secret. Never put the token or complete payload in argv, logs, + manifests, recordings, or a plaintext host config file. +- Deliver all credentials needed by the selected activation phase before starting + consumers. Preserve existing commands; add shared eligibility/phase support as + necessary rather than a Clamps-specific command path. +- Use the existing issuance journals, verified transport, run lock, immutable + target checks, and retry policy. Rotation is explicit; repeated delivery reuses + existing persistent credentials. Keep provider dependencies workstation-side. + +Exit: fake-provider tests cover use-or-create, secret-safe errors, interrupted +delivery/activation, and rotation. No guest apply depends on KeePassXC or the +workstation issuer. + +## 4. Disposable acceptance and production handover + +- Make test DDNS activation opt-in. Use only explicitly configured test record + names and the test environment zone; refuse production config on a disposable + VM. Journal ownership before writes, detect record collisions, and use a + bounded-lifetime token. Zone tokens cannot enforce per-record scope; allowlisting + and ownership validation belong to provisioning and the updater's config. +- Keep DDNS record ownership separate from the UI lifecycle. The workstation may + preflight/journal test records, but the DDNS runtime is their continuing writer. + Cleanup verifies recorded identity and records, then deletes only test-owned + records and revokes the DDNS token. Persistent disposal never deletes production + records. If the image cannot preserve reliable ownership metadata, design the + record-journaling interface before permitting test record creation. +- On a named VM verify a record receives the discovered public IPv4, stays + DNS-policy compatible, leaves unrelated A/AAAA/CNAME/TXT records untouched, + survives repeat apply/reboot, and reuses its credential. Test credential rotation + and disposal without printing private values. Park these checks while Flatpak + libvirt is unavailable; record results in `../butane/ACCEPTANCE.md` later. +- Before production: privately inventory Rupik's exact records, TTL/proxy state, + image/version/cadence, and active updater services/cron. Approve the takeover + record set; stop the old writer, activate Clamps, verify records and services, + and keep a rollback path that runs only one writer. Do not trigger this handover + as part of an implementation test. + +Exit: local checks pass and named-VM results or exact unavailable checks are +recorded. Production mutation and DNS-writer handover remain a separate cutover. + +## Handoff + +Implement slices in order with focused commits. Update this plan/design and the +parity checklist after each slice; do not claim production parity from rendering +tests. Datadog port and UniFi backup/restore are the following work items. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 3c4eb77a..0e9c5539 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -4,8 +4,10 @@ Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) before proceeding with the remaining feature milestones in this plan. Existing acceptance evidence and deferred tasks remain applicable. -Status: remaining work, 2026-09-27. Skillet convergence and disposable VM -bootstrap are implemented; their completed agent assignments have been removed. +Status: reconciled against source and acceptance evidence, 2026-10-06. +Refactor implementation is committed; Syncthing live validation is parked and +remote CI evidence is pending. Parity auditing and DDNS planning proceed while +that check is parked. Skillet convergence and disposable VM bootstrap exist. Runtime evidence and outstanding bootstrap checks are recorded in [ACCEPTANCE.md](../butane/ACCEPTANCE.md). @@ -18,8 +20,14 @@ checks, temporary VM SSH keys, binary delivery, resolver configuration, signed bootstrap, user environment, exact KeePassXC reads, and encrypted Pi-hole credential delivery, and the shared Btrfs data layout. A dummy-credential Pi-hole container runs in the VM with data and Podman storage under -`/var/lib/data`; service networking, live ACME, final disk selection, and -post-provision rebase checks remain open. +`/var/lib/data`. Shared service networking, test Cloudflare lifecycle, and +staging ACME/HTTPS have passed named-VM acceptance. Final physical disk, +production/network acceptance, and remaining rebase/bootstrap checks stay open. + +The [source parity checklist](CLAMPS-PARITY.md) records retained behavior and +deliberate changes. The next missing service is +[Cloudflare DDNS](CLAMPS-CLOUDFLARE-DDNS.md). Datadog is retained by user +decision; its port and UniFi backup/restore follow the DDNS milestone. After the refactoring prerequisite: complete Pi-hole LAN acceptance and configure the actual custom DNS @@ -116,13 +124,13 @@ Each row is a separate implementation and validation step. Adjust order for depe | Order | Capability | Behavior and state to preserve | | --- | --- | --- | -| 1 | Syncthing (implemented; disposable VM service and repeat-apply checks passed) | Data, device identity, folder configuration, UID/GID, required ports | +| 1 | Syncthing (implemented; ownership correction committed; live retry parked) | Data, device identity, folder configuration, UID/GID, required ports; production peer and state transfer remain | | 2 | btrbk (implemented and verified) | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | -| 3 | UniFi (implementation added; acceptance pending) | Follow the [UniFi port plan](CLAMPS-UNIFI.md): rootful host-network container for discovery/adoption, persistent data, and supported backup restore/version compatibility before cutover. No Caddy route. | -| 4 | Tailscale (implemented; smoke enrollment and cleanup accepted 2026-09-30) | Host-network container, persistent state, KeePassXC OAuth client, one-use tagged key delivery, and smoke device cleanup are implemented. Production forwarding remains to validate. UI names use Cloudflare DNS-only records; Tailscale DNS management is not required. | -| 5 | Private UI access with Caddy + ACME (implementation started) | Caddy's credential-gated Quadlet config proxies the Pi-hole and Syncthing UIs by bridge DNS name; Syncthing's host-published GUI port is removed. Next wire smoke-specific KeePassXC values, mint per-VM zone-scoped tokens and manage DNS-only A/AAAA records through the workstation Cloudflare token creator. Validate tailnet-only access, ACME staging, certificate persistence/renewal, record/token cleanup, repeated runs and interruption recovery. | -| 6 | Cloudflare DDNS | Required records and token delivery; reconcile the legacy updater before enabling competing writers | -| 7 | Monitoring and remaining host baseline | Explicit keep/drop decision for Datadog; required hardening, users, SSH/sudo, ET and WOL behavior | +| 3 | UniFi (implemented; empty-controller/repeat-apply acceptance passed) | Follow the [UniFi port plan](CLAMPS-UNIFI.md): rootful host-network container, persistent data, and isolated backup restore/version compatibility before cutover. No Caddy route. | +| 4 | Tailscale (implemented; smoke enrollment and cleanup accepted) | Host network, state, OAuth enrollment, and cleanup exist. Chef's exit-node advertisement is missing in the current declaration; add caller-controlled behavior and validate production forwarding. UI names use Cloudflare DNS-only records. | +| 5 | Private UI access with Caddy + ACME (implemented; staging accepted) | Generic declarations/aliases, encrypted delivery, scoped account tokens, A/AAAA/CNAME reconciliation and cleanup exist. Staging ACME/HTTPS passed. Production renewal, real-client resolution and separate-network denial remain; see [UI plan](GENERIC-PRIVATE-UIS.md). | +| 6 | Cloudflare DDNS (next service plan) | Follow [DDNS plan](CLAMPS-CLOUDFLARE-DDNS.md); public-address record ownership is separate from UI DNS; no competing production writer | +| 7 | Datadog and remaining host baseline | Keep Datadog for now; port after DDNS. Audit hardening, users, SSH/sudo, ET and WOL targets against the [parity checklist](CLAMPS-PARITY.md) | For each service: inspect effective Chef inputs, settle its open decisions, add only needed Skillet support, migrate a copy of state, validate functionality and repeat convergence. Account for ARM-to-x86 application/image and data compatibility. Avoid activating duplicate production identities or DNS writers during testing. @@ -152,7 +160,8 @@ Done when: all retained capabilities and data work on clamps, routine convergenc OS rebase acceptance. The disposable VM mount-failure and graphroot checks passed. - Credentials: production TPM/rebase policy and protection of Podman's on-disk copy; portable storage and delivery are decided in the secret design. -- Services: actual DNS records, domains/routes, Tailscale enrollment, Nebula Sync, Datadog and legacy networking. +- Services: actual DNS/DDNS records, retained cross-host routes, exit-node operation, + Nebula Sync, and legacy networking. Datadog retention is decided; its port is pending. - Cutover: address/identity transitions, outage window and rollback duration. ## Technical references diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md new file mode 100644 index 00000000..5e41a8ee --- /dev/null +++ b/ublue/plan/CLAMPS-PARITY.md @@ -0,0 +1,47 @@ +# Rupik to Clamps parity checklist + +Status: source audit, 2026-10-06. This compares the repository's Chef include +path with current Skillet, Butane, and image definitions. It is not an inventory +of effective private Chef attributes or live Rupik state. Never copy production +domains, addresses, record names, zone IDs, tokens, or backups into this file. + +Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. +`site-cookbooks/rupik/recipes/default.rb` selects the application recipes below; +`server::default` supplies hardening, users, and Chef maintenance. + +| Chef capability | Clamps implementation / deliberate change | Remaining work | +| --- | --- | --- | +| Btrfs `/srv` and system/user Podman stores | Shared `/var/lib/data` subvolume; system graphroot under `containers/system`, overlay driver on uCore | Final physical disk, encrypted backing filesystem, state transfer; rootless store only if a retained workload needs it | +| Disable resolved; Pi-hole | Mask/resolver setup, dual-stack port 53, persistent service data, encrypted password, shared bridge | Real LAN/firewall acceptance, actual custom DNS configuration, data transfer | +| nginx, ACME, OAuth proxy, web pod | Separate bridge containers; generic Caddy routes, Cloudflare DNS-01, tailnet restriction, per-service names and aliases | Production certificate/renewal and external-source denial; inventory any legacy cross-host routes still needed | +| Syncthing | Container and leaf data subvolume; peer ports retained; host GUI publication removed; explicit host/container identity mapping | PARKED: live ownership fix acceptance; production peer connectivity and identity/data migration | +| btrbk | Caller-selected Syncthing snapshots, hourly timer, retention and restore accepted | Off-machine backup policy and physical cutover acceptance; nested source subvolumes unsupported | +| UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply acceptance | Isolated backup restore, version compatibility, adoption and Site Manager access; see `CLAMPS-UNIFI.md` | +| Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | +| Cloudflare DDNS | Not implemented; separate from implemented UI DNS publication | Follow `CLAMPS-CLOUDFLARE-DDNS.md`; obtain private record/proxy configuration and ensure one active writer at cutover | +| Datadog | Retained by user decision, 2026-10-06; not implemented | Port metrics/process/network monitoring and retained checks; replace nginx-specific check as appropriate. Later local metrics/Grafana is separate work | +| EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | +| Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | +| Users, SSH, sudo, shell/dotfiles | Generic user environment/bootstrap and hardening exist; user acceptance previously passed | Compare additional Chef-managed accounts, permissions, and relevant hardening settings. Dotfiles install hook was explicitly deferred | +| Ubuntu package/Chef updates | Signed uCore images and Podman auto-update replace that maintenance mechanism | Physical OS update/rebase acceptance; no Chef daemon migration | +| Argon One | Omitted intentionally for x86_64 Clamps | None | + +## Recipes outside the default path + +`rupik::network` contains legacy NAT rules and a second Cloudflare update cron, +but `rupik::default` does not include it. Do not assume the cron/rules are absent +on the running host: inspect effective run lists and retained system state before +cutover. Do not port or enable a competing DNS writer merely because its source +exists. Additional private run-list recipes remain an inventory question. + +## Work order + +1. DDNS: the implementation-ready plan is the next service slice after the + applicable refactor validation gate; do not mutate production DNS now. +2. Datadog port, then isolated UniFi backup/restore planning and execution. +3. Close exit-node and remaining host-baseline gaps before cutover. +4. Run the parked VM and deferred network/production acceptance when their + required infrastructure is available; transfer state only with a recovery path. + +Track migration ordering in `CLAMPS-MIGRATION.md`, refactor evidence in +`REFACTOR-FOLLOWUP.md`, and live results in `../butane/ACCEPTANCE.md`. diff --git a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md index 25279b19..6e43667a 100644 --- a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -28,8 +28,8 @@ permission and cross-invocation behavior were verified with a dummy key on 2026-09-28. Credential persistence passed an updated signed image deployment and explicit digest-pinned rebase. Recovery after atomic credential install and full-apply failure passed; a disconnect during streaming or encryption remains -untested. Production TPM assessment and Cloudflare creator migration remain -open. +untested. Production TPM assessment remains open. The Cloudflare creator uses +the implemented KDBX/account-token path; DDNS is a separate planned consumer. ## Split into two small steps diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md index f41a9237..dacf3714 100644 --- a/ublue/plan/CLAMPS-UNIFI.md +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -59,9 +59,11 @@ for the controller's UI account. and device connectivity, and retain rupik plus an independent backup for rollback. Do not run two controllers against the same devices concurrently. -Add routine CI coverage for caller opt-in, Quadlet rendering, data-mount -dependency, and idempotent state. This coverage and the disposable VM runtime -acceptance are pending. Record commands and results in `butane/ACCEPTANCE.md`. +Routine tests already cover host-network Quadlet rendering, numeric data-root +ownership, mount refusal, repeated apply, and preservation of existing files; +the canonical profile selects UniFi for Clamps. Empty-controller runtime +acceptance passed on 2026-10-03. Backup restore and production/device acceptance +remain pending. Record new commands and results in `butane/ACCEPTANCE.md`. Device migration stays unverified until a backup restore or deliberate re-adoption succeeds. diff --git a/ublue/plan/REFACTOR-FOLLOWUP.md b/ublue/plan/REFACTOR-FOLLOWUP.md index 0f805de3..53a722cc 100644 --- a/ublue/plan/REFACTOR-FOLLOWUP.md +++ b/ublue/plan/REFACTOR-FOLLOWUP.md @@ -9,6 +9,12 @@ Podman user-namespace API. Its live application acceptance is parked until Flatpak libvirt access returns; then rerun named-VM apply, repeat-apply, and reboot acceptance. Remote CI evidence remains pending. +Latest ownership-fix checks: formatting, strict workspace Clippy, and the +Syncthing/host-profile/Podman tests passed using the native GNU target. The full +workspace run hit 10 provider-fixture failures because this sandbox forbids +local HTTP listener sockets. Earlier successful workspace runs remain evidence; +the latest restricted run is not a new full-suite pass. + ## Progress | Step | Implementation | Evidence still needed | diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md index 6e6cd7c6..c7bb0282 100644 --- a/ublue/plan/SKILLET-REFACTOR.md +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -1,10 +1,13 @@ # Skillet refactoring prerequisite -Status: reopened, 2026-10-05. The implementation follow-up and scripted -interrupted-reboot recovery are complete. Repeat application acceptance -exposed a Syncthing data ownership divergence after reboot, and remote CI -evidence remains open. Track them in -[the six-point follow-up](REFACTOR-FOLLOWUP.md) before resuming migrations. +Status: implementation complete; validation gate remains open, 2026-10-06. +The six follow-ups and scripted interrupted-reboot recovery are implemented. +The Syncthing identity fix is committed as `43438e3`, CLI diagnostics as +`126004f`. Its live apply/repeat/reboot check is PARKED until Flatpak libvirt +access returns; remote CI evidence remains pending. Track these in +[the six-point follow-up](REFACTOR-FOLLOWUP.md). Migration documentation, +parity auditing, and DDNS planning proceed by user instruction while the check +is parked; this does not constitute new runtime acceptance. Previous routine validation and fresh/retained VM runs remain evidence. The live legacy-manifest import and controlled interrupted-provisioning recovery are recorded in the [acceptance log](../butane/ACCEPTANCE.md#legacy-manifest-import-and-interrupted-provisioning-recovery-2026-10-05). @@ -13,9 +16,9 @@ deferred from this refactor gate. The workstream table below records earlier acceptance, not closure of the follow-up acceptance gate. Lifecycle and fake-contract corrections are now -implemented; workstream 7 still requires investigation of repeat application -ownership after reboot and remote CI. Documentation reconciliation spans all -workstreams. +implemented; workstream 7 still requires live verification of the committed +Syncthing ownership correction and remote CI. No additional refactor slice is +currently planned. Read `../AGENTS.md`, `../skillet/AGENTS.md`, and [the architecture decision](../design/skillet-architecture.md) first. Then read @@ -37,7 +40,7 @@ assignments to execute concurrently against the same interfaces. | 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Delivery, rotation, interrupted retry, provider cleanup, and VM cleanup passed | | 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Live service-network, identity, ownership, rotation, and repeat-apply checks passed | | 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Fixture recovery/reboot and host repeat-apply passed | -| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | CI and fresh/retained VM acceptance passed | +| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Implementation/fixture acceptance passed; Syncthing live retry parked; remote CI pending | Each earlier slice includes its own regression checks; testing does not wait for workstream 7. Workstream 1 creates the minimal VM/transport contracts used From 0c6bed4192c9bf477dae8d8126e10b52f6f8fd86 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 6 Oct 2026 11:46:40 +0200 Subject: [PATCH 204/252] fix(ci): satisfy Rust 1.99 and scoped ShellCheck checks --- .github/workflows/ci.yml | 5 ++- ublue/AGENTS.md | 3 ++ ublue/butane/ACCEPTANCE.md | 22 +++++++++++ ublue/plan/REFACTOR-FOLLOWUP.md | 11 +++--- .../crates/host-profiles/src/hosts_tests.rs | 37 +++++++++++-------- ublue/skillet/crates/vm/src/process/tests.rs | 2 +- .../crates/workstation/src/tailscale/tests.rs | 7 ++-- .../workstation/src/ui_provisioning/tests.rs | 2 +- 8 files changed, 63 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 887c013b..2b0f7d86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,10 @@ jobs: -o -name test-vm -o -name test-vm-ready -o -name virsh -o -name virt-install \ -o -name setup-llama-server -o -name update-llama \) \ -not -path './.git/*' -not -path './.venv/*' -not -path './berks-cookbooks/*' \ - -not -path '*/target/*' -exec shellcheck -x {} + + -not -path '*/target/*' -not -path './ublue/llama/files/update-llama' \ + -exec shellcheck -x {} + + # This desktop helper intentionally ignores a failed best-effort stop. + shellcheck -x --exclude=SC2015 ublue/llama/files/update-llama skillet-checks: runs-on: ubuntu-latest diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index e48f4a81..72d6ea91 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -72,6 +72,9 @@ ## Local validation before commit +- Compare CI and local Rust/Clippy versions when diagnosing lint failures. + Validate fixes with CI's toolchain version; an older local toolchain passing + does not verify lints added in a newer release. - Before committing, run the CI checks that apply to the changed paths locally and resolve failures first. For Skillet changes, run `cargo fmt --all -- --check`, diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 0f6008d6..301fd33d 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,27 @@ # Clamps VM acceptance log +## CI lint recovery, 2026-10-06 + +- [CI run 37440317671](https://github.com/gbagnoli/devmachine/actions/runs/37440317671) + at `c8f5d7e` failed strict Rust 1.99 Clippy's `assert_is_empty` lint and + ShellCheck SC2015 on the desktop llama helper's intentional best-effort stop. + Replaced affected collection assertions with equality assertions that expose + actual values on failure. The workflow excludes only SC2015 for that helper + and still runs its other ShellCheck checks; desktop runtime files are unchanged. +- After the user updated local Rust to 1.99, passed the musl-target commands + `cargo fmt --all -- --check`, + `cargo clippy --workspace --all-targets --offline -- -D warnings`, + `cargo test --workspace --all-targets --offline`, and + `cargo run --bin skillet -- test run beezelbot --phase base --image fedora:latest`. + Used the existing cross compiler on PATH and existing Cargo build cache. + The Fedora container applied successfully twice without repeat service actions + and was removed afterward. The complete workspace suite, including provider + HTTP fixtures, passed outside the sandbox's localhost-listener restrictions. +- The exact updated workflow ShellCheck command and + `bash ublue/butane/tests/bootstrap.sh` passed. Remote CI must rerun after push; + Syncthing live VM acceptance remains parked. No vault/provider/VM contact was + required by these local checks. + ## Migration documentation audit, 2026-10-06 - Reconciled the refactor/migration status against current source and recorded diff --git a/ublue/plan/REFACTOR-FOLLOWUP.md b/ublue/plan/REFACTOR-FOLLOWUP.md index 53a722cc..1045bab6 100644 --- a/ublue/plan/REFACTOR-FOLLOWUP.md +++ b/ublue/plan/REFACTOR-FOLLOWUP.md @@ -9,11 +9,12 @@ Podman user-namespace API. Its live application acceptance is parked until Flatpak libvirt access returns; then rerun named-VM apply, repeat-apply, and reboot acceptance. Remote CI evidence remains pending. -Latest ownership-fix checks: formatting, strict workspace Clippy, and the -Syncthing/host-profile/Podman tests passed using the native GNU target. The full -workspace run hit 10 provider-fixture failures because this sandbox forbids -local HTTP listener sockets. Earlier successful workspace runs remain evidence; -the latest restricted run is not a new full-suite pass. +Latest local checks, 2026-10-06: musl formatting, strict workspace Clippy, +all-target workspace tests, Fedora base/repeat-apply integration, ShellCheck, +and bootstrap regressions passed under Rust 1.99. Checks ran outside the sandbox +restrictions that previously blocked provider HTTP fixtures. GitHub's last run +failed Rust 1.99 collection-assertion lints and an intentional desktop helper's +ShellCheck warning; fixes are verified locally and await a new remote run. ## Progress diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 71e63f2d..aa6b6159 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -16,7 +16,7 @@ fn boot_expectations_are_profile_inputs_and_unknown_profiles_are_refused() { assert_eq!(clamps.signed_image, "ghcr.io/gbagnoli/ucore-clamps"); assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); let other = super::boot_policy_for_host("beezelbot").unwrap(); - assert!(other.masked_units.is_empty()); + assert_eq!(other.masked_units, [] as [&str; 0]); assert!(super::boot_policy_for_host("clamps-test-smoke").is_none()); assert!(super::boot_policy_for_host("unknown").is_none()); } @@ -106,7 +106,7 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s beezelbot.signed_image, Some("ghcr.io/gbagnoli/ucore-beezelbot") ); - assert!(beezelbot.masked_units.is_empty()); + assert_eq!(beezelbot.masked_units, [] as [&str; 0]); assert!(beezelbot.requires_data_mount); assert!(!beezelbot.requires_pihole_dns_listener_policy()); assert_eq!( @@ -127,9 +127,15 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s #[test] fn agent_baseline_declares_no_ui_or_credentials_and_unknown_full_profiles_fail() { let baseline = super::profile_for_name("agent").unwrap(); - assert!(baseline.services.is_empty()); - assert!(baseline.ui_services().is_empty()); - assert!(baseline.credential_consumers().is_empty()); + assert_eq!(baseline.services, Vec::::new()); + assert_eq!( + baseline.ui_services(), + Vec::::new() + ); + assert_eq!( + baseline.credential_consumers(), + Vec::::new() + ); assert!(super::ui_config_for_host("agent").is_none()); let system = MockSystem::new(); let files = LocalFileResource::new(); @@ -147,10 +153,9 @@ fn agent_baseline_declares_no_ui_or_credentials_and_unknown_full_profiles_fail() #[test] fn credential_values_are_selected_by_host_and_apply_phase() { - assert!( - super::credentials_for_phase("clamps", super::HostApplyPhase::Base) - .unwrap() - .is_empty() + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Base).unwrap(), + Vec::<&str>::new() ); assert_eq!( super::credentials_for_phase("clamps", super::HostApplyPhase::Full).unwrap(), @@ -166,10 +171,9 @@ fn credential_values_are_selected_by_host_and_apply_phase() { super::CLOUDFLARE_ACME_TOKEN_CREDENTIAL ] ); - assert!( - super::credentials_for_phase("beezelbot", super::HostApplyPhase::Full) - .unwrap() - .is_empty() + assert_eq!( + super::credentials_for_phase("beezelbot", super::HostApplyPhase::Full).unwrap(), + Vec::<&str>::new() ); assert!(super::credentials_for_phase("unknown", super::HostApplyPhase::Base).is_err()); } @@ -188,7 +192,7 @@ fn host_ui_declarations_include_only_the_services_each_host_runs() { assert_eq!(clamps.network_name, "clamps"); assert_eq!(clamps.services[0].upstream, "pihole"); assert_eq!(clamps.services[0].port, 8088); - assert!(clamps.services[0].aliases.is_empty()); + assert_eq!(clamps.services[0].aliases, Vec::::new()); assert_eq!(clamps.services[1].upstream, "syncthing"); assert_eq!(clamps.services[1].port, 8384); assert_eq!(clamps.services[1].aliases, ["sync.{host}"]); @@ -351,7 +355,10 @@ fn a_credential_service_remains_eligible_when_its_ui_is_not_exposed() { ui: None, }], }; - assert!(profile.ui_services().is_empty()); + assert_eq!( + profile.ui_services(), + Vec::::new() + ); assert_eq!( profile.credential_consumers(), [super::CredentialConsumer { diff --git a/ublue/skillet/crates/vm/src/process/tests.rs b/ublue/skillet/crates/vm/src/process/tests.rs index 4432e21e..027b6a80 100644 --- a/ublue/skillet/crates/vm/src/process/tests.rs +++ b/ublue/skillet/crates/vm/src/process/tests.rs @@ -11,7 +11,7 @@ fn stdin_and_large_output_are_memory_backed_and_do_not_deadlock() { .unwrap(); assert!(output.status.success()); assert_eq!(output.stdout, input); - assert!(output.stderr.is_empty()); + assert_eq!(output.stderr, Vec::::new()); } #[test] diff --git a/ublue/skillet/crates/workstation/src/tailscale/tests.rs b/ublue/skillet/crates/workstation/src/tailscale/tests.rs index 0fe157a0..bd71d00a 100644 --- a/ublue/skillet/crates/workstation/src/tailscale/tests.rs +++ b/ublue/skillet/crates/workstation/src/tailscale/tests.rs @@ -5,9 +5,10 @@ use super::{ #[test] fn guest_status_addresses_require_running_backend_and_filter_invalid_values() { - assert!(status_addresses(br#"{"BackendState":"NeedsLogin"}"#) - .expect("waiting state") - .is_empty()); + assert_eq!( + status_addresses(br#"{"BackendState":"NeedsLogin"}"#).expect("waiting state"), + std::collections::BTreeSet::::new() + ); assert_eq!( status_addresses( br#"{"BackendState":"Running","Self":{"TailscaleIPs":["100.64.0.10","fd7a:115c:a1e0::10","bad"]}}"# diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index 5ec0723c..748730ae 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -496,7 +496,7 @@ fn disposable_cloudflare_journal_keeps_issued_token_after_dns_failure() { assert_eq!(provider.issued.get(), 1); let ownership = read_ownership(run.path()); assert!(ownership.token_id.is_some()); - assert!(ownership.record_ids.is_empty()); + assert_eq!(ownership.record_ids, Vec::::new()); } #[test] From 82465ccf2c38093cb43ceb0ce39343becc955e5c Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 6 Oct 2026 12:15:37 +0200 Subject: [PATCH 205/252] feat(ddns): add optional updater and scoped credential delivery --- ublue/AGENTS.md | 5 + ublue/butane/ACCEPTANCE.md | 26 ++ ublue/butane/includes/skillet.bu | 17 ++ ublue/design/cloudflare-ddns.md | 40 ++- ublue/design/secrets.md | 9 + ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md | 33 ++- ublue/plan/CLAMPS-MIGRATION.md | 4 +- ublue/plan/CLAMPS-PARITY.md | 2 +- ublue/skillet/Cargo.lock | 13 + ublue/skillet/Cargo.toml | 2 + ublue/skillet/README.md | 31 +++ ublue/skillet/crates/cli-common/src/hosts.rs | 2 + ublue/skillet/crates/cli-common/src/lib.rs | 1 + ublue/skillet/crates/cli/src/main.rs | 5 +- .../skillet/crates/cli/src/secret_delivery.rs | 56 +++- .../crates/cli/src/secret_delivery/tests.rs | 37 +++ ublue/skillet/crates/ddns/Cargo.toml | 17 ++ ublue/skillet/crates/ddns/src/config.rs | 203 ++++++++++++++ ublue/skillet/crates/ddns/src/config/tests.rs | 93 +++++++ ublue/skillet/crates/ddns/src/lib.rs | 85 ++++++ ublue/skillet/crates/ddns/src/tests.rs | 68 +++++ ublue/skillet/crates/host-profiles/Cargo.toml | 1 + .../crates/host-profiles/src/hosts_tests.rs | 46 +++- ublue/skillet/crates/host-profiles/src/lib.rs | 38 ++- .../crates/host-profiles/src/profile.rs | 30 ++- ublue/skillet/crates/workstation/Cargo.toml | 1 + .../crates/workstation/src/cloudflare/mod.rs | 2 +- .../workstation/src/ddns_provisioning.rs | 198 ++++++++++++++ .../src/ddns_provisioning/tests.rs | 254 ++++++++++++++++++ ublue/skillet/crates/workstation/src/lib.rs | 2 + .../crates/workstation/src/ui_provisioning.rs | 38 ++- .../crates/workstation/src/zone_token.rs | 38 +++ 32 files changed, 1358 insertions(+), 39 deletions(-) create mode 100644 ublue/skillet/crates/ddns/Cargo.toml create mode 100644 ublue/skillet/crates/ddns/src/config.rs create mode 100644 ublue/skillet/crates/ddns/src/config/tests.rs create mode 100644 ublue/skillet/crates/ddns/src/lib.rs create mode 100644 ublue/skillet/crates/ddns/src/tests.rs create mode 100644 ublue/skillet/crates/workstation/src/ddns_provisioning.rs create mode 100644 ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs create mode 100644 ublue/skillet/crates/workstation/src/zone_token.rs diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 72d6ea91..57edbb27 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -38,6 +38,11 @@ ## Shared infrastructure boundaries +- Optional services require explicit selection in the canonical host caller + declaration. Shared baseline services retain their common defaults; the + presence of environment configuration or credentials must not enable an + optional service implicitly. + - Shared helpers receive identity and configuration from validated inputs or declarations. Keep deployment-specific assumptions in the selected profile; do not bake them into generic readiness, delivery, or lifecycle code. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 301fd33d..6a288084 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,31 @@ # Clamps VM acceptance log +## DDNS local implementation checks, 2026-10-06 + +- Evaluated upstream 2.2.0 (source revision + `7c6d5b43c1d400f4977f2b25bd44d784bb601ae3`) and its multi-platform image index + `sha256:5f2471be9efd9f0c95f973645cc87f05d501020ded94d380a2120fbfdf812d3d`. + Image inspect confirmed `/cloudflare-ddns --repeat`, working directory `/`, + and default root identity. Ran the exact image as `0:0`, `--network=none`, + `--dry-run` with a dummy JSON Podman secret at `/config.json`, mode `0400`. + The binary accepted the config and skipped updates after IP discovery failed, + as expected without networking. The container and dummy secret were removed. + This verifies consumer parsing/mount access, not DNS updates or rootful uCore. +- DDNS and canonical profile tests cover caller selection, phase credentials, + missing storage/input, no published ports, no plaintext config files, repeat + apply, rotation, and failed-activation retry. Fake provider/transport tests + cover persistent token reuse, vault conflicts, saving/revocation failure, + interrupted delivery retry and record takeover/namespace protection. Routine + CI uses no vault, live Cloudflare, IP-discovery service or hypervisor. +- Workspace musl tests, pedantic Clippy and formatting passed with Rust 1.99. + Fedora base integration applied twice without repeat service actions and + removed its container. Bootstrap regression tests passed all eight cases. +- PARKED: rootful DDNS on a named uCore VM, actual public-address records, + reboot persistence and disposal acceptance. Flatpak libvirt remains unavailable + by the user's prior instruction. Disposable DDNS issuance/ownership/cleanup + is not implemented and test-policy delivery is refused. No vault read, live + provider mutation, production activation or old-writer handover was attempted. + ## CI lint recovery, 2026-10-06 - [CI run 37440317671](https://github.com/gbagnoli/devmachine/actions/runs/37440317671) diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index ef17747e..a0159c28 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -40,5 +40,22 @@ systemd: LoadCredentialEncrypted=cloudflare_acme_token:/etc/credstore.encrypted/skillet/cloudflare_acme_token.cred ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase caddy + [Install] + WantedBy=multi-user.target + - name: skillet-ddns-apply.service + enabled: true + contents: | + [Unit] + Description=Apply Skillet optional Cloudflare DDNS configuration + Requires=skillet-apply.service skillet-data-prepare.service + After=skillet-apply.service skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data + ConditionPathExists=/etc/credstore.encrypted/skillet/cloudflare_ddns_config.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=cloudflare_ddns_config:/etc/credstore.encrypted/skillet/cloudflare_ddns_config.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase ddns + [Install] WantedBy=multi-user.target diff --git a/ublue/design/cloudflare-ddns.md b/ublue/design/cloudflare-ddns.md index 90b74d2c..86a22dc0 100644 --- a/ublue/design/cloudflare-ddns.md +++ b/ublue/design/cloudflare-ddns.md @@ -4,9 +4,14 @@ Decision, 2026-10-06: retain Rupik's public-address DDNS capability. Plan to reuse the existing Chef image family in a rootful Quadlet container, with caller opt-in and private configuration supplied through Skillet credentials. This preserves the existing updater instead of introducing another guest -Cloudflare API client. Implementation is pending; verify the chosen image's -config schema and Podman secret-file consumption before committing to its -runtime integration. +Cloudflare API client. Guest convergence and production credential delivery are +implemented; disposable lifecycle and live DNS acceptance remain pending. + +The host caller explicitly opts into the shared DDNS crate/service. Installing +Skillet or configuring an environment zone does not enable DDNS. Reuse +`skillet/environments//dns/cloudflare-zone-id`; no separate DDNS +zone entry is needed. Private record configuration remains in KeePassXC, rather +than embedding production DNS names in the host binary. The DDNS container owns only its explicitly configured public-address records. The existing workstation UI lifecycle owns tailnet machine records and UI @@ -25,6 +30,35 @@ Avoid a plaintext token-bearing config file on persistent host storage. Podman's own disk secret copy remains covered by the planned storage encryption policy. Use existing atomic delivery, deferred activation, and lifecycle ownership. +The selected image is upstream 2.2.0, pinned to its multi-platform index digest. +Its scratch image runs as UID/GID 0 with `/` as its working directory and +`/cloudflare-ddns --repeat` as entrypoint. The legacy JSON loader reads +`/config.json`; its repeat interval is the configured TTL (300 seconds). +Mount the complete Podman secret there as `0400`, UID/GID 0. Do not set +environment-mode token variables, which would bypass JSON configuration. +An offline run of the actual image accepted this secret mount with dummy +credentials and networking disabled. Digest pinning intentionally prevents +registry auto-update from changing this evaluated contract. +Its legacy API path reads one page of 100 A records; provisioning refuses a +zone that would exceed that limit after adding the declared records. + +Private config is version 1, with relative `records` and explicit `proxied` +booleans. `takeover_existing` defaults to false. Production preflight rejects +the whole UI namespace, duplicates, and incompatible records. Existing public +A records need explicit takeover approval; records owned by other Skillet +consumers cannot be adopted. The updater writes a distinct +`skillet-ddns:production:` comment so later delivery recognizes its +records. Approval does not stop an old writer; that remains a cutover action. + +Production delivery reuses or creates a separate persistent child token through +the same atomic token-persistence helper used by Caddy. New tokens are revoked +when vault saving fails; deterministic token names support retry recovery. +The dedicated DDNS apply phase leaves ordinary full apply independent of this +credential. Test-policy delivery is refused until disposable ownership and +cleanup are implemented; ordinary application smoke checks exclude DDNS. + +Source contract: [upstream 2.2.0](https://github.com/timothymiller/cloudflare-ddns/tree/7c6d5b43c1d400f4977f2b25bd44d784bb601ae3). + Disposable tests use explicitly isolated record names and bounded credentials. Journal and clean only test-owned resources; disposal never removes retained production DDNS records. See the [implementation plan](../plan/CLAMPS-CLOUDFLARE-DDNS.md) diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 95e90a57..917a124e 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -61,6 +61,8 @@ policy maps to the `prod` group. | `skillet/environments//dns/ui-domain` | Optional relative UI prefix; defaults to `ui`, appended to the fetched zone domain | | `skillet/environments//dns/cloudflare-zone-id` | Environment-specific Cloudflare Zone ID lookup (`prod` or `test`) | | `skillet/environments//hosts//cloudflare/acme-token` | Persistent `cloudflare_acme_token`, created on first Caddy delivery | +| `skillet/environments//hosts//cloudflare/ddns-config` | Private versioned record configuration, used to compose `cloudflare_ddns_config` | +| `skillet/environments/prod/hosts//cloudflare/ddns-token` | Persistent child token, created on first DDNS delivery; distinct from ACME | | `skillet/hosts//cloudflare/acme-token` | Legacy production credential migrated on first delivery | | `skillet/cloudflare/token-creator` | Workstation only | | `skillet/tailscale/provisioner-client-id` | Workstation only; OAuth Client ID | @@ -110,6 +112,13 @@ production delivery migrates a legacy per-host token into the environment path. Podman's default secret storage persists a copy on the guest disk; systemd's credential encryption does not encrypt that copy. +Optional DDNS uses the same encrypted delivery path and a separate apply unit. +The workstation composes its complete JSON in memory, combining private record +config with a zone-scoped child token. The guest mounts the Podman secret as +`/config.json`, root-only. The master token is never delivered. Production +delivery is implemented; disposable DDNS delivery is intentionally unavailable +until ownership/cleanup is added. See [DDNS design](cloudflare-ddns.md). + ## Disposable credentials The [Cloudflare lifecycle decision](cloudflare-ui-lifecycle.md) is implemented diff --git a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md index f2169572..01b6d64f 100644 --- a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md +++ b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md @@ -1,8 +1,9 @@ # Cloudflare DDNS port -Status: planned, 2026-10-06. Next missing Clamps service from the -[parity audit](CLAMPS-PARITY.md). No DDNS service, vault schema, or delivery command -described below is implemented yet. Refactor implementation is committed; +Status: slices 1–2 and production portion of slice 3 implemented, 2026-10-06. +Shared DDNS crate, caller selection, separate apply phase/unit, validated private +config, token use-or-create and production encrypted delivery exist. Disposable +DDNS provisioning/cleanup and live DNS acceptance remain pending. Refactor implementation is committed; Syncthing live acceptance is parked and remote CI evidence is pending. Planning can proceed; keep unavailable runtime acceptance explicit during implementation. @@ -27,6 +28,12 @@ The legacy `rupik::network` cron is not selected by the default include path. ## 1. Establish the private config and image contract +Implemented: upstream 2.2.0, index digest +`sha256:5f2471be9efd9f0c95f973645cc87f05d501020ded94d380a2120fbfdf812d3d`. +Actual image accepted a Podman secret at `/config.json`, mode `0400`, UID/GID 0, +with dummy credentials and no network. Schema v1 rejects malformed names, +duplicates and unsupported policies. Private live inventory remains pending. + - Inspect official image source/docs for the selected version, JSON schema, config-file access, refresh cadence, runtime identity, and credential support. Pin an evaluated image version/digest in the implementation; do not invent its @@ -50,9 +57,19 @@ work. Private source inventory remains explicit if the host is unavailable. ## 2. Declare the service and compose its Quadlet +Implemented: `ServiceConfig::Ddns` selects `skillet_ddns`; Clamps opts in. +`--phase ddns` and generic `skillet-ddns-apply.service` consume only +`cloudflare_ddns_config`. Full apply skips DDNS. Tests cover opt-in, missing +credentials/storage, root-only secret mount, no ports, no-op, rotation and retry. +Ordinary application acceptance excludes the separately provisioned DNS writer; +`acceptance_plan_with_ddns(true)` supplies its checks for the future live scenario. + - Add a DDNS capability and credential consumer to the canonical host profile; Clamps opts in, other callers may opt in. Keep declarations free of real DNS values. Service/UI/credential eligibility derives from that same declaration. + The host binary explicitly selects the shared DDNS crate/service; having an + environment zone in KeePassXC must never implicitly enable it. Reuse the + existing environment zone entry, with private record names in `ddns-config`. - Add an independently credential-gated DDNS apply phase/unit, following the existing Caddy separation. Base/full convergence must continue to work before DDNS credentials exist; declaring the service must not add its credential to @@ -77,6 +94,16 @@ Routine CI requires no vault, external IP discovery, Cloudflare, or hypervisor. ## 3. Issue and deliver the DDNS credential +Production implemented: `secret deliver ddns` uses the environment zone, +private `ddns-config` JSON and persistent `ddns-token`. Caddy and DDNS share the +atomic use-or-create helper. JSON may include `takeover_existing: true` to approve +adopting an existing public A record; UI namespace and other Skillet ownership +remain protected. New records receive a DDNS-specific ownership comment. +Fake-provider/transport tests cover token reuse, failed delivery retry, explicit +vault token rotation, failed saving/revocation and collisions. Test policy is +refused before unlocking the vault; its bounded issuance/journal belongs to +slice 4. No production DNS writes or guest activation were performed here. + - Reuse the workstation Cloudflare SDK/account-token issuer and atomic KDBX writer. Planned token path: `skillet/environments//hosts//cloudflare/ddns-token`. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 0e9c5539..cac4cc6a 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -6,7 +6,7 @@ Existing acceptance evidence and deferred tasks remain applicable. Status: reconciled against source and acceptance evidence, 2026-10-06. Refactor implementation is committed; Syncthing live validation is parked and -remote CI evidence is pending. Parity auditing and DDNS planning proceed while +remote CI evidence is pending. Parity auditing and DDNS implementation proceed while that check is parked. Skillet convergence and disposable VM bootstrap exist. Runtime evidence and outstanding bootstrap checks are recorded in [ACCEPTANCE.md](../butane/ACCEPTANCE.md). @@ -129,7 +129,7 @@ Each row is a separate implementation and validation step. Adjust order for depe | 3 | UniFi (implemented; empty-controller/repeat-apply acceptance passed) | Follow the [UniFi port plan](CLAMPS-UNIFI.md): rootful host-network container, persistent data, and isolated backup restore/version compatibility before cutover. No Caddy route. | | 4 | Tailscale (implemented; smoke enrollment and cleanup accepted) | Host network, state, OAuth enrollment, and cleanup exist. Chef's exit-node advertisement is missing in the current declaration; add caller-controlled behavior and validate production forwarding. UI names use Cloudflare DNS-only records. | | 5 | Private UI access with Caddy + ACME (implemented; staging accepted) | Generic declarations/aliases, encrypted delivery, scoped account tokens, A/AAAA/CNAME reconciliation and cleanup exist. Staging ACME/HTTPS passed. Production renewal, real-client resolution and separate-network denial remain; see [UI plan](GENERIC-PRIVATE-UIS.md). | -| 6 | Cloudflare DDNS (next service plan) | Follow [DDNS plan](CLAMPS-CLOUDFLARE-DDNS.md); public-address record ownership is separate from UI DNS; no competing production writer | +| 6 | Cloudflare DDNS | Guest service and production credential delivery implemented; disposable lifecycle and live acceptance remain in [DDNS plan](CLAMPS-CLOUDFLARE-DDNS.md); no competing production writer | | 7 | Datadog and remaining host baseline | Keep Datadog for now; port after DDNS. Audit hardening, users, SSH/sudo, ET and WOL targets against the [parity checklist](CLAMPS-PARITY.md) | For each service: inspect effective Chef inputs, settle its open decisions, add only needed Skillet support, migrate a copy of state, validate functionality and repeat convergence. Account for ARM-to-x86 application/image and data compatibility. Avoid activating duplicate production identities or DNS writers during testing. diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 5e41a8ee..dc972696 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -18,7 +18,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | btrbk | Caller-selected Syncthing snapshots, hourly timer, retention and restore accepted | Off-machine backup policy and physical cutover acceptance; nested source subvolumes unsupported | | UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply acceptance | Isolated backup restore, version compatibility, adoption and Site Manager access; see `CLAMPS-UNIFI.md` | | Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | -| Cloudflare DDNS | Not implemented; separate from implemented UI DNS publication | Follow `CLAMPS-CLOUDFLARE-DDNS.md`; obtain private record/proxy configuration and ensure one active writer at cutover | +| Cloudflare DDNS | Caller-selected rootful updater, validated secret config and production child-token delivery implemented; separate from UI DNS publication | Disposable lifecycle, live DNS acceptance, private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | | Datadog | Retained by user decision, 2026-10-06; not implemented | Port metrics/process/network monitoring and retained checks; replace nginx-specific check as appropriate. Later local metrics/Grafana is separate work | | EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | | Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 31649f0c..2241bb57 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2384,6 +2384,17 @@ dependencies = [ "zbus", ] +[[package]] +name = "skillet_ddns" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "skillet_core", + "skillet_podman", + "thiserror 1.0.69", +] + [[package]] name = "skillet_hardening" version = "0.1.0" @@ -2403,6 +2414,7 @@ dependencies = [ "skillet_btrbk", "skillet_caddy", "skillet_core", + "skillet_ddns", "skillet_hardening", "skillet_pihole", "skillet_podman", @@ -2488,6 +2500,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "skillet_caddy", + "skillet_ddns", "skillet_hosts", "skillet_vm", "tempfile", diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 6f6bc53d..930448c3 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/syncthing", "crates/btrbk", "crates/caddy", + "crates/ddns", "crates/podman", "crates/cli", "crates/hosts/beezelbot", @@ -27,6 +28,7 @@ skillet_unifi = { path = "crates/unifi" } skillet_syncthing = { path = "crates/syncthing" } skillet_btrbk = { path = "crates/btrbk" } skillet_caddy = { path = "crates/caddy" } +skillet_ddns = { path = "crates/ddns" } skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } skillet_vm = { path = "crates/vm" } diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index bc2b9050..d57a78ea 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -338,6 +338,37 @@ permissions. Staging ACME and HTTPS acceptance passed on 2026-10-02; production renewal and access checks from outside the tailnet remain deferred. See the [VM lifecycle](../design/smoke-vms.md). +### Optional Cloudflare DDNS + +The selected host profile must declare DDNS. Clamps currently enables it. +In KeePassXC's Password fields, set: + +- `skillet/environments/prod/dns/cloudflare-zone-id`: the existing environment + zone ID. +- `skillet/environments/prod/hosts//cloudflare/ddns-config`: JSON such as + `{"version":1,"records":[{"name":"edge","proxied":false}]}`. Names are relative + to the zone itself. Use the approved record names and proxy state. +- `skillet/cloudflare/token-creator`: the account token described above. + +Skillet creates `skillet/environments/prod/hosts//cloudflare/ddns-token` +automatically. Close KeePassXC before delivery. With the prior DNS writer stopped +and the selected record set approved, run: + +```bash +cargo run --release -p skillet -- secret deliver clamps ddns \ + --target giacomo@clamps --identity /path/to/ssh-key \ + --known-hosts /path/to/known_hosts +``` + +To adopt an existing public A record, add `"takeover_existing":true` to the +config JSON after reviewing it. Conflicting records and other Skillet owners +remain protected. Repeat delivery reuses the child token; edit its vault entry +deliberately and redeliver for rotation. On the host, +`systemctl start skillet-ddns-apply.service` reuses the encrypted credential. +Test-environment DDNS delivery is currently disabled. See the +[DDNS design](../design/cloudflare-ddns.md) and +[remaining lifecycle plan](../plan/CLAMPS-CLOUDFLARE-DDNS.md). + ## Development checks - Workspace checks: `cargo fmt --all -- --check`, diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 560aadbc..fd250e6a 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -15,6 +15,7 @@ pub enum ApplyPhase { Base, Full, Caddy, + Ddns, } pub fn apply_host_phase( @@ -28,6 +29,7 @@ pub fn apply_host_phase( ApplyPhase::Base => skillet_hosts::HostApplyPhase::Base, ApplyPhase::Full => skillet_hosts::HostApplyPhase::Full, ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, + ApplyPhase::Ddns => skillet_hosts::HostApplyPhase::Ddns, }; skillet_hosts::apply_host_phase(hostname, phase, system, files, credentials) } diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 98520340..88f4fc0e 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -155,6 +155,7 @@ where ApplyPhase::Base => skillet_hosts::HostApplyPhase::Base, ApplyPhase::Full => skillet_hosts::HostApplyPhase::Full, ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, + ApplyPhase::Ddns => skillet_hosts::HostApplyPhase::Ddns, }; let required = skillet_hosts::credentials_for_phase(hostname, phase) .map_err(|error| CliCommonError::Config(error.to_string()))?; diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index adcaf708..a0bb238c 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -70,13 +70,13 @@ struct SecretLockArgs { #[derive(clap::Args, Debug)] struct SecretDeliverArgs { hostname: String, - #[arg(value_parser = ["pihole", "tailscale", "caddy"])] + #[arg(value_parser = ["pihole", "tailscale", "caddy", "ddns"])] service: String, #[arg(long)] database: Option, #[arg(long)] key_file: Option, - /// `KeePassXC` environment to use for private UI configuration + /// `KeePassXC` environment to use for service configuration #[arg(long, value_enum, default_value_t = UiEnvironmentName::Production)] environment: UiEnvironmentName, #[arg(long)] @@ -1205,6 +1205,7 @@ fn run_twice_and_check(name: &str, phase: ApplyPhase, inspect: bool) -> Result<( ApplyPhase::Base => "base", ApplyPhase::Full => "full", ApplyPhase::Caddy => "caddy", + ApplyPhase::Ddns => "ddns", }; for round in ["first", "second"] { let status = Command::new("podman") diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index de408641..083dfd68 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -14,6 +14,14 @@ use std::path::Path; pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { validate_delivery_service(&args.hostname, &args.service)?; + if args.service == "ddns" + && args.environment.policy().environment() + != skillet_workstation::provisioning_policy::Environment::Production + { + return Err(anyhow!( + "disposable DDNS is not enabled until record ownership and cleanup are implemented" + )); + } let database = match &args.database { Some(path) => path.clone(), None => default_database_path()?, @@ -51,6 +59,7 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { Ok(()) } "caddy" => deliver_caddy_from_vault(args, &mut vault), + "ddns" => deliver_ddns_from_vault(args, &mut vault), _ => Err(anyhow!("unsupported secret service {}", args.service)), } } @@ -59,17 +68,60 @@ fn validate_delivery_service(hostname: &str, service: &str) -> Result Err(anyhow!( + "pihole" | "tailscale" | "ddns" if !profile.supports_service(service) => Err(anyhow!( "host {hostname} does not declare {service} credential delivery" )), "caddy" if profile.ui_services().is_empty() => { Err(anyhow!("host {hostname} declares no UI services")) } - "pihole" | "tailscale" | "caddy" => Ok(profile), + "pihole" | "tailscale" | "caddy" | "ddns" => Ok(profile), unsupported => Err(anyhow!("unsupported secret service {unsupported}")), } } +fn deliver_ddns_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Result<()> { + let policy = args.environment.policy(); + if policy.environment() != skillet_workstation::provisioning_policy::Environment::Production { + return Err(anyhow!( + "disposable DDNS is not enabled until record ownership and cleanup are implemented" + )); + } + let zone_path = policy.cloudflare_zone_entry(); + let zone_id = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let config_path = format!( + "skillet/environments/{}/hosts/{}/cloudflare/ddns-config", + policy.vault_name(), + args.hostname + ); + let config = vault + .get(&config_path)? + .ok_or_else(|| anyhow!("KeePassXC DDNS config is missing: {config_path}"))?; + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare token creator is missing"))?; + let prefix = vault.get(&policy.ui_domain_entry())?; + let transport = credential_transport(args)?; + let provider = skillet_workstation::cloudflare::Cloudflare::new(); + let mut store = + skillet_workstation::vault::VaultSecretStore::new(vault, args.key_file.as_deref()); + skillet_workstation::ddns_provisioning::deliver_persistent_ddns( + &skillet_workstation::ddns_provisioning::DdnsDelivery { + host: &args.hostname, + policy, + zone_id: zone_id.trim(), + relative_ui_domain: prefix.as_deref(), + config: &config, + creator_token: &creator, + }, + &mut store, + &provider, + &transport, + )?; + Ok(()) +} + fn deliver_caddy_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Result<()> { let policy = args.environment.policy(); let domain_path = policy.ui_domain_entry(); diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 1787630f..222e4d9e 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,4 +1,39 @@ use super::{validate_delivery_service, validate_tailscale_unit_config}; +use clap::Parser; + +#[test] +fn ddns_commands_parse_and_test_delivery_refuses_before_vault_access() { + let parsed = crate::Args::try_parse_from([ + "skillet", + "secret", + "deliver", + "clamps", + "ddns", + "--environment", + "test", + "--database", + "/nonexistent/skillet-test.kdbx", + "--target", + "user@localhost", + "--identity", + "/nonexistent/key", + "--known-hosts", + "/nonexistent/known_hosts", + ]) + .unwrap(); + let crate::Commands::Secret { + command: crate::SecretCommands::Deliver(args), + } = parsed.command + else { + panic!("wrong parsed command"); + }; + let error = super::deliver_from_vault(&args).unwrap_err(); + assert!(error.to_string().contains("disposable DDNS is not enabled")); + assert!(crate::Args::try_parse_from([ + "skillet", "apply", "--host", "clamps", "--phase", "ddns" + ]) + .is_ok()); +} #[test] fn rejects_vm_without_tailscale_systemd_credential() { @@ -15,6 +50,8 @@ fn delivery_eligibility_uses_declared_service_capabilities() { assert!(validate_delivery_service("beezelbot", "pihole").is_err()); assert!(validate_delivery_service("beezelbot", "tailscale").is_err()); assert!(validate_delivery_service("beezelbot", "caddy").is_ok()); + assert!(validate_delivery_service("beezelbot", "ddns").is_err()); + assert!(validate_delivery_service("clamps", "ddns").is_ok()); assert!(validate_delivery_service("missing-host", "caddy").is_err()); assert!(validate_delivery_service("clamps", "unknown").is_err()); } diff --git a/ublue/skillet/crates/ddns/Cargo.toml b/ublue/skillet/crates/ddns/Cargo.toml new file mode 100644 index 00000000..76cad123 --- /dev/null +++ b/ublue/skillet/crates/ddns/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skillet_ddns" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/ddns/src/config.rs b/ublue/skillet/crates/ddns/src/config.rs new file mode 100644 index 00000000..173e0963 --- /dev/null +++ b/ublue/skillet/crates/ddns/src/config.rs @@ -0,0 +1,203 @@ +//! Private input and the pinned updater's JSON contract. +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("invalid DDNS JSON or schema")] + Json, + #[error("unsupported DDNS config version")] + Version, + #[error("DDNS requires at least one valid, unique relative record name")] + Records, + #[error("DDNS record conflicts with a private UI record")] + Overlap, + #[error("invalid DDNS zone or credential")] + Credential, + #[error("unsupported DDNS address, TTL, or purge policy")] + Policy, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Record { + pub name: String, + pub proxied: bool, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateConfig { + pub version: u32, + pub records: Vec, + /// Required explicit approval before adopting an existing public A record. + #[serde(default)] + pub takeover_existing: bool, +} + +impl PrivateConfig { + pub fn parse(json: &str) -> Result { + let config: Self = serde_json::from_str(json).map_err(|_| ConfigError::Json)?; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> Result<(), ConfigError> { + if self.version != 1 { + return Err(ConfigError::Version); + } + let mut names = BTreeSet::new(); + if self.records.is_empty() + || self + .records + .iter() + .any(|record| !valid_dns_name(&record.name) || !names.insert(record.name.as_str())) + { + return Err(ConfigError::Records); + } + Ok(()) + } + + /// Resolve below the zone itself, independently of the private UI prefix. + pub fn validate_zone(&self, zone: &str, ui_records: &[String]) -> Result<(), ConfigError> { + self.validate()?; + if !valid_dns_name(zone) { + return Err(ConfigError::Credential); + } + for record in &self.records { + let name = format!("{}.{zone}", record.name); + if name.len() > 253 { + return Err(ConfigError::Records); + } + if ui_records.iter().any(|ui| ui.eq_ignore_ascii_case(&name)) { + return Err(ConfigError::Overlap); + } + } + Ok(()) + } + + pub fn payload(&self, zone_id: &str, token: &str) -> Result { + self.validate()?; + let payload = Payload { + cloudflare: vec![ZoneConfig { + authentication: Authentication { + api_token: token.to_string(), + }, + zone_id: zone_id.to_string(), + subdomains: self.records.clone(), + }], + a: true, + aaaa: false, + purge_unknown_records: false, + ttl: 300, + record_comment: None, + }; + payload.validate()?; + Ok(payload) + } +} + +// Deliberately no Debug: payloads contain credentials and private DNS names. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Payload { + cloudflare: Vec, + a: bool, + aaaa: bool, + #[serde(rename = "purgeUnknownRecords")] + purge_unknown_records: bool, + ttl: u32, + #[serde( + rename = "recordComment", + default, + skip_serializing_if = "Option::is_none" + )] + record_comment: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ZoneConfig { + authentication: Authentication, + zone_id: String, + subdomains: Vec, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Authentication { + api_token: String, +} + +impl Payload { + /// Distinct from UI ownership markers; the updater preserves this comment. + pub fn with_record_comment(mut self, comment: &str) -> Result { + if !comment.starts_with("skillet-ddns:") + || comment.len() > 200 + || comment.chars().any(char::is_control) + { + return Err(ConfigError::Credential); + } + self.record_comment = Some(comment.to_string()); + Ok(self) + } + pub fn parse(json: &str) -> Result { + let payload: Self = serde_json::from_str(json).map_err(|_| ConfigError::Json)?; + payload.validate()?; + Ok(payload) + } + + pub fn render(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| ConfigError::Json) + } + + fn validate(&self) -> Result<(), ConfigError> { + if self.record_comment.as_ref().is_some_and(|comment| { + !comment.starts_with("skillet-ddns:") + || comment.len() > 200 + || comment.chars().any(char::is_control) + }) { + return Err(ConfigError::Credential); + } + if !self.a || self.aaaa || self.purge_unknown_records || self.ttl != 300 { + return Err(ConfigError::Policy); + } + let [zone] = self.cloudflare.as_slice() else { + return Err(ConfigError::Credential); + }; + if zone.zone_id.len() != 32 + || !zone.zone_id.bytes().all(|byte| byte.is_ascii_hexdigit()) + || zone.authentication.api_token.is_empty() + || zone.authentication.api_token.trim() != zone.authentication.api_token + || zone.authentication.api_token.chars().any(char::is_control) + { + return Err(ConfigError::Credential); + } + PrivateConfig { + version: 1, + records: zone.subdomains.clone(), + takeover_existing: false, + } + .validate() + } +} + +fn valid_dns_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 253 + && name.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) +} + +#[cfg(test)] +#[path = "config/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/ddns/src/config/tests.rs b/ublue/skillet/crates/ddns/src/config/tests.rs new file mode 100644 index 00000000..906d2f9a --- /dev/null +++ b/ublue/skillet/crates/ddns/src/config/tests.rs @@ -0,0 +1,93 @@ +use super::*; + +const ZONE: &str = "0123456789abcdef0123456789abcdef"; + +#[test] +fn renders_updater_schema_and_matches_chef_policy() { + let config = + PrivateConfig::parse(r#"{"version":1,"records":[{"name":"edge","proxied":true}]}"#) + .unwrap(); + config + .validate_zone("example.com", &["syncthing.host.ui.example.com".into()]) + .unwrap(); + let rendered = config + .payload(ZONE, "dummy-token") + .unwrap() + .render() + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(json["a"], true); + assert_eq!(json["aaaa"], false); + assert_eq!(json["ttl"], 300); + assert_eq!(json["purgeUnknownRecords"], false); + assert_eq!(json["cloudflare"][0]["subdomains"][0]["name"], "edge"); + assert_eq!(json["cloudflare"][0]["subdomains"][0]["proxied"], true); + assert_eq!( + Payload::parse(&rendered).unwrap().render().unwrap(), + rendered + ); +} + +#[test] +fn rejects_unsupported_schema_names_and_duplicates() { + for input in [ + r#"{"version":2,"records":[{"name":"edge","proxied":false}]}"#, + r#"{"version":1,"records":[]}"#, + r#"{"version":1,"records":[{"name":"edge"}]}"#, + r#"{"version":1,"records":[{"name":"edge","proxied":false}],"token":"secret"}"#, + r#"{"version":1,"records":[{"name":"edge","proxied":false},{"name":"edge","proxied":true}]}"#, + ] { + assert!(PrivateConfig::parse(input).is_err()); + } + for name in [ + "", + "*", + "@", + "Edge", + "-edge", + "edge-", + "edge..local", + "edge.", + "../../etc", + "edge\n", + ] { + let input = + serde_json::json!({"version":1,"records":[{"name":name,"proxied":false}]}).to_string(); + assert!(PrivateConfig::parse(&input).is_err(), "accepted {name:?}"); + } +} + +#[test] +fn rejects_ui_overlap_and_overlong_resolved_names() { + let config = + PrivateConfig::parse(r#"{"version":1,"records":[{"name":"edge","proxied":false}]}"#) + .unwrap(); + assert!(config + .validate_zone("example.com", &["EDGE.EXAMPLE.COM".into()]) + .is_err()); + let zone = vec!["x".repeat(63); 4].join("."); + assert!(config.validate_zone(&zone, &[]).is_err()); +} + +#[test] +fn errors_do_not_include_secret_values() { + let Err(error) = Payload::parse(r#"{"cloudflare":"sensitive-do-not-print"}"#) else { + panic!("invalid payload accepted"); + }; + assert!(!format!("{error:?} {error}").contains("sensitive-do-not-print")); + let config = + PrivateConfig::parse(r#"{"version":1,"records":[{"name":"edge","proxied":false}]}"#) + .unwrap(); + assert!(config.payload("bad-zone", "dummy-token").is_err()); + let payload = config + .payload(ZONE, "dummy-token") + .unwrap() + .render() + .unwrap(); + let mut json: serde_json::Value = serde_json::from_str(&payload).unwrap(); + json["aaaa"] = true.into(); + assert!(Payload::parse(&json.to_string()).is_err()); + json["aaaa"] = false.into(); + json["purgeUnknownRecords"] = true.into(); + assert!(Payload::parse(&json.to_string()).is_err()); +} diff --git a/ublue/skillet/crates/ddns/src/lib.rs b/ublue/skillet/crates/ddns/src/lib.rs new file mode 100644 index 00000000..a21ed64a --- /dev/null +++ b/ublue/skillet/crates/ddns/src/lib.rs @@ -0,0 +1,85 @@ +//! Caller-selected public-address DDNS using a secret-backed updater config. +pub mod config; + +use skillet_core::{ + files::{FileError, FileMutationResource, FileReadResource, StorageResource}, + system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, + }, +}; +use skillet_podman::{ + MountDependency, NetworkAttachment, PodmanConfig, PodmanError, ProcessIdentity, QuadletSecret, + SecretTarget, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; + +pub const CREDENTIAL: &str = "cloudflare_ddns_config"; +/// Version 2.2.0 multi-platform index, evaluated against the shipped binary. +pub const IMAGE: &str = "docker.io/timothyjmiller/cloudflare-ddns@sha256:5f2471be9efd9f0c95f973645cc87f05d501020ded94d380a2120fbfdf812d3d"; + +#[derive(Debug, Error)] +pub enum DdnsError { + #[error(transparent)] + Config(#[from] config::ConfigError), + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Podman(#[from] PodmanError), +} + +pub fn apply(system: &S, files: &F, input: &str, network: &str) -> Result<(), DdnsError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, +{ + let payload = config::Payload::parse(input)?.render()?; + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + system.ensure_podman_secret(CREDENTIAL, &payload)?; + skillet_podman::container( + system, + files, + PodmanConfig { + name: "cloudflare-ddns".into(), + image: IMAGE.into(), + network_attachments: vec![NetworkAttachment::Bridge(network.into())], + port_publications: Vec::new(), + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::Numeric { uid: 0, gid: 0 }, + namespace_mapping: None, + volumes: Vec::new(), + secrets: vec![QuadletSecret { + secret_name: CREDENTIAL.into(), + target: SecretTarget::File { + target_path: "/config.json".into(), + mode: Some("0400".into()), + uid: Some(0), + gid: Some(0), + }, + }], + config_revisions: vec![payload.into_bytes()], + extra_config: BTreeMap::from([ + ( + "Container".into(), + vec!["ContainerName=cloudflare-ddns".into()], + ), + ( + "Unit".into(), + vec![ + "After=network-online.target".into(), + "Wants=network-online.target".into(), + ], + ), + ("Service".into(), vec!["Restart=always".into()]), + ("Install".into(), vec!["WantedBy=multi-user.target".into()]), + ]), + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/ddns/src/tests.rs b/ublue/skillet/crates/ddns/src/tests.rs new file mode 100644 index 00000000..b42e712e --- /dev/null +++ b/ublue/skillet/crates/ddns/src/tests.rs @@ -0,0 +1,68 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +fn payload(token: &str) -> String { + config::PrivateConfig::parse(r#"{"version":1,"records":[{"name":"edge","proxied":false}]}"#) + .unwrap() + .payload("0123456789abcdef0123456789abcdef", token) + .unwrap() + .render() + .unwrap() +} + +fn mounted_files() -> MockFiles { + let files = MockFiles::new(); + files.record_btrfs_mount(Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount(Path::new("/var/lib/data"), "/dev/test", "/data", "btrfs"); + files +} + +#[test] +fn mounts_secret_as_root_without_publishing_ports_or_plaintext() { + let system = MockSystem::new(); + let files = mounted_files(); + apply(&system, &files, &payload("dummy-token"), "test-net").unwrap(); + let state = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&state["/etc/containers/systemd/cloudflare-ddns.container"]); + assert!(quadlet.contains(IMAGE)); + assert!(quadlet.contains("Network=test-net.network")); + assert!(quadlet.contains("User=0:0")); + assert!( + quadlet.contains("Secret=cloudflare_ddns_config,target=/config.json,mode=0400,uid=0,gid=0") + ); + assert!(quadlet.contains("Restart=always")); + assert!(quadlet.contains("WantedBy=multi-user.target")); + assert!(quadlet.contains("AssertPathIsMountPoint=/var/lib/data")); + assert!(!quadlet.contains("PublishPort=")); + assert!(!quadlet.contains("AutoUpdate=")); + assert!(state + .values() + .all(|bytes| !String::from_utf8_lossy(bytes).contains("dummy-token"))); +} + +#[test] +fn no_op_rotation_and_activation_retry() { + let system = MockSystem::new(); + let files = mounted_files(); + apply(&system, &files, &payload("dummy-one"), "test-net").unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + apply(&system, &files, &payload("dummy-one"), "test-net").unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(apply(&system, &files, &payload("dummy-two"), "test-net").is_err()); + apply(&system, &files, &payload("dummy-two"), "test-net").unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts + 2); +} + +#[test] +fn rejects_missing_storage_and_bad_input_without_mutation() { + for input in [payload("dummy-one"), "invalid".into()] { + let system = MockSystem::new(); + let files = MockFiles::new(); + assert!(apply(&system, &files, &input, "test-net").is_err()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); + assert!(files.files.lock().unwrap().is_empty()); + } +} diff --git a/ublue/skillet/crates/host-profiles/Cargo.toml b/ublue/skillet/crates/host-profiles/Cargo.toml index 853bfe0e..8ea7579f 100644 --- a/ublue/skillet/crates/host-profiles/Cargo.toml +++ b/ublue/skillet/crates/host-profiles/Cargo.toml @@ -14,6 +14,7 @@ skillet_unifi.workspace = true skillet_syncthing.workspace = true skillet_btrbk.workspace = true skillet_caddy.workspace = true +skillet_ddns.workspace = true skillet_podman.workspace = true thiserror.workspace = true diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index aa6b6159..4d7902fd 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -68,7 +68,7 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s .iter() .map(HostService::name) .collect::>(), - ["pihole", "tailscale", "syncthing", "unifi", "btrbk"] + ["pihole", "tailscale", "syncthing", "unifi", "ddns", "btrbk"] ); let credentials = clamps.credential_consumers(); assert!(credentials.iter().any(|use_| { @@ -153,6 +153,10 @@ fn agent_baseline_declares_no_ui_or_credentials_and_unknown_full_profiles_fail() #[test] fn credential_values_are_selected_by_host_and_apply_phase() { + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Ddns).unwrap(), + [super::CLOUDFLARE_DDNS_CONFIG_CREDENTIAL] + ); assert_eq!( super::credentials_for_phase("clamps", super::HostApplyPhase::Base).unwrap(), Vec::<&str>::new() @@ -178,6 +182,46 @@ fn credential_values_are_selected_by_host_and_apply_phase() { assert!(super::credentials_for_phase("unknown", super::HostApplyPhase::Base).is_err()); } +#[test] +fn ddns_is_caller_selected_and_independently_credential_gated() { + let system = MockSystem::new(); + let files = skillet_core::test_utils::MockFiles::new(); + let credentials = CredentialInputs::default(); + assert!(super::apply_host_phase( + "clamps", + super::HostApplyPhase::Ddns, + &system, + &files, + &credentials + ) + .is_err()); + assert!(super::apply_host_phase( + "beezelbot", + super::HostApplyPhase::Ddns, + &system, + &files, + &credentials + ) + .is_err()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); + assert!(files.files.lock().unwrap().is_empty()); + let profile = super::profile_for_name("clamps").unwrap(); + assert!(profile.supports_service("ddns")); + assert!(!profile + .acceptance_plan() + .services + .iter() + .any(|service| service.unit == "cloudflare-ddns.service")); + assert!(profile + .acceptance_plan_with_ddns(true) + .services + .iter() + .any(|service| service.unit == "cloudflare-ddns.service")); + assert!(!super::profile_for_name("beezelbot") + .unwrap() + .supports_service("ddns")); +} + #[test] fn host_ui_declarations_include_only_the_services_each_host_runs() { let clamps = ui_config_for_host("clamps").expect("clamps UI declaration"); diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index b5e702ad..27f69735 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -21,6 +21,7 @@ pub enum HostApplyPhase { Base, Full, Caddy, + Ddns, } pub fn credentials_for_phase( @@ -36,8 +37,11 @@ pub fn credentials_for_phase( .credential_consumers() .into_iter() .filter(|consumer| match phase { - HostApplyPhase::Full => consumer.unit != "caddy.service", + HostApplyPhase::Full => { + !matches!(consumer.unit, "caddy.service" | "cloudflare-ddns.service") + } HostApplyPhase::Caddy => consumer.unit == "caddy.service", + HostApplyPhase::Ddns => consumer.unit == "cloudflare-ddns.service", HostApplyPhase::Base => false, }) .map(|consumer| consumer.credential) @@ -80,6 +84,8 @@ pub enum ApplyError { Btrbk(#[from] skillet_btrbk::BtrbkError), #[error("Caddy apply error: {0}")] Caddy(#[from] skillet_caddy::CaddyError), + #[error("DDNS apply error: {0}")] + Ddns(#[from] skillet_ddns::DdnsError), #[error("Podman error: {0}")] Podman(#[from] skillet_podman::PodmanError), #[error("Fixture input error: {0}")] @@ -104,6 +110,7 @@ pub const TAILSCALE_AUTH_KEY_CREDENTIAL: &str = "tailscale_auth_key"; pub const CADDY_SITES_CREDENTIAL: &str = "caddy_sites"; /// Cloudflare token used only by Caddy's DNS-01 challenge provider. pub const CLOUDFLARE_ACME_TOKEN_CREDENTIAL: &str = "cloudflare_acme_token"; +pub const CLOUDFLARE_DDNS_CONFIG_CREDENTIAL: &str = skillet_ddns::CREDENTIAL; /// UI services declared by a host. This is the canonical input used by Caddy /// both on the workstation (to build delivery payloads) and on the host (to @@ -239,6 +246,8 @@ fn apply_profile( } for service in &profile.services { match &service.config { + // Independently credential-gated optional services have their own phase. + ServiceConfig::Ddns => {} ServiceConfig::Pihole { custom_dns } => { let password = credentials .require(PIHOLE_WEB_PASSWORD_CREDENTIAL)? @@ -333,7 +342,34 @@ pub fn apply_host_phase( HostApplyPhase::Base => apply_base(system, files), HostApplyPhase::Full => apply_host(hostname, system, files, credentials), HostApplyPhase::Caddy => apply_caddy_host(hostname, system, files, credentials), + HostApplyPhase::Ddns => apply_ddns_host(hostname, system, files, credentials), + } +} + +fn apply_ddns_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialInputs, +) -> Result<(), ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; + if !profile.supports_service("ddns") { + return Err(ApplyError::FixtureInput( + "host does not declare DDNS".into(), + )); } + let payload = credentials.require(CLOUDFLARE_DDNS_CONFIG_CREDENTIAL)?; + // Reject malformed secret input before any network or container mutation. + skillet_ddns::config::Payload::parse(payload).map_err(skillet_ddns::DdnsError::from)?; + files.require_btrfs_subvolume_mount( + std::path::Path::new("/var/lib/data"), + std::path::Path::new("/var"), + "/data", + )?; + skillet_podman::ensure_network(system, files, &profile.service_network())?; + skillet_ddns::apply(system, files, payload, profile.id.as_str())?; + Ok(()) } fn apply_caddy_host( diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index a343a498..31e759e0 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -52,6 +52,7 @@ pub enum ServiceConfig { container_gid: u32, }, Unifi, + Ddns, Tailscale { state_path: &'static str, }, @@ -72,6 +73,7 @@ impl HostService { ServiceConfig::Pihole { .. } => "pihole", ServiceConfig::Syncthing { .. } => "syncthing", ServiceConfig::Unifi => "unifi", + ServiceConfig::Ddns => "ddns", ServiceConfig::Tailscale { .. } => "tailscale", ServiceConfig::Btrbk { .. } => "btrbk", } @@ -79,6 +81,10 @@ impl HostService { pub fn credential_consumers(&self) -> Vec { match self.config { + ServiceConfig::Ddns => vec![CredentialConsumer { + credential: super::CLOUDFLARE_DDNS_CONFIG_CREDENTIAL, + unit: "cloudflare-ddns.service", + }], ServiceConfig::Pihole { .. } => vec![CredentialConsumer { credential: super::PIHOLE_WEB_PASSWORD_CREDENTIAL, unit: "pihole.service", @@ -156,9 +162,16 @@ impl HostProfile { /// this profile, so fixture acceptance cannot stand in for application /// acceptance. pub fn acceptance_plan(&self) -> HostAcceptancePlan { + self.acceptance_plan_with_ddns(false) + } + + /// Public-address updates require a separate opt-in acceptance scenario; + /// ordinary application checks must not start an external DNS writer. + pub fn acceptance_plan_with_ddns(&self, with_ddns: bool) -> HostAcceptancePlan { let mut services = self .services .iter() + .filter(|service| with_ddns || !matches!(service.config, ServiceConfig::Ddns)) .map(|service| acceptance_service(self.id.as_str(), service)) .collect::>(); if self.services.iter().any(|service| service.ui.is_some()) { @@ -184,7 +197,9 @@ impl HostProfile { self.services.iter().any(|service| { matches!( service.config, - ServiceConfig::Pihole { .. } | ServiceConfig::Syncthing { .. } + ServiceConfig::Pihole { .. } + | ServiceConfig::Syncthing { .. } + | ServiceConfig::Ddns ) || service.ui.is_some() }) } @@ -272,6 +287,15 @@ impl HostProfile { fn acceptance_service(profile: &str, service: &HostService) -> AcceptanceService { match service.config { + ServiceConfig::Ddns => AcceptanceService { + unit: "cloudflare-ddns.service".into(), + container: Some("cloudflare-ddns".into()), + network_mode: Some(profile.into()), + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + }, ServiceConfig::Pihole { .. } => AcceptanceService { unit: "pihole.service".into(), container: Some("pihole".into()), @@ -427,6 +451,10 @@ fn clamps() -> HostProfile { config: ServiceConfig::Unifi, ui: None, }, + HostService { + config: ServiceConfig::Ddns, + ui: None, + }, HostService { config: ServiceConfig::Btrbk { snapshot_subvolumes: &["syncthing"], diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml index dc280546..2bc6903e 100644 --- a/ublue/skillet/crates/workstation/Cargo.toml +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -17,6 +17,7 @@ serde.workspace = true serde_json.workspace = true sha2.workspace = true skillet_caddy.workspace = true +skillet_ddns.workspace = true skillet_hosts.workspace = true skillet_vm.workspace = true tempfile.workspace = true diff --git a/ublue/skillet/crates/workstation/src/cloudflare/mod.rs b/ublue/skillet/crates/workstation/src/cloudflare/mod.rs index 6554ad97..ce87239f 100644 --- a/ublue/skillet/crates/workstation/src/cloudflare/mod.rs +++ b/ublue/skillet/crates/workstation/src/cloudflare/mod.rs @@ -352,7 +352,7 @@ impl Cloudflare { Ok(()) } - fn list_records(&self, token: &str, zone_id: &str) -> Result> { + pub fn list_records(&self, token: &str, zone_id: &str) -> Result> { validate_zone_id(zone_id)?; let mut records = Vec::new(); for page in 1..=100_u32 { diff --git a/ublue/skillet/crates/workstation/src/ddns_provisioning.rs b/ublue/skillet/crates/workstation/src/ddns_provisioning.rs new file mode 100644 index 00000000..29b8f58d --- /dev/null +++ b/ublue/skillet/crates/workstation/src/ddns_provisioning.rs @@ -0,0 +1,198 @@ +//! Persistent DDNS credential issuance and encrypted guest delivery. +use crate::{ + cloudflare::{Cloudflare, CloudflareError, IssuedToken, RecordRef, Zone}, + provisioning_policy::{Environment, ProvisioningPolicy}, + vault::{SecretStore, VaultError}, +}; +use skillet_ddns::config::{ConfigError, PrivateConfig}; +use skillet_vm::{credential, transport::GuestTransport}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DdnsProvisioningError { + #[error("invalid DDNS provisioning: {0}")] + Invalid(&'static str), + #[error(transparent)] + Config(#[from] ConfigError), + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Cloudflare(#[from] CloudflareError), + #[error(transparent)] + Token(#[from] crate::zone_token::TokenError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), + #[error(transparent)] + Caddy(#[from] skillet_caddy::CaddyError), +} + +/// Provider operations needed for persistent public-address updates. +/// DNS is observed here; the guest updater is the sole continuing writer. +pub trait DdnsProvider { + fn zone(&self, token: &str, zone: &str) -> Result; + fn records(&self, token: &str, zone: &str) -> Result, CloudflareError>; + fn issue( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + ) -> Result; + fn revoke(&self, creator: &str, account: &str, id: &str) -> Result<(), CloudflareError>; +} + +impl DdnsProvider for Cloudflare { + fn zone(&self, token: &str, zone: &str) -> Result { + Self::zone(self, token, zone) + } + fn records(&self, token: &str, zone: &str) -> Result, CloudflareError> { + self.list_records(token, zone) + } + fn issue( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + ) -> Result { + self.replace_named_zone_token(creator, zone, account, name, None) + } + fn revoke(&self, creator: &str, account: &str, id: &str) -> Result<(), CloudflareError> { + self.revoke_token(creator, account, id) + } +} + +pub struct DdnsDelivery<'a> { + pub host: &'a str, + pub policy: ProvisioningPolicy, + pub zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub config: &'a str, + pub creator_token: &'a str, +} + +pub fn deliver_persistent_ddns( + request: &DdnsDelivery<'_>, + store: &mut impl SecretStore, + provider: &impl DdnsProvider, + guest: &impl GuestTransport, +) -> Result<(), DdnsProvisioningError> { + if request.policy.environment() != Environment::Production { + return Err(DdnsProvisioningError::Invalid( + "disposable DDNS requires its own ownership and cleanup scenario; not enabled yet", + )); + } + skillet_hosts::profile_for_name(request.host) + .filter(|profile| profile.supports_service("ddns")) + .ok_or(DdnsProvisioningError::Invalid("host does not declare DDNS"))?; + let config = PrivateConfig::parse(request.config)?; + crate::cloudflare::validate_zone_id(request.zone_id)?; + // Validate credentials before issuance as well as at the receiving host. + config.payload(request.zone_id, "validation-placeholder")?; + let zone = provider.zone(request.creator_token, request.zone_id)?; + if zone.id != request.zone_id { + return Err(DdnsProvisioningError::Invalid( + "provider zone identity mismatch", + )); + } + let ui_domain = skillet_caddy::resolve_ui_domain(&zone.name, request.relative_ui_domain)?; + config.validate_zone(&zone.name, &[])?; + // Reserve the complete UI namespace, including aliases and other hosts. + // Its naming rules remain owned by Caddy, rather than copied here. + if config.records.iter().any(|record| { + let name = format!("{}.{name}", record.name, name = zone.name); + name == ui_domain || name.ends_with(&format!(".{ui_domain}")) + }) { + return Err(ConfigError::Overlap.into()); + } + let account = Cloudflare::account_id(&zone)?; + let token_path = format!( + "skillet/environments/{}/hosts/{}/cloudflare/ddns-token", + request.policy.vault_name(), + request.host + ); + let token_name = format!("skillet:{}:{}:ddns", request.policy.name(), request.host); + let token = crate::zone_token::use_or_create( + store, + &token_path, + || provider.issue(request.creator_token, request.zone_id, account, &token_name), + |id| provider.revoke(request.creator_token, account, id), + )?; + let child_zone = provider.zone(&token, request.zone_id)?; + if child_zone.id != zone.id || child_zone.name != zone.name { + return Err(DdnsProvisioningError::Invalid( + "child token zone identity mismatch", + )); + } + let existing = provider.records(&token, request.zone_id)?; + let marker = format!("skillet-ddns:{}:{}", request.policy.name(), request.host); + validate_takeover(&config, &zone.name, &marker, &existing)?; + store.ensure_unchanged()?; + let payload = config + .payload(request.zone_id, &token)? + .with_record_comment(&marker)? + .render()?; + credential::install( + guest, + request.host, + skillet_ddns::CREDENTIAL, + "skillet-ddns-apply.service", + credential::ActivationPolicy::StartConsumer, + payload.as_bytes(), + )?; + Ok(()) +} + +fn validate_takeover( + config: &PrivateConfig, + zone: &str, + marker: &str, + existing: &[RecordRef], +) -> Result<(), DdnsProvisioningError> { + // The pinned legacy updater fetches one page of at most 100 A records. + // Refuse a zone it cannot fully observe instead of risking duplicates. + let existing_a = existing + .iter() + .filter(|record| record.record_type == "A") + .count(); + let new_a = config + .records + .iter() + .filter(|record| { + let name = format!("{}.{zone}", record.name); + !existing + .iter() + .any(|entry| entry.record_type == "A" && entry.name.eq_ignore_ascii_case(&name)) + }) + .count(); + if existing_a + new_a > 100 { + return Err(DdnsProvisioningError::Invalid( + "selected updater supports at most 100 zone A records", + )); + } + for record in &config.records { + let name = format!("{}.{zone}", record.name); + let matches = existing + .iter() + .filter(|entry| entry.name.eq_ignore_ascii_case(&name)) + .collect::>(); + // Require explicit adoption on first activation and every retry. A UI + // ownership marker or conflicting record type must never be adopted. + if !matches.is_empty() + && ((!config.takeover_existing && matches[0].comment.as_deref() != Some(marker)) + || matches.len() != 1 + || matches[0].record_type != "A" + || matches[0].comment.as_deref().is_some_and(|comment| { + comment.starts_with("skillet:") + || (comment.starts_with("skillet-ddns:") && comment != marker) + })) + { + return Err(DdnsProvisioningError::Invalid("record collision: explicit public A-record takeover required; UI-owned and conflicting records cannot be adopted")); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "ddns_provisioning/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs new file mode 100644 index 00000000..10e82fa4 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs @@ -0,0 +1,254 @@ +use super::*; +use crate::cloudflare::ZoneAccount; +use skillet_vm::transport::GuestCommand; +use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + os::unix::process::ExitStatusExt, + path::Path, + process::{ExitStatus, Output}, +}; + +const ZONE: &str = "0123456789abcdef0123456789abcdef"; +const CONFIG: &str = r#"{"version":1,"records":[{"name":"edge","proxied":false}]}"#; +const MARKER: &str = "skillet-ddns:production:clamps"; + +#[derive(Default)] +struct Store { + entries: BTreeMap, + fail_save: bool, + changed: bool, +} +impl SecretStore for Store { + fn get(&self, path: &str) -> Result, VaultError> { + Ok(self.entries.get(path).cloned()) + } + fn ensure_unchanged(&self) -> Result<(), VaultError> { + if self.changed { + Err(VaultError::Invalid("vault changed".into())) + } else { + Ok(()) + } + } + fn save_verified(&mut self, path: &str, value: &str) -> Result<(), VaultError> { + if self.fail_save { + return Err(VaultError::Invalid("save failed".into())); + } + self.entries.insert(path.into(), value.into()); + Ok(()) + } +} + +#[derive(Default)] +struct Provider { + issued: Cell, + revoked: Cell, + records: Vec, + fail_revoke: bool, +} +impl DdnsProvider for Provider { + fn zone(&self, _token: &str, zone: &str) -> Result { + Ok(Zone { + id: zone.into(), + name: "example.com".into(), + account: Some(ZoneAccount { id: ZONE.into() }), + }) + } + fn records(&self, token: &str, zone: &str) -> Result, CloudflareError> { + assert_eq!(zone, ZONE); + assert!(matches!(token, "dummy-child-token" | "dummy-rotated-token")); + Ok(self.records.clone()) + } + fn issue( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + ) -> Result { + assert_eq!(creator, "dummy-master-token"); + assert_eq!(zone, ZONE); + assert_eq!(account, ZONE); + assert_eq!(name, "skillet:production:clamps:ddns"); + self.issued.set(self.issued.get() + 1); + Ok(IssuedToken { + id: "dummy-id".into(), + value: "dummy-child-token".into(), + expires_on: None, + }) + } + fn revoke(&self, _creator: &str, _account: &str, id: &str) -> Result<(), CloudflareError> { + assert_eq!(id, "dummy-id"); + self.revoked.set(self.revoked.get() + 1); + if self.fail_revoke { + Err(CloudflareError::Invalid("revoke failed".into())) + } else { + Ok(()) + } + } +} + +#[derive(Default)] +struct Guest { + payloads: RefCell>>, + fail: Cell, +} +impl GuestTransport for Guest { + fn execute( + &self, + command: &GuestCommand<'_>, + payload: Option<&[u8]>, + ) -> skillet_vm::Result { + assert_eq!(command.program, "/usr/bin/sudo"); + assert!(command.arguments.contains(&"cloudflare_ddns_config")); + assert!(command.arguments.contains(&"skillet-ddns-apply.service")); + assert!(command + .arguments + .iter() + .all(|arg| !arg.contains("dummy-child-token") && !arg.contains("dummy-master-token"))); + let payload = payload.unwrap(); + assert!(!String::from_utf8_lossy(payload).contains("dummy-master-token")); + skillet_ddns::config::Payload::parse(std::str::from_utf8(payload).unwrap()).unwrap(); + self.payloads.borrow_mut().push(payload.to_vec()); + Ok(Output { + status: ExitStatus::from_raw(if self.fail.get() { 256 } else { 0 }), + stdout: Vec::new(), + stderr: Vec::new(), + }) + } + fn upload(&self, _source: &Path, _destination: &str) -> skillet_vm::Result<()> { + panic!("delivery must use stdin") + } +} +fn request(config: &str) -> DdnsDelivery<'_> { + DdnsDelivery { + host: "clamps", + policy: ProvisioningPolicy::new(Environment::Production), + zone_id: ZONE, + relative_ui_domain: None, + config, + creator_token: "dummy-master-token", + } +} + +#[test] +fn creates_once_and_reuses_saved_token_after_delivery_failure() { + let mut store = Store::default(); + let provider = Provider::default(); + let guest = Guest::default(); + guest.fail.set(true); + assert!(deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).is_err()); + guest.fail.set(false); + deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).unwrap(); + assert_eq!(provider.issued.get(), 1); + assert_eq!( + store.entries["skillet/environments/prod/hosts/clamps/cloudflare/ddns-token"], + "dummy-child-token" + ); + assert_eq!(guest.payloads.borrow()[0], guest.payloads.borrow()[1]); + store.entries.insert( + "skillet/environments/prod/hosts/clamps/cloudflare/ddns-token".into(), + "dummy-rotated-token".into(), + ); + deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).unwrap(); + assert_eq!(provider.issued.get(), 1); + assert!(String::from_utf8_lossy(&guest.payloads.borrow()[2]).contains("dummy-rotated-token")); +} + +#[test] +fn invalid_config_ui_namespace_and_test_policy_never_issue_or_deliver() { + let mut store = Store::default(); + let provider = Provider::default(); + let guest = Guest::default(); + for config in [ + "invalid", + r#"{"version":1,"records":[{"name":"edge.ui","proxied":false}]}"#, + ] { + assert!(deliver_persistent_ddns(&request(config), &mut store, &provider, &guest).is_err()); + } + let mut request = request(CONFIG); + request.policy = ProvisioningPolicy::new(Environment::Test); + assert!(deliver_persistent_ddns(&request, &mut store, &provider, &guest).is_err()); + request.policy = ProvisioningPolicy::new(Environment::Production); + request.host = "beezelbot"; + assert!(deliver_persistent_ddns(&request, &mut store, &provider, &guest).is_err()); + assert_eq!(provider.issued.get(), 0); + assert!(guest.payloads.borrow().is_empty()); +} + +#[test] +fn save_failure_revokes_and_vault_conflict_prevents_issuance() { + let provider = Provider::default(); + let guest = Guest::default(); + let mut store = Store { + fail_save: true, + ..Store::default() + }; + assert!(deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).is_err()); + assert_eq!(provider.revoked.get(), 1); + assert!(guest.payloads.borrow().is_empty()); + store.changed = true; + assert!(deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).is_err()); + assert_eq!(provider.issued.get(), 1); +} + +#[test] +fn revocation_failure_returns_safe_recovery_error() { + let provider = Provider { + fail_revoke: true, + ..Provider::default() + }; + let mut store = Store { + fail_save: true, + ..Store::default() + }; + let error = deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &Guest::default()) + .unwrap_err(); + assert!(error.to_string().contains("named-token recovery")); + assert!(!format!("{error:?} {error}").contains("dummy-child-token")); +} + +fn existing(kind: &str, comment: Option<&str>) -> RecordRef { + RecordRef { + id: "record-id".into(), + name: "edge.example.com".into(), + record_type: kind.into(), + content: "192.0.2.1".into(), + comment: comment.map(str::to_string), + } +} +#[test] +fn only_explicit_single_public_a_record_takeover_is_allowed() { + let mut config = PrivateConfig::parse(CONFIG).unwrap(); + assert!(validate_takeover(&config, "example.com", MARKER, &[existing("A", None)]).is_err()); + assert!(validate_takeover( + &config, + "example.com", + MARKER, + &[existing("A", Some(MARKER))] + ) + .is_ok()); + config.takeover_existing = true; + assert!(validate_takeover(&config, "example.com", MARKER, &[existing("A", None)]).is_ok()); + for records in [ + vec![existing("CNAME", None)], + vec![existing("A", Some("skillet:production:clamps"))], + vec![existing("A", Some("skillet-ddns:production:other"))], + vec![existing("A", None), existing("A", None)], + ] { + assert!(validate_takeover(&config, "example.com", MARKER, &records).is_err()); + } +} + +#[test] +fn rejects_zones_that_exceed_the_updaters_record_page() { + let config = PrivateConfig::parse(CONFIG).unwrap(); + let records = (0..100) + .map(|index| RecordRef { + name: format!("other-{index}.example.com"), + ..existing("A", None) + }) + .collect::>(); + assert!(validate_takeover(&config, "example.com", MARKER, &records).is_err()); + assert!(validate_takeover(&config, "example.com", MARKER, &records[..99]).is_ok()); +} diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index 8fce4561..47defe67 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -2,9 +2,11 @@ pub mod cloudflare; pub mod credential_delivery; +pub mod ddns_provisioning; pub mod provisioning_policy; pub mod provisioning_state; pub mod tailscale; pub mod tailscale_enrollment; pub mod ui_provisioning; pub mod vault; +pub mod zone_token; diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs index b7f376de..fb8b3b11 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -24,6 +24,8 @@ pub enum UiProvisioningError { State(#[from] provisioning_state::ProvisioningStateError), #[error(transparent)] Vault(#[from] VaultError), + #[error("Cloudflare token persistence failed: {0}")] + Token(#[from] crate::zone_token::TokenError), #[error(transparent)] Tailscale(#[from] TailscaleError), #[error(transparent)] @@ -421,28 +423,20 @@ pub fn deliver_persistent_ui( token_store.save_verified(&token_path, &token)?; token } else { - token_store.ensure_unchanged()?; - let issued = cloudflare.replace_named_zone_token( - request.creator_token, - request.zone_id, - &account_id, - &token_name, - request.policy.cloudflare_token_lifetime(), - )?; - if let Err(save_error) = token_store.save_verified(&token_path, &issued.value) { - return match cloudflare.revoke_token( - request.creator_token, - &account_id, - &issued.id, - ) { - Ok(()) => Err(UiProvisioningError::Vault(save_error)), - Err(revoke_error) => Err(UiProvisioningError::Invalid(format!( - "saving issued token failed ({save_error}); revoking token {} also failed ({revoke_error})", - issued.id - ))), - }; - } - issued.value + crate::zone_token::use_or_create( + token_store, + &token_path, + || { + cloudflare.replace_named_zone_token( + request.creator_token, + request.zone_id, + &account_id, + &token_name, + request.policy.cloudflare_token_lifetime(), + ) + }, + |id| cloudflare.revoke_token(request.creator_token, &account_id, id), + )? } }; cloudflare.zone(&token, request.zone_id)?; diff --git a/ublue/skillet/crates/workstation/src/zone_token.rs b/ublue/skillet/crates/workstation/src/zone_token.rs new file mode 100644 index 00000000..9cff3f27 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/zone_token.rs @@ -0,0 +1,38 @@ +//! Shared atomic persistence of child tokens used by distinct consumers. +use crate::{ + cloudflare::{CloudflareError, IssuedToken}, + vault::{SecretStore, VaultError}, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TokenError { + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Cloudflare(#[from] CloudflareError), + #[error("saving the issued token and revoking it both failed; retry named-token recovery")] + Recovery, +} + +/// Issuance recovers an orphan by its deterministic consumer-specific name. +/// If vault persistence fails, revoke the new token before returning. +pub fn use_or_create( + store: &mut impl SecretStore, + path: &str, + issue: impl FnOnce() -> Result, + revoke: impl FnOnce(&str) -> Result<(), CloudflareError>, +) -> Result { + store.ensure_unchanged()?; + if let Some(token) = store.get(path)? { + return Ok(token); + } + let issued = issue()?; + if let Err(error) = store.save_verified(path, &issued.value) { + return match revoke(&issued.id) { + Ok(()) => Err(error.into()), + Err(_) => Err(TokenError::Recovery), + }; + } + Ok(issued.value) +} From c378ce662b54c4b3e922edce71defcb9091df8fd Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 6 Oct 2026 13:59:09 +0200 Subject: [PATCH 206/252] feat(ddns): add disposable ownership lifecycle --- ublue/skillet/crates/cli/src/main.rs | 3 + .../skillet/crates/cli/src/secret_delivery.rs | 85 +++- .../crates/workstation/src/cloudflare/mod.rs | 73 ++++ .../workstation/src/cloudflare/tests.rs | 30 ++ .../workstation/src/ddns_provisioning.rs | 412 +++++++++++++++++- .../src/ddns_provisioning/tests.rs | 153 ++++++- .../workstation/src/provisioning_state.rs | 36 ++ .../src/provisioning_state/tests.rs | 27 ++ 8 files changed, 815 insertions(+), 4 deletions(-) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index a0bb238c..11b416c9 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -184,6 +184,9 @@ struct VmProvisionArgs { /// Also provision private UI DNS, Caddy, and disposable Cloudflare credentials #[arg(long)] with_ui: bool, + /// Provision public DDNS records from test-environment `KeePassXC` config + #[arg(long)] + with_ddns: bool, #[arg(long)] database: Option, #[arg(long)] diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 083dfd68..70aef568 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -233,6 +233,9 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { args.hostname )); } + if args.with_ddns && !profile.supports_service("ddns") { + return Err(anyhow!("host {} does not declare DDNS", args.hostname)); + } let butane = butane_root()?; let run_identity = RunIdentity::new(&args.hostname, &args.instance)?; let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; @@ -279,6 +282,9 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { if args.with_ui { provision_vm_ui(args, &run_dir, &transport, &mut vault, &record)?; } + if args.with_ddns { + provision_vm_ddns(args, &run_dir, &transport, &mut vault)?; + } provisioning_state::remove_tailscale_pending(&run_dir)?; println!("Tailscale connected VM {expected_hostname}"); Ok(()) @@ -293,15 +299,20 @@ pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs, run: &VmRun) -> let butane = butane_root()?; let run_dir = butane.join("runs").join(run.identity.domain_name()); let cloudflare_path = run_dir.join("cloudflare.json"); + let ddns_path = run_dir.join("ddns.json"); let cloudflare_exists = provisioning_state::cloudflare_ownership_exists(&cloudflare_path)?; + let ddns_exists = provisioning_state::ddns_ownership_exists(&ddns_path)?; let tailscale_pending = provisioning_state::tailscale_pending_exists(&run_dir)?; let tailscale_record = provisioning_state::tailscale_record_exists(&run_dir)?; - if !tailscale_pending && !tailscale_record && !cloudflare_exists { + if !tailscale_pending && !tailscale_record && !cloudflare_exists && !ddns_exists { return Ok(()); } if cloudflare_exists { cleanup_vm_cloudflare(args, &cloudflare_path)?; } + if ddns_exists { + cleanup_vm_ddns(args, &ddns_path)?; + } if !provisioning_state::tailscale_pending_exists(&run_dir)? && !provisioning_state::tailscale_record_exists(&run_dir)? { @@ -380,6 +391,78 @@ fn provision_vm_ui( Ok(()) } +fn provision_vm_ddns( + args: &VmProvisionArgs, + run_dir: &Path, + guest: &impl GuestTransport, + vault: &mut Vault, +) -> Result<()> { + let policy = UiEnvironmentName::Test.policy(); + let zone_path = policy.cloudflare_zone_entry(); + let zone_id = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let config_path = format!( + "skillet/environments/{}/hosts/{}/cloudflare/ddns-config", + policy.vault_name(), + args.hostname + ); + let config = vault + .get(&config_path)? + .ok_or_else(|| anyhow!("KeePassXC DDNS config is missing: {config_path}"))?; + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare token creator is missing"))?; + let prefix = vault.get(&policy.ui_domain_entry())?; + let api = skillet_workstation::cloudflare::Cloudflare::new(); + skillet_workstation::ddns_provisioning::provision_disposable_ddns( + &skillet_workstation::ddns_provisioning::DisposableDdnsRequest { + host: &args.hostname, + instance: &args.instance, + policy, + zone_id: zone_id.trim(), + relative_ui_domain: prefix.as_deref(), + config: &config, + creator_token: &creator, + run_directory: run_dir, + }, + &api, + guest, + std::time::Duration::from_mins(5), + std::time::Duration::from_secs(5), + )?; + Ok(()) +} + +fn cleanup_vm_ddns(args: &VmDestroyArgs, metadata_path: &Path) -> Result<()> { + let policy = UiEnvironmentName::Test.policy(); + let vault_path = args + .database + .clone() + .map_or_else(default_database_path, Ok)?; + let vault = Vault::open(&vault_path, args.key_file.as_deref())?; + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare token creator is missing"))?; + let zone_path = policy.cloudflare_zone_entry(); + let zone_id = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let api = skillet_workstation::cloudflare::Cloudflare::new(); + skillet_workstation::ddns_provisioning::cleanup_disposable_ddns( + &skillet_workstation::ddns_provisioning::DisposableDdnsCleanup { + host: &args.hostname, + instance: &args.instance, + policy, + ownership_path: metadata_path, + configured_zone_id: zone_id.trim(), + creator_token: &creator, + }, + &api, + )?; + Ok(()) +} + fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<()> { let policy = UiEnvironmentName::Test.policy(); let vault_path = args diff --git a/ublue/skillet/crates/workstation/src/cloudflare/mod.rs b/ublue/skillet/crates/workstation/src/cloudflare/mod.rs index ce87239f..e47197d5 100644 --- a/ublue/skillet/crates/workstation/src/cloudflare/mod.rs +++ b/ublue/skillet/crates/workstation/src/cloudflare/mod.rs @@ -379,6 +379,79 @@ impl Cloudflare { Ok(records) } + /// Return only records carrying this DDNS instance marker at its exact + /// pre-journaled names. Any marker outside that set is an ownership error. + pub fn list_owned_ddns_records( + &self, + token: &str, + zone_id: &str, + marker: &str, + names: &[String], + ) -> Result> { + validate_zone_id(zone_id)?; + if marker.is_empty() || names.is_empty() { + return Err(CloudflareError::Invalid( + "invalid DDNS ownership scope".into(), + )); + } + let names = names + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect::>(); + let mut owned = Vec::new(); + for record in self.list_records(token, zone_id)? { + if record.comment.as_deref() != Some(marker) { + continue; + } + if record.record_type != "A" || !names.contains(&record.name.to_ascii_lowercase()) { + return Err(CloudflareError::Invalid( + "DDNS ownership marker appears outside the recorded A-record names".into(), + )); + } + owned.push(record); + } + Ok(owned) + } + + /// Remove only exact test-owned DDNS records after checking both the + /// caller's allowlist and the immutable record IDs captured by the journal. + pub fn remove_owned_ddns_records( + &self, + token: &str, + zone_id: &str, + marker: &str, + names: &[String], + recorded_ids: &[String], + ) -> Result<()> { + let owned = self.list_owned_ddns_records(token, zone_id, marker, names)?; + let current = self.list_records(token, zone_id)?; + for record in &owned { + if !recorded_ids.is_empty() && !recorded_ids.contains(&record.id) { + return Err(CloudflareError::Invalid( + "test DDNS record identity changed; refusing cleanup".into(), + )); + } + } + for id in recorded_ids { + if let Some(record) = current.iter().find(|record| &record.id == id) { + if record.comment.as_deref() != Some(marker) + || record.record_type != "A" + || !names + .iter() + .any(|name| name.eq_ignore_ascii_case(&record.name)) + { + return Err(CloudflareError::Invalid( + "journaled DDNS record no longer matches its recorded owner".into(), + )); + } + } + } + for record in owned { + self.delete_record(token, zone_id, &record.id)?; + } + Ok(()) + } + fn create_record( &self, token: &str, diff --git a/ublue/skillet/crates/workstation/src/cloudflare/tests.rs b/ublue/skillet/crates/workstation/src/cloudflare/tests.rs index 2c4b457f..34a09bb9 100644 --- a/ublue/skillet/crates/workstation/src/cloudflare/tests.rs +++ b/ublue/skillet/crates/workstation/src/cloudflare/tests.rs @@ -417,6 +417,36 @@ fn dns_cleanup_retry_finishes_after_a_partial_delete_failure() { server.join().expect("server thread"); } +#[test] +fn ddns_cleanup_refuses_an_unjournaled_owned_record_before_deleting() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let records = json!([ + {"id":"11111111111111111111111111111111", "name":"host.example.test", "type":"A", "content":"203.0.113.10", "comment":"skillet-ddns:test:clamps-ddns"}, + {"id":"22222222222222222222222222222222", "name":"host.example.test", "type":"A", "content":"203.0.113.11", "comment":"skillet-ddns:test:clamps-ddns"} + ]); + for _ in 0..2 { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + assert!(request.starts_with("GET /client/v4/zones/")); + send_json(&mut stream, 200, &json!({"result": records})); + } + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let error = api + .remove_owned_ddns_records( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet-ddns:test:clamps-ddns", + &["host.example.test".into()], + &["11111111111111111111111111111111".into()], + ) + .expect_err("unrecorded duplicate must prevent cleanup"); + assert!(error.to_string().contains("identity changed")); + server.join().expect("server thread"); +} + fn send_json(stream: &mut std::net::TcpStream, status: u16, value: &Value) { let success = (200..300).contains(&status); let body = json!({ diff --git a/ublue/skillet/crates/workstation/src/ddns_provisioning.rs b/ublue/skillet/crates/workstation/src/ddns_provisioning.rs index 29b8f58d..9d3b089b 100644 --- a/ublue/skillet/crates/workstation/src/ddns_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ddns_provisioning.rs @@ -23,6 +23,8 @@ pub enum DdnsProvisioningError { #[error(transparent)] Guest(#[from] skillet_vm::Error), #[error(transparent)] + State(#[from] crate::provisioning_state::ProvisioningStateError), + #[error(transparent)] Caddy(#[from] skillet_caddy::CaddyError), } @@ -37,8 +39,38 @@ pub trait DdnsProvider { zone: &str, account: &str, name: &str, + lifetime: Option, ) -> Result; fn revoke(&self, creator: &str, account: &str, id: &str) -> Result<(), CloudflareError>; + fn replace( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + lifetime: Option, + ) -> Result; + fn owned_test_records( + &self, + token: &str, + zone: &str, + marker: &str, + names: &[String], + ) -> Result, CloudflareError>; + fn remove_test_records( + &self, + token: &str, + zone: &str, + marker: &str, + names: &[String], + ids: &[String], + ) -> Result<(), CloudflareError>; + fn token_ids( + &self, + creator: &str, + account: &str, + name: &str, + ) -> Result, CloudflareError>; } impl DdnsProvider for Cloudflare { @@ -54,12 +86,50 @@ impl DdnsProvider for Cloudflare { zone: &str, account: &str, name: &str, + lifetime: Option, ) -> Result { - self.replace_named_zone_token(creator, zone, account, name, None) + self.create_zone_token(creator, zone, account, name, lifetime) } fn revoke(&self, creator: &str, account: &str, id: &str) -> Result<(), CloudflareError> { self.revoke_token(creator, account, id) } + fn replace( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + lifetime: Option, + ) -> Result { + self.replace_named_zone_token(creator, zone, account, name, lifetime) + } + fn owned_test_records( + &self, + token: &str, + zone: &str, + marker: &str, + names: &[String], + ) -> Result, CloudflareError> { + self.list_owned_ddns_records(token, zone, marker, names) + } + fn remove_test_records( + &self, + token: &str, + zone: &str, + marker: &str, + names: &[String], + ids: &[String], + ) -> Result<(), CloudflareError> { + self.remove_owned_ddns_records(token, zone, marker, names, ids) + } + fn token_ids( + &self, + creator: &str, + account: &str, + name: &str, + ) -> Result, CloudflareError> { + self.token_ids_by_name(creator, account, name) + } } pub struct DdnsDelivery<'a> { @@ -115,7 +185,15 @@ pub fn deliver_persistent_ddns( let token = crate::zone_token::use_or_create( store, &token_path, - || provider.issue(request.creator_token, request.zone_id, account, &token_name), + || { + provider.issue( + request.creator_token, + request.zone_id, + account, + &token_name, + None, + ) + }, |id| provider.revoke(request.creator_token, account, id), )?; let child_zone = provider.zone(&token, request.zone_id)?; @@ -143,6 +221,336 @@ pub fn deliver_persistent_ddns( Ok(()) } +pub struct DisposableDdnsRequest<'a> { + pub host: &'a str, + pub instance: &'a str, + pub policy: ProvisioningPolicy, + pub zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub config: &'a str, + pub creator_token: &'a str, + pub run_directory: &'a std::path::Path, +} + +struct DisposableDdnsPlan { + config: PrivateConfig, + account: String, + marker: String, + token_name: String, + cleanup_token_name: String, + record_names: Vec, +} + +/// Journal first, mint a bounded test token, then activate the existing guest +/// DDNS writer and wait for its allowlisted marked A records to appear. +pub fn provision_disposable_ddns( + request: &DisposableDdnsRequest<'_>, + provider: &impl DdnsProvider, + guest: &impl GuestTransport, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Result<(), DdnsProvisioningError> { + if request.policy.environment() != Environment::Test { + return Err(DdnsProvisioningError::Invalid( + "disposable DDNS requires test policy", + )); + } + skillet_hosts::profile_for_name(request.host) + .filter(|profile| profile.supports_service("ddns")) + .ok_or(DdnsProvisioningError::Invalid("host does not declare DDNS"))?; + let plan = disposable_ddns_plan(request, provider)?; + let path = request.run_directory.join("ddns.json"); + let mut ownership = load_or_create_ddns_ownership(request, &plan, &path)?; + let token = provider.replace( + request.creator_token, + request.zone_id, + &plan.account, + &plan.token_name, + request.policy.cloudflare_token_lifetime(), + )?; + ownership.token_id = Some(token.id); + ownership.expires_on = token.expires_on; + crate::provisioning_state::save_ddns_ownership(&path, &ownership)?; + activate_disposable_ddns( + request, + &plan, + &token.value, + &mut ownership, + &path, + provider, + guest, + timeout, + interval, + ) +} + +fn disposable_ddns_plan( + request: &DisposableDdnsRequest<'_>, + provider: &impl DdnsProvider, +) -> Result { + let config = PrivateConfig::parse(request.config)?; + crate::cloudflare::validate_zone_id(request.zone_id)?; + config.payload(request.zone_id, "validation-placeholder")?; + let zone = provider.zone(request.creator_token, request.zone_id)?; + if zone.id != request.zone_id { + return Err(DdnsProvisioningError::Invalid( + "provider zone identity mismatch", + )); + } + config.validate_zone(&zone.name, &[])?; + let ui_domain = skillet_caddy::resolve_ui_domain(&zone.name, request.relative_ui_domain)?; + if config.records.iter().any(|record| { + let name = format!("{}.{zone}", record.name, zone = zone.name); + name == ui_domain || name.ends_with(&format!(".{ui_domain}")) + }) { + return Err(ConfigError::Overlap.into()); + } + let account = Cloudflare::account_id(&zone)?; + let marker = format!("skillet-ddns:test:{}:{}", request.host, request.instance); + let token_name = format!("skillet:test:{}-{}:ddns", request.host, request.instance); + let cleanup_token_name = format!("{token_name}:cleanup"); + let record_names = config + .records + .iter() + .map(|record| format!("{}.{zone}", record.name, zone = zone.name)) + .collect::>(); + Ok(DisposableDdnsPlan { + config, + account: account.to_string(), + marker, + token_name, + cleanup_token_name, + record_names, + }) +} + +fn load_or_create_ddns_ownership( + request: &DisposableDdnsRequest<'_>, + plan: &DisposableDdnsPlan, + path: &std::path::Path, +) -> Result { + let ownership = if crate::provisioning_state::ddns_ownership_exists(path)? { + let saved = crate::provisioning_state::load_ddns_ownership(path)?; + if saved.identity + != crate::provisioning_state::ProvisioningIdentity::new( + request.host, + request.policy.name(), + request.instance, + ) + || saved.zone_id != request.zone_id + || saved.marker != plan.marker + || saved.token_name != plan.token_name + || saved.record_names != plan.record_names + || saved.cleanup_token_name != plan.cleanup_token_name + { + return Err(DdnsProvisioningError::Invalid( + "DDNS journal does not match this VM/configuration", + )); + } + saved + } else { + let value = crate::provisioning_state::DdnsVmOwnership { + identity: crate::provisioning_state::ProvisioningIdentity::new( + request.host, + request.policy.name(), + request.instance, + ), + zone_id: request.zone_id.into(), + marker: plan.marker.clone(), + token_name: plan.token_name.clone(), + token_id: None, + expires_on: None, + record_names: plan.record_names.clone(), + record_ids: Vec::new(), + cleanup_token_name: plan.cleanup_token_name.clone(), + cleanup_token_id: None, + }; + crate::provisioning_state::save_ddns_ownership(path, &value)?; + value + }; + Ok(ownership) +} + +#[allow(clippy::too_many_arguments)] +fn activate_disposable_ddns( + request: &DisposableDdnsRequest<'_>, + plan: &DisposableDdnsPlan, + token: &str, + ownership: &mut crate::provisioning_state::DdnsVmOwnership, + path: &std::path::Path, + provider: &impl DdnsProvider, + guest: &impl GuestTransport, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Result<(), DdnsProvisioningError> { + let existing = + provider.owned_test_records(token, request.zone_id, &plan.marker, &plan.record_names)?; + let all_records = provider.records(token, request.zone_id)?; + for name in &plan.record_names { + if all_records.iter().any(|record| { + record.name.eq_ignore_ascii_case(name) + && record.comment.as_deref() != Some(plan.marker.as_str()) + }) { + return Err(DdnsProvisioningError::Invalid( + "test DDNS record name is already in use", + )); + } + } + ownership.record_ids = existing.iter().map(|record| record.id.clone()).collect(); + crate::provisioning_state::save_ddns_ownership(path, ownership)?; + let payload = plan + .config + .payload(request.zone_id, token)? + .with_record_comment(&plan.marker)? + .render()?; + credential::install( + guest, + request.host, + skillet_ddns::CREDENTIAL, + "skillet-ddns-apply.service", + credential::ActivationPolicy::StartConsumer, + payload.as_bytes(), + )?; + wait_for_disposable_ddns( + request, plan, token, ownership, path, provider, timeout, interval, + ) +} + +#[allow(clippy::too_many_arguments)] +fn wait_for_disposable_ddns( + request: &DisposableDdnsRequest<'_>, + plan: &DisposableDdnsPlan, + token: &str, + ownership: &mut crate::provisioning_state::DdnsVmOwnership, + path: &std::path::Path, + provider: &impl DdnsProvider, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Result<(), DdnsProvisioningError> { + let deadline = std::time::Instant::now() + timeout; + loop { + let records = provider.owned_test_records( + token, + request.zone_id, + &plan.marker, + &plan.record_names, + )?; + let ready = plan.record_names.iter().all(|name| { + records.iter().any(|record| { + record.name.eq_ignore_ascii_case(name) && public_ipv4(&record.content) + }) + }); + if ready { + ownership.record_ids = records.iter().map(|record| record.id.clone()).collect(); + crate::provisioning_state::save_ddns_ownership(path, ownership)?; + return Ok(()); + } + if std::time::Instant::now() >= deadline { + return Err(DdnsProvisioningError::Invalid( + "timed out waiting for marked public DDNS records; ownership journal retained", + )); + } + std::thread::sleep(interval); + } +} + +fn public_ipv4(value: &str) -> bool { + let Ok(ip) = value.parse::() else { + return false; + }; + let octets = ip.octets(); + let documentation = (octets[0] == 192 && octets[1] == 0 && octets[2] == 2) + || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100) + || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113); + let special = octets[0] == 0 + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + || (octets[0] == 192 && octets[1] == 88 && octets[2] == 99) + || (octets[0] == 198 && (18..=19).contains(&octets[1])) + || octets[0] >= 240; + !documentation + && !special + && !ip.is_private() + && !ip.is_loopback() + && !ip.is_link_local() + && !ip.is_multicast() + && !ip.is_broadcast() + && !ip.is_unspecified() + && !(octets[0] == 100 && (64..=127).contains(&octets[1])) +} + +pub struct DisposableDdnsCleanup<'a> { + pub host: &'a str, + pub instance: &'a str, + pub policy: ProvisioningPolicy, + pub ownership_path: &'a std::path::Path, + pub configured_zone_id: &'a str, + pub creator_token: &'a str, +} + +pub fn cleanup_disposable_ddns( + request: &DisposableDdnsCleanup<'_>, + provider: &impl DdnsProvider, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(DdnsProvisioningError::Invalid( + "DDNS cleanup requires test policy", + )); + } + if !crate::provisioning_state::ddns_ownership_exists(request.ownership_path)? { + return Ok(false); + } + let mut owner = crate::provisioning_state::load_ddns_ownership(request.ownership_path)?; + let expected = crate::provisioning_state::ProvisioningIdentity::new( + request.host, + request.policy.name(), + request.instance, + ); + let token_name = format!("skillet:test:{}-{}:ddns", request.host, request.instance); + if owner.identity != expected + || owner.zone_id != request.configured_zone_id + || owner.marker != format!("skillet-ddns:test:{}:{}", request.host, request.instance) + || owner.token_name != token_name + || owner.cleanup_token_name != format!("{token_name}:cleanup") + || owner.record_names.is_empty() + { + return Err(DdnsProvisioningError::Invalid( + "DDNS ownership journal does not match this VM and environment", + )); + } + let zone = provider.zone(request.creator_token, &owner.zone_id)?; + if zone.id != owner.zone_id { + return Err(DdnsProvisioningError::Invalid( + "provider zone identity mismatch", + )); + } + let account = Cloudflare::account_id(&zone)?; + let cleanup = provider.replace( + request.creator_token, + &owner.zone_id, + account, + &owner.cleanup_token_name, + Some(request.policy.cleanup_token_lifetime()), + )?; + owner.cleanup_token_id = Some(cleanup.id); + crate::provisioning_state::save_ddns_ownership(request.ownership_path, &owner)?; + provider.zone(&cleanup.value, &owner.zone_id)?; + provider.remove_test_records( + &cleanup.value, + &owner.zone_id, + &owner.marker, + &owner.record_names, + &owner.record_ids, + )?; + for id in provider.token_ids(request.creator_token, account, &owner.token_name)? { + provider.revoke(request.creator_token, account, &id)?; + } + for id in provider.token_ids(request.creator_token, account, &owner.cleanup_token_name)? { + provider.revoke(request.creator_token, account, &id)?; + } + crate::provisioning_state::remove_ddns_ownership(request.ownership_path)?; + Ok(true) +} + fn validate_takeover( config: &PrivateConfig, zone: &str, diff --git a/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs index 10e82fa4..32624f0e 100644 --- a/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs @@ -45,6 +45,8 @@ struct Provider { revoked: Cell, records: Vec, fail_revoke: bool, + owned_calls: Cell, + removed_records: Cell, } impl DdnsProvider for Provider { fn zone(&self, _token: &str, zone: &str) -> Result { @@ -65,11 +67,13 @@ impl DdnsProvider for Provider { zone: &str, account: &str, name: &str, + lifetime: Option, ) -> Result { assert_eq!(creator, "dummy-master-token"); assert_eq!(zone, ZONE); assert_eq!(account, ZONE); assert_eq!(name, "skillet:production:clamps:ddns"); + assert_eq!(lifetime, None); self.issued.set(self.issued.get() + 1); Ok(IssuedToken { id: "dummy-id".into(), @@ -78,7 +82,7 @@ impl DdnsProvider for Provider { }) } fn revoke(&self, _creator: &str, _account: &str, id: &str) -> Result<(), CloudflareError> { - assert_eq!(id, "dummy-id"); + assert!(id == "dummy-id" || id == "abcdefabcdefabcdefabcdefabcdefab"); self.revoked.set(self.revoked.get() + 1); if self.fail_revoke { Err(CloudflareError::Invalid("revoke failed".into())) @@ -86,6 +90,70 @@ impl DdnsProvider for Provider { Ok(()) } } + + fn replace( + &self, + _creator: &str, + _zone: &str, + _account: &str, + name: &str, + lifetime: Option, + ) -> Result { + assert!(name.starts_with("skillet:test:clamps-") && name.contains(":ddns")); + assert!(lifetime.is_some()); + self.issued.set(self.issued.get() + 1); + Ok(IssuedToken { + id: "abcdefabcdefabcdefabcdefabcdefab".into(), + value: "dummy-child-token".into(), + expires_on: Some("2030-01-01T00:00:00Z".into()), + }) + } + fn owned_test_records( + &self, + _token: &str, + _zone: &str, + marker: &str, + names: &[String], + ) -> Result, CloudflareError> { + let call = self.owned_calls.get(); + self.owned_calls.set(call + 1); + if call == 0 { + return Ok(Vec::new()); + } + Ok(names + .iter() + .map(|name| RecordRef { + id: "1234567890abcdef1234567890abcdef".into(), + name: name.clone(), + record_type: "A".into(), + content: "8.8.8.8".into(), + comment: Some(marker.into()), + }) + .collect()) + } + fn remove_test_records( + &self, + _token: &str, + _zone: &str, + marker: &str, + names: &[String], + ids: &[String], + ) -> Result<(), CloudflareError> { + assert!(marker.starts_with("skillet-ddns:test:")); + assert_ne!(ids.len(), 0); + assert_eq!(names.len(), ids.len()); + self.removed_records.set(ids.len()); + Ok(()) + } + fn token_ids( + &self, + _creator: &str, + _account: &str, + name: &str, + ) -> Result, CloudflareError> { + assert!(name.contains(":ddns")); + Ok(vec!["abcdefabcdefabcdefabcdefabcdefab".into()]) + } } #[derive(Default)] @@ -240,6 +308,89 @@ fn only_explicit_single_public_a_record_takeover_is_allowed() { } } +#[test] +fn disposable_provision_and_cleanup_are_journaled_and_identity_bound() { + let directory = tempfile::tempdir().unwrap(); + let provider = Provider::default(); + let guest = Guest::default(); + let policy = ProvisioningPolicy::new(Environment::Test); + let request = DisposableDdnsRequest { + host: "clamps", + instance: "ddns-smoke", + policy, + zone_id: ZONE, + relative_ui_domain: Some("ui"), + config: CONFIG, + creator_token: "dummy-master-token", + run_directory: directory.path(), + }; + provision_disposable_ddns( + &request, + &provider, + &guest, + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ) + .unwrap(); + let path = directory.path().join("ddns.json"); + let owner = crate::provisioning_state::load_ddns_ownership(&path).unwrap(); + assert_eq!(owner.identity.host, "clamps"); + assert_eq!(owner.identity.environment, "test"); + assert_eq!(owner.record_names, ["edge.example.com"]); + assert_eq!(owner.record_ids, ["1234567890abcdef1234567890abcdef"]); + let cleanup = DisposableDdnsCleanup { + host: "clamps", + instance: "ddns-smoke", + policy, + ownership_path: &path, + configured_zone_id: ZONE, + creator_token: "dummy-master-token", + }; + assert!(cleanup_disposable_ddns(&cleanup, &provider).unwrap()); + assert_eq!(provider.removed_records.get(), 1); + assert!(!crate::provisioning_state::ddns_ownership_exists(&path).unwrap()); + assert!(!cleanup_disposable_ddns(&cleanup, &provider).unwrap()); +} + +#[test] +fn disposable_cleanup_refuses_identity_and_zone_mismatches_before_api_changes() { + let directory = tempfile::tempdir().unwrap(); + let provider = Provider::default(); + let policy = ProvisioningPolicy::new(Environment::Test); + let request = DisposableDdnsRequest { + host: "clamps", + instance: "ddns-smoke", + policy, + zone_id: ZONE, + relative_ui_domain: None, + config: CONFIG, + creator_token: "dummy-master-token", + run_directory: directory.path(), + }; + provision_disposable_ddns( + &request, + &provider, + &Guest::default(), + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ) + .unwrap(); + let path = directory.path().join("ddns.json"); + let wrong = DisposableDdnsCleanup { + host: "clamps", + instance: "another", + policy, + ownership_path: &path, + configured_zone_id: ZONE, + creator_token: "dummy-master-token", + }; + assert!(cleanup_disposable_ddns(&wrong, &provider).is_err()); + assert_eq!(provider.removed_records.get(), 0); + assert!(!public_ipv4("100.64.0.2")); + assert!(!public_ipv4("192.0.2.1")); + assert!(public_ipv4("8.8.8.8")); +} + #[test] fn rejects_zones_that_exceed_the_updaters_record_page() { let config = PrivateConfig::parse(CONFIG).unwrap(); diff --git a/ublue/skillet/crates/workstation/src/provisioning_state.rs b/ublue/skillet/crates/workstation/src/provisioning_state.rs index b9d21972..9e9f083a 100644 --- a/ublue/skillet/crates/workstation/src/provisioning_state.rs +++ b/ublue/skillet/crates/workstation/src/provisioning_state.rs @@ -68,6 +68,42 @@ pub struct CloudflareVmOwnership { pub record_ids: Vec, } +/// Ownership for public DDNS records created by one disposable VM. +/// The configured names are private; this file remains mode 0600 under runs/. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DdnsVmOwnership { + pub identity: ProvisioningIdentity, + pub zone_id: String, + pub marker: String, + pub token_name: String, + pub token_id: Option, + pub expires_on: Option, + pub record_names: Vec, + #[serde(default)] + pub record_ids: Vec, + pub cleanup_token_name: String, + pub cleanup_token_id: Option, +} + +pub fn save_ddns_ownership( + path: &Path, + ownership: &DdnsVmOwnership, +) -> Result<(), ProvisioningStateError> { + write_json_atomically(path, ownership) +} + +pub fn load_ddns_ownership(path: &Path) -> Result { + read_json(path) +} + +pub fn ddns_ownership_exists(path: &Path) -> Result { + regular_file_exists(path, "DDNS ownership state must be a regular file") +} + +pub fn remove_ddns_ownership(path: &Path) -> Result<(), ProvisioningStateError> { + remove_regular_file(path, "DDNS ownership state must be a regular file") +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] struct TailscaleVmOwnership { identity: ProvisioningIdentity, diff --git a/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs b/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs index 76c5dfa8..ff3065ca 100644 --- a/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs +++ b/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs @@ -28,6 +28,33 @@ fn identity() -> ProvisioningIdentity { } } +#[test] +fn ddns_ownership_journal_is_private_and_removable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ddns.json"); + let owner = DdnsVmOwnership { + identity: identity(), + zone_id: "0123456789abcdef0123456789abcdef".into(), + marker: "skillet-ddns:test:host:instance".into(), + token_name: "skillet:test:host-instance:ddns".into(), + token_id: Some("abcdef0123456789abcdef0123456789".into()), + expires_on: Some("2030-01-01T00:00:00Z".into()), + record_names: vec!["edge.example.invalid".into()], + record_ids: vec!["abcdef0123456789abcdef0123456789".into()], + cleanup_token_name: "skillet:test:host-instance:ddns:cleanup".into(), + cleanup_token_id: None, + }; + save_ddns_ownership(&path, &owner).unwrap(); + assert_eq!(load_ddns_ownership(&path).unwrap(), owner); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + PRIVATE_FILE_MODE + ); + assert!(ddns_ownership_exists(&path).unwrap()); + remove_ddns_ownership(&path).unwrap(); + assert!(!ddns_ownership_exists(&path).unwrap()); +} + #[test] fn cloudflare_ownership_is_private_atomic_json_state() { let directory = tempfile::tempdir().unwrap(); From 1f286d1984e707ec435c478f54a75f79e40fadcc Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 6 Oct 2026 14:00:06 +0200 Subject: [PATCH 207/252] docs(ddns): record disposable acceptance status --- ublue/butane/ACCEPTANCE.md | 43 ++++++++++++++++++++++--- ublue/design/cloudflare-ddns.md | 18 +++++++---- ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md | 47 +++++++++++++++------------- ublue/plan/CLAMPS-MIGRATION.md | 16 +++++----- ublue/plan/CLAMPS-PARITY.md | 8 ++--- 5 files changed, 88 insertions(+), 44 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 6a288084..cd7f2020 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,39 @@ # Clamps VM acceptance log +## Disposable DDNS attempt and cleanup, 2026-10-06 + +- Built the current Skillet CLI with Rust 1.99 and created a separate + `clamps-test-ddns` VM on the native session libvirt runtime, leaving the + retained `clamps-test-smoke` VM untouched. The guest completed signed boot + and Skillet readiness. +- Ran `skillet test vm provision clamps ddns --with-ddns`. Pi-hole and + Tailscale provisioning completed, then DDNS preflight stopped because the + test KeePassXC entry + `skillet/environments/test/hosts/clamps/cloudflare/ddns-config` is absent. + The failure occurred before Cloudflare mutation: no DDNS child token, + ownership journal, or DNS record was created. +- Ran `skillet test vm destroy clamps ddns`. Skillet removed the recorded + Tailscale identity and disposed the VM and its temporary SSH material. No + production DNS or other VM was touched. +- Live DDNS record creation, updater convergence, and exact-record cleanup are + still parked until the test-only DDNS config entry is added to KeePassXC. + The disposable ownership/create/cleanup paths are covered by workstation + fake-provider tests; this attempt is not live DDNS ownership acceptance. + +## DDNS lifecycle local validation, 2026-10-06 + +- Passed `cargo fmt --all -- --check`, `git diff --check`, strict workspace + Clippy, and `cargo test --workspace --all-targets --offline` using Rust 1.99 + and the configured x86_64 musl target. The full suite passed; workstation + coverage now includes 77 tests. +- Added a Cloudflare API fixture regression proving cleanup refuses an extra, + unjournaled record carrying the disposable marker before sending any DELETE. + Disposable provisioning tests cover bounded credentials, exact configured + names, journal identity, mismatch refusal, cleanup/retry and safe errors. +- Did not run live DDNS container/record acceptance: test configuration was + missing, so no DDNS guest credential or service was activated. The VM attempt + and recorded Tailscale cleanup are described above. + ## DDNS local implementation checks, 2026-10-06 - Evaluated upstream 2.2.0 (source revision @@ -20,11 +54,10 @@ - Workspace musl tests, pedantic Clippy and formatting passed with Rust 1.99. Fedora base integration applied twice without repeat service actions and removed its container. Bootstrap regression tests passed all eight cases. -- PARKED: rootful DDNS on a named uCore VM, actual public-address records, - reboot persistence and disposal acceptance. Flatpak libvirt remains unavailable - by the user's prior instruction. Disposable DDNS issuance/ownership/cleanup - is not implemented and test-policy delivery is refused. No vault read, live - provider mutation, production activation or old-writer handover was attempted. +- Superseded by the later disposable DDNS attempt recorded above. That attempt + created and disposed a named uCore VM using the native session libvirt runtime; + test-only config was missing, so live DDNS records and provider cleanup remain + unverified. No production activation or old-writer handover was attempted. ## CI lint recovery, 2026-10-06 diff --git a/ublue/design/cloudflare-ddns.md b/ublue/design/cloudflare-ddns.md index 86a22dc0..2a33fdee 100644 --- a/ublue/design/cloudflare-ddns.md +++ b/ublue/design/cloudflare-ddns.md @@ -4,8 +4,9 @@ Decision, 2026-10-06: retain Rupik's public-address DDNS capability. Plan to reuse the existing Chef image family in a rootful Quadlet container, with caller opt-in and private configuration supplied through Skillet credentials. This preserves the existing updater instead of introducing another guest -Cloudflare API client. Guest convergence and production credential delivery are -implemented; disposable lifecycle and live DNS acceptance remain pending. +Cloudflare API client. Guest convergence, production credential delivery, and +journaled disposable token/record cleanup are implemented. Live disposable DNS +acceptance remains pending the test-only private DDNS config entry. The host caller explicitly opts into the shared DDNS crate/service. Installing Skillet or configuring an environment zone does not enable DDNS. Reuse @@ -54,12 +55,17 @@ Production delivery reuses or creates a separate persistent child token through the same atomic token-persistence helper used by Caddy. New tokens are revoked when vault saving fails; deterministic token names support retry recovery. The dedicated DDNS apply phase leaves ordinary full apply independent of this -credential. Test-policy delivery is refused until disposable ownership and -cleanup are implemented; ordinary application smoke checks exclude DDNS. +credential. Test-policy delivery is opt-in through the disposable VM provision +flow. It journals exact test record names, ownership markers, record IDs, and +bounded token identities before mutation; cleanup validates this state against +the recorded VM and deletes only records that still match. Ordinary application +smoke checks exclude DDNS. Source contract: [upstream 2.2.0](https://github.com/timothymiller/cloudflare-ddns/tree/7c6d5b43c1d400f4977f2b25bd44d784bb601ae3). Disposable tests use explicitly isolated record names and bounded credentials. Journal and clean only test-owned resources; disposal never removes retained -production DDNS records. See the [implementation plan](../plan/CLAMPS-CLOUDFLARE-DDNS.md) -and [existing token/DNS lifecycle](cloudflare-ui-lifecycle.md). +production DDNS records. The first VM attempt was parked before Cloudflare +mutation because the test `ddns-config` vault entry was absent. See the +[implementation plan](../plan/CLAMPS-CLOUDFLARE-DDNS.md) and [token/DNS +lifecycle](cloudflare-ui-lifecycle.md). diff --git a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md index 01b6d64f..e303b0c2 100644 --- a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md +++ b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md @@ -1,11 +1,14 @@ # Cloudflare DDNS port -Status: slices 1–2 and production portion of slice 3 implemented, 2026-10-06. -Shared DDNS crate, caller selection, separate apply phase/unit, validated private -config, token use-or-create and production encrypted delivery exist. Disposable -DDNS provisioning/cleanup and live DNS acceptance remain pending. Refactor implementation is committed; -Syncthing live acceptance is parked and remote CI evidence is pending. Planning -can proceed; keep unavailable runtime acceptance explicit during implementation. +Status: slices 1–3 and disposable test provisioning/cleanup implementation +complete, 2026-10-06. Shared DDNS crate, caller selection, separate apply +phase/unit, validated private config, token use-or-create, production encrypted +delivery, and journaled test-only DNS/token ownership are implemented. Routine +and fake-provider tests cover cleanup. Live DDNS DNS ownership acceptance is +parked because the test vault has no `ddns-config` entry. The disposable VM was +created, reached signed boot, partially provisioned, and destroyed with its +Tailscale identity removed; no DDNS token or DNS record was created. Syncthing +live acceptance remains parked and remote CI evidence is pending. Read `../AGENTS.md`, `../skillet/AGENTS.md`, `../design/cloudflare-ddns.md`, `../design/secrets.md`, `../design/cloudflare-ui-lifecycle.md`, and @@ -100,9 +103,8 @@ atomic use-or-create helper. JSON may include `takeover_existing: true` to appro adopting an existing public A record; UI namespace and other Skillet ownership remain protected. New records receive a DDNS-specific ownership comment. Fake-provider/transport tests cover token reuse, failed delivery retry, explicit -vault token rotation, failed saving/revocation and collisions. Test policy is -refused before unlocking the vault; its bounded issuance/journal belongs to -slice 4. No production DNS writes or guest activation were performed here. +vault token rotation, failed saving/revocation and collisions. Test policy uses +an opt-in VM flow with bounded issuance and a mode-0600 ownership journal. - Reuse the workstation Cloudflare SDK/account-token issuer and atomic KDBX writer. Planned token path: @@ -126,22 +128,23 @@ workstation issuer. ## 4. Disposable acceptance and production handover -- Make test DDNS activation opt-in. Use only explicitly configured test record - names and the test environment zone; refuse production config on a disposable - VM. Journal ownership before writes, detect record collisions, and use a - bounded-lifetime token. Zone tokens cannot enforce per-record scope; allowlisting - and ownership validation belong to provisioning and the updater's config. -- Keep DDNS record ownership separate from the UI lifecycle. The workstation may - preflight/journal test records, but the DDNS runtime is their continuing writer. - Cleanup verifies recorded identity and records, then deletes only test-owned - records and revokes the DDNS token. Persistent disposal never deletes production - records. If the image cannot preserve reliable ownership metadata, design the - record-journaling interface before permitting test record creation. +Implementation complete; live DNS acceptance remains parked. The disposable +provisioner persists VM/environment/zone identity, a unique test ownership +marker, exact configured record names, token identity and cleanup-token state +before provider mutation. It records created record IDs after verifying the +updater has published its marked A records. Disposal revalidates the journal +against the exact test VM and configured name allowlist, deletes only records +that still match the ownership marker and recorded IDs, then revokes the +bounded DDNS and cleanup tokens. A mismatch aborts cleanup without broadening +the deletion set. + - On a named VM verify a record receives the discovered public IPv4, stays DNS-policy compatible, leaves unrelated A/AAAA/CNAME/TXT records untouched, survives repeat apply/reboot, and reuses its credential. Test credential rotation - and disposal without printing private values. Park these checks while Flatpak - libvirt is unavailable; record results in `../butane/ACCEPTANCE.md` later. + and disposal without printing private values. Park live DDNS record checks + until `skillet/environments/test/hosts//cloudflare/ddns-config` is + present in KeePassXC. Record results in + `../butane/ACCEPTANCE.md`. - Before production: privately inventory Rupik's exact records, TTL/proxy state, image/version/cadence, and active updater services/cron. Approve the takeover record set; stop the old writer, activate Clamps, verify records and services, diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index cac4cc6a..f2fd21d3 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -6,8 +6,8 @@ Existing acceptance evidence and deferred tasks remain applicable. Status: reconciled against source and acceptance evidence, 2026-10-06. Refactor implementation is committed; Syncthing live validation is parked and -remote CI evidence is pending. Parity auditing and DDNS implementation proceed while -that check is parked. Skillet convergence and disposable VM bootstrap exist. +remote CI evidence is pending. Parity auditing and DDNS implementation proceeded +while that check is parked. Skillet convergence and disposable VM bootstrap exist. Runtime evidence and outstanding bootstrap checks are recorded in [ACCEPTANCE.md](../butane/ACCEPTANCE.md). @@ -25,9 +25,11 @@ staging ACME/HTTPS have passed named-VM acceptance. Final physical disk, production/network acceptance, and remaining rebase/bootstrap checks stay open. The [source parity checklist](CLAMPS-PARITY.md) records retained behavior and -deliberate changes. The next missing service is -[Cloudflare DDNS](CLAMPS-CLOUDFLARE-DDNS.md). Datadog is retained by user -decision; its port and UniFi backup/restore follow the DDNS milestone. +deliberate changes. [Cloudflare DDNS](CLAMPS-CLOUDFLARE-DDNS.md) has its guest +service, production delivery, and disposable ownership/cleanup implementation. +Live disposable DNS acceptance awaits the private test config; production +cutover remains separate. Datadog is retained by user decision; its port and +UniFi backup/restore are the next implementation milestones. After the refactoring prerequisite: complete Pi-hole LAN acceptance and configure the actual custom DNS @@ -129,8 +131,8 @@ Each row is a separate implementation and validation step. Adjust order for depe | 3 | UniFi (implemented; empty-controller/repeat-apply acceptance passed) | Follow the [UniFi port plan](CLAMPS-UNIFI.md): rootful host-network container, persistent data, and isolated backup restore/version compatibility before cutover. No Caddy route. | | 4 | Tailscale (implemented; smoke enrollment and cleanup accepted) | Host network, state, OAuth enrollment, and cleanup exist. Chef's exit-node advertisement is missing in the current declaration; add caller-controlled behavior and validate production forwarding. UI names use Cloudflare DNS-only records. | | 5 | Private UI access with Caddy + ACME (implemented; staging accepted) | Generic declarations/aliases, encrypted delivery, scoped account tokens, A/AAAA/CNAME reconciliation and cleanup exist. Staging ACME/HTTPS passed. Production renewal, real-client resolution and separate-network denial remain; see [UI plan](GENERIC-PRIVATE-UIS.md). | -| 6 | Cloudflare DDNS | Guest service and production credential delivery implemented; disposable lifecycle and live acceptance remain in [DDNS plan](CLAMPS-CLOUDFLARE-DDNS.md); no competing production writer | -| 7 | Datadog and remaining host baseline | Keep Datadog for now; port after DDNS. Audit hardening, users, SSH/sudo, ET and WOL targets against the [parity checklist](CLAMPS-PARITY.md) | +| 6 | Cloudflare DDNS | Guest service, production delivery, and journaled disposable lifecycle implemented; live disposable acceptance awaits test config and production one-writer handover remains separate; see [DDNS plan](CLAMPS-CLOUDFLARE-DDNS.md) | +| 7 | Datadog and remaining host baseline | Retain Datadog; port it next. Audit hardening, users, SSH/sudo, ET and WOL targets against the [parity checklist](CLAMPS-PARITY.md) | For each service: inspect effective Chef inputs, settle its open decisions, add only needed Skillet support, migrate a copy of state, validate functionality and repeat convergence. Account for ARM-to-x86 application/image and data compatibility. Avoid activating duplicate production identities or DNS writers during testing. diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index dc972696..ebe1ed98 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -18,7 +18,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | btrbk | Caller-selected Syncthing snapshots, hourly timer, retention and restore accepted | Off-machine backup policy and physical cutover acceptance; nested source subvolumes unsupported | | UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply acceptance | Isolated backup restore, version compatibility, adoption and Site Manager access; see `CLAMPS-UNIFI.md` | | Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | -| Cloudflare DDNS | Caller-selected rootful updater, validated secret config and production child-token delivery implemented; separate from UI DNS publication | Disposable lifecycle, live DNS acceptance, private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | +| Cloudflare DDNS | Caller-selected rootful updater, validated secret config, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Live disposable DNS acceptance (test config missing), private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | | Datadog | Retained by user decision, 2026-10-06; not implemented | Port metrics/process/network monitoring and retained checks; replace nginx-specific check as appropriate. Later local metrics/Grafana is separate work | | EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | | Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | @@ -36,9 +36,9 @@ exists. Additional private run-list recipes remain an inventory question. ## Work order -1. DDNS: the implementation-ready plan is the next service slice after the - applicable refactor validation gate; do not mutate production DNS now. -2. Datadog port, then isolated UniFi backup/restore planning and execution. +1. Complete live disposable DDNS acceptance once the test-only config exists; + do not mutate production DNS. +2. Port Datadog, then handle isolated UniFi backup/restore planning and execution. 3. Close exit-node and remaining host-baseline gaps before cutover. 4. Run the parked VM and deferred network/production acceptance when their required infrastructure is available; transfer state only with a recovery path. From e3674fc2cef3b57cd478276d643ee53846451ffe Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Tue, 6 Oct 2026 17:33:21 +0200 Subject: [PATCH 208/252] feat(config): resolve vault references in service templates Keep DDNS policy in an embedded source catalog with complete host overrides and resolve private leaf values through typed KeePassXC references. Share rendering across production and disposable delivery, document migration, and cover resolver safety and consumer validation. --- ublue/AGENTS.md | 5 + ublue/design/cloudflare-ddns.md | 16 +- ublue/design/configuration-templates.md | 37 +++++ ublue/design/secrets.md | 18 +- ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md | 35 ++-- ublue/plan/CLAMPS-MIGRATION.md | 2 +- ublue/plan/CLAMPS-PARITY.md | 4 +- ublue/skillet/README.md | 20 ++- .../skillet/crates/cli/src/secret_delivery.rs | 28 ++-- .../crates/cli/src/secret_delivery/tests.rs | 4 +- .../src/configuration-templates.json | 20 +++ .../src/configuration_templates.rs | 154 ++++++++++++++++++ .../src/configuration_templates/tests.rs | 127 +++++++++++++++ ublue/skillet/crates/workstation/src/lib.rs | 1 + 14 files changed, 422 insertions(+), 49 deletions(-) create mode 100644 ublue/design/configuration-templates.md create mode 100644 ublue/skillet/crates/workstation/src/configuration-templates.json create mode 100644 ublue/skillet/crates/workstation/src/configuration_templates.rs create mode 100644 ublue/skillet/crates/workstation/src/configuration_templates/tests.rs diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 57edbb27..1cc86d33 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -46,6 +46,11 @@ - Shared helpers receive identity and configuration from validated inputs or declarations. Keep deployment-specific assumptions in the selected profile; do not bake them into generic readiness, delivery, or lifecycle code. +- Keep clear configuration policy in reviewed source templates and secret + leaves in the vault. Resolve secrets through typed references at the + workstation boundary; do not store whole configuration documents in the + vault or interpolate secret text into configuration strings. Record template + behavior and schema changes in the relevant design document. - Give each resource lifecycle one owner. Every public entry point must use the same ownership validation, recovery, and cleanup policy; transport adapters must not provide a shortcut that bypasses those guarantees. diff --git a/ublue/design/cloudflare-ddns.md b/ublue/design/cloudflare-ddns.md index 2a33fdee..ba80fa60 100644 --- a/ublue/design/cloudflare-ddns.md +++ b/ublue/design/cloudflare-ddns.md @@ -6,13 +6,14 @@ caller opt-in and private configuration supplied through Skillet credentials. This preserves the existing updater instead of introducing another guest Cloudflare API client. Guest convergence, production credential delivery, and journaled disposable token/record cleanup are implemented. Live disposable DNS -acceptance remains pending the test-only private DDNS config entry. +acceptance remains pending the test-only DDNS record-name entry. The host caller explicitly opts into the shared DDNS crate/service. Installing Skillet or configuring an environment zone does not enable DDNS. Reuse `skillet/environments//dns/cloudflare-zone-id`; no separate DDNS -zone entry is needed. Private record configuration remains in KeePassXC, rather -than embedding production DNS names in the host binary. +zone entry is needed. The public JSON policy is an embedded workstation +configuration template. KeePassXC contains only each environment/host's +relative DDNS record name; see the [configuration template design](configuration-templates.md). The DDNS container owns only its explicitly configured public-address records. The existing workstation UI lifecycle owns tailnet machine records and UI @@ -43,8 +44,10 @@ registry auto-update from changing this evaluated contract. Its legacy API path reads one page of 100 A records; provisioning refuses a zone that would exceed that limit after adding the declared records. -Private config is version 1, with relative `records` and explicit `proxied` -booleans. `takeover_existing` defaults to false. Production preflight rejects +The version 1 DDNS JSON template has relative `records` and explicit `proxied` +booleans. Its `$secret` marker resolves the `ddns-dns-name` vault leaf, and +`takeover_existing` defaults to false. A per-host template replaces the shared +template completely. Production preflight rejects the whole UI namespace, duplicates, and incompatible records. Existing public A records need explicit takeover approval; records owned by other Skillet consumers cannot be adopted. The updater writes a distinct @@ -66,6 +69,7 @@ Source contract: [upstream 2.2.0](https://github.com/timothymiller/cloudflare-dd Disposable tests use explicitly isolated record names and bounded credentials. Journal and clean only test-owned resources; disposal never removes retained production DDNS records. The first VM attempt was parked before Cloudflare -mutation because the test `ddns-config` vault entry was absent. See the +mutation because its then-required private JSON config entry was missing. Live +acceptance now requires the test `ddns-dns-name` leaf. See the [implementation plan](../plan/CLAMPS-CLOUDFLARE-DDNS.md) and [token/DNS lifecycle](cloudflare-ui-lifecycle.md). diff --git a/ublue/design/configuration-templates.md b/ublue/design/configuration-templates.md new file mode 100644 index 00000000..b4181642 --- /dev/null +++ b/ublue/design/configuration-templates.md @@ -0,0 +1,37 @@ +# Configuration templates and secret references + +Decision, 2026-10-06: keep ordinary service configuration as clear, reviewed +source templates; keep only host/environment-specific leaf values in KeePassXC. +This avoids storing and hand-editing whole JSON documents in the vault while +keeping private values out of the public source. + +Templates are JSON keyed by service, with a shared default and optional complete +host overrides. A host override replaces the entire shared template; automatic +field or array merging is intentionally undefined. A value can use the typed +marker `{"$secret":"skillet/environments/{environment}/hosts/{host}/..."}`. +Skillet substitutes only the validated host and environment components in that +entry path, then replaces the whole marker with the KeePassXC Password value as +a JSON string. Secret contents are never interpolated into surrounding text or +interpreted recursively. + +The workstation resolves references in memory before service-schema validation +and before provider mutations. Missing entries, malformed references, and +unknown placeholders in entry paths fail closed. Ordinary strings remain +literal. Rendered documents and secret values are not +logged, recorded, or persisted as workstation files. Guest apply has no vault +dependency. + +The embedded catalog is +[`configuration-templates.json`](../skillet/crates/workstation/src/configuration-templates.json); +the resolver is workstation-only. Adding a service adds its default template to +the catalog; add a host override only when a caller needs a different complete +configuration. For example, DDNS keeps policy in its template and its relative +record name in +`skillet/environments//hosts//cloudflare/ddns-dns-name`. + +When converting an existing vault-held JSON document, move its structural policy +into the source template and each private leaf into its own KeePassXC entry. +For DDNS, move `name` to `ddns-dns-name`; keep `proxied` and +`takeover_existing` in the template. Multi-record hosts can provide a complete +host override with one typed reference per record. Remove the obsolete whole +document entry after the new template and leaf values have been validated. diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 917a124e..68e092ab 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -61,7 +61,7 @@ policy maps to the `prod` group. | `skillet/environments//dns/ui-domain` | Optional relative UI prefix; defaults to `ui`, appended to the fetched zone domain | | `skillet/environments//dns/cloudflare-zone-id` | Environment-specific Cloudflare Zone ID lookup (`prod` or `test`) | | `skillet/environments//hosts//cloudflare/acme-token` | Persistent `cloudflare_acme_token`, created on first Caddy delivery | -| `skillet/environments//hosts//cloudflare/ddns-config` | Private versioned record configuration, used to compose `cloudflare_ddns_config` | +| `skillet/environments//hosts//cloudflare/ddns-dns-name` | Private relative record name consumed by the shared DDNS configuration template | | `skillet/environments/prod/hosts//cloudflare/ddns-token` | Persistent child token, created on first DDNS delivery; distinct from ACME | | `skillet/hosts//cloudflare/acme-token` | Legacy production credential migrated on first delivery | | `skillet/cloudflare/token-creator` | Workstation only | @@ -80,6 +80,12 @@ create a replacement. Production values persist across workstation changes; rotation is an explicit vault edit and redelivery. Normal host re-apply can reuse credentials already installed on that host. +## Configuration templates + +Use the generic [configuration template design](configuration-templates.md) +when service configuration combines clear policy with KeePassXC values. The +DDNS template is the first implementation. + ## Delivery and consumption After signed boot and base readiness, the workstation sends only the target's @@ -113,11 +119,13 @@ Podman's default secret storage persists a copy on the guest disk; systemd's credential encryption does not encrypt that copy. Optional DDNS uses the same encrypted delivery path and a separate apply unit. -The workstation composes its complete JSON in memory, combining private record -config with a zone-scoped child token. The guest mounts the Podman secret as +The workstation renders the shared configuration template, resolves its typed +KeePassXC references, and combines it in memory with a zone-scoped child token. +The guest mounts the Podman secret as `/config.json`, root-only. The master token is never delivered. Production -delivery is implemented; disposable DDNS delivery is intentionally unavailable -until ownership/cleanup is added. See [DDNS design](cloudflare-ddns.md). +delivery and journaled disposable ownership/cleanup are implemented; live +disposable DNS acceptance awaits its test name entry. See +[DDNS design](cloudflare-ddns.md). ## Disposable credentials diff --git a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md index e303b0c2..4bc20097 100644 --- a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md +++ b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md @@ -2,15 +2,25 @@ Status: slices 1–3 and disposable test provisioning/cleanup implementation complete, 2026-10-06. Shared DDNS crate, caller selection, separate apply -phase/unit, validated private config, token use-or-create, production encrypted -delivery, and journaled test-only DNS/token ownership are implemented. Routine +phase/unit, validated rendered config, token use-or-create, production encrypted +delivery, embedded templates with typed KeePassXC references, and journaled +test-only DNS/token ownership are implemented. Routine and fake-provider tests cover cleanup. Live DDNS DNS ownership acceptance is -parked because the test vault has no `ddns-config` entry. The disposable VM was +parked until the test `ddns-dns-name` entry is configured. The disposable VM was created, reached signed boot, partially provisioned, and destroyed with its Tailscale identity removed; no DDNS token or DNS record was created. Syncthing live acceptance remains parked and remote CI evidence is pending. +Template review validation, 2026-10-06: six resolver tests cover environment +paths, complete host overrides, nested references, safe JSON string escaping, +malformed/missing references, and downstream DDNS schema validation. All 282 +workspace tests, formatting, pedantic Clippy, and the CI Fedora base container +scenario passed locally on the musl target. The container scenario confirmed +two successful applies with no service start/restart on repeat apply. These +checks used dummy values; live DDNS ownership acceptance remains parked. + Read `../AGENTS.md`, `../skillet/AGENTS.md`, `../design/cloudflare-ddns.md`, +`../design/configuration-templates.md`, `../design/secrets.md`, `../design/cloudflare-ui-lifecycle.md`, and `../design/smoke-vms.md`. Install nothing on the workstation. Use Rust resources/crates for orchestration and provider APIs; never embed shell programs. @@ -29,7 +39,7 @@ The live image version, polling cadence, full record/proxy configuration, and any second updater on Rupik need private inventory before production activation. The legacy `rupik::network` cron is not selected by the default include path. -## 1. Establish the private config and image contract +## 1. Establish the configuration template and image contract Implemented: upstream 2.2.0, index digest `sha256:5f2471be9efd9f0c95f973645cc87f05d501020ded94d380a2120fbfdf812d3d`. @@ -45,11 +55,11 @@ duplicates and unsupported policies. Private live inventory remains pending. `skillet/environments//dns/cloudflare-zone-id`. Verify the retained DDNS records actually belong to it; an additional zone needs an explicit schema extension, never automatic token-scope broadening. -- Planned host config entry: - `skillet/environments//hosts//cloudflare/ddns-config`. - Define a versioned JSON schema with relative record names and explicit approved - proxy state; initial address/TTL/purge policy matches Chef. Resolve record names - directly below the selected zone, independently of `dns/ui-domain`. +- The embedded workstation template defines versioned JSON with relative record + names and explicit proxy state; KeePassXC contains the individual + `skillet/environments//hosts//cloudflare/ddns-dns-name` + leaf. Resolve record names directly below the selected zone, independently of + `dns/ui-domain`. - Validate names, duplicate/conflicting records, and overlap with the host's UI records before token issuance or delivery. Existing unrelated records require an explicit takeover decision. Do not populate production values in this repo. @@ -72,7 +82,8 @@ Ordinary application acceptance excludes the separately provisioned DNS writer; values. Service/UI/credential eligibility derives from that same declaration. The host binary explicitly selects the shared DDNS crate/service; having an environment zone in KeePassXC must never implicitly enable it. Reuse the - existing environment zone entry, with private record names in `ddns-config`. + existing environment zone entry, with host/environment-specific values in + typed template references. - Add an independently credential-gated DDNS apply phase/unit, following the existing Caddy separation. Base/full convergence must continue to work before DDNS credentials exist; declaring the service must not add its credential to @@ -98,7 +109,7 @@ Routine CI requires no vault, external IP discovery, Cloudflare, or hypervisor. ## 3. Issue and deliver the DDNS credential Production implemented: `secret deliver ddns` uses the environment zone, -private `ddns-config` JSON and persistent `ddns-token`. Caddy and DDNS share the +rendered workstation template and persistent `ddns-token`. Caddy and DDNS share the atomic use-or-create helper. JSON may include `takeover_existing: true` to approve adopting an existing public A record; UI namespace and other Skillet ownership remain protected. New records receive a DDNS-specific ownership comment. @@ -142,7 +153,7 @@ the deletion set. DNS-policy compatible, leaves unrelated A/AAAA/CNAME/TXT records untouched, survives repeat apply/reboot, and reuses its credential. Test credential rotation and disposal without printing private values. Park live DDNS record checks - until `skillet/environments/test/hosts//cloudflare/ddns-config` is + until `skillet/environments/test/hosts//cloudflare/ddns-dns-name` is present in KeePassXC. Record results in `../butane/ACCEPTANCE.md`. - Before production: privately inventory Rupik's exact records, TTL/proxy state, diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index f2fd21d3..998dd7b4 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -27,7 +27,7 @@ production/network acceptance, and remaining rebase/bootstrap checks stay open. The [source parity checklist](CLAMPS-PARITY.md) records retained behavior and deliberate changes. [Cloudflare DDNS](CLAMPS-CLOUDFLARE-DDNS.md) has its guest service, production delivery, and disposable ownership/cleanup implementation. -Live disposable DNS acceptance awaits the private test config; production +Live disposable DNS acceptance awaits the private test DDNS name entry; production cutover remains separate. Datadog is retained by user decision; its port and UniFi backup/restore are the next implementation milestones. diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index ebe1ed98..323122c9 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -18,7 +18,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | btrbk | Caller-selected Syncthing snapshots, hourly timer, retention and restore accepted | Off-machine backup policy and physical cutover acceptance; nested source subvolumes unsupported | | UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply acceptance | Isolated backup restore, version compatibility, adoption and Site Manager access; see `CLAMPS-UNIFI.md` | | Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | -| Cloudflare DDNS | Caller-selected rootful updater, validated secret config, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Live disposable DNS acceptance (test config missing), private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | +| Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Live disposable DNS acceptance (pending test DDNS name entry), private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | | Datadog | Retained by user decision, 2026-10-06; not implemented | Port metrics/process/network monitoring and retained checks; replace nginx-specific check as appropriate. Later local metrics/Grafana is separate work | | EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | | Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | @@ -36,7 +36,7 @@ exists. Additional private run-list recipes remain an inventory question. ## Work order -1. Complete live disposable DDNS acceptance once the test-only config exists; +1. Complete live disposable DDNS acceptance once the test-only name entry exists; do not mutate production DNS. 2. Port Datadog, then handle isolated UniFi backup/restore planning and execution. 3. Close exit-node and remaining host-baseline gaps before cutover. diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index d57a78ea..b73d2b28 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -345,11 +345,17 @@ In KeePassXC's Password fields, set: - `skillet/environments/prod/dns/cloudflare-zone-id`: the existing environment zone ID. -- `skillet/environments/prod/hosts//cloudflare/ddns-config`: JSON such as - `{"version":1,"records":[{"name":"edge","proxied":false}]}`. Names are relative - to the zone itself. Use the approved record names and proxy state. +- `skillet/environments/prod/hosts//cloudflare/ddns-dns-name`: one + approved relative record name, without the zone suffix. - `skillet/cloudflare/token-creator`: the account token described above. +The clear JSON policy is in +`crates/workstation/src/configuration-templates.json`. Its shared DDNS template +reads the record name through a typed `$secret` reference. A host-specific +template in that catalog replaces the complete shared template when needed. +Change template policy in source and rebuild Skillet; do not store the JSON +configuration in KeePassXC. + Skillet creates `skillet/environments/prod/hosts//cloudflare/ddns-token` automatically. Close KeePassXC before delivery. With the prior DNS writer stopped and the selected record set approved, run: @@ -360,12 +366,14 @@ cargo run --release -p skillet -- secret deliver clamps ddns \ --known-hosts /path/to/known_hosts ``` -To adopt an existing public A record, add `"takeover_existing":true` to the -config JSON after reviewing it. Conflicting records and other Skillet owners +To adopt an existing public A record, set `takeover_existing` to `true` in the +source template after reviewing it. Conflicting records and other Skillet owners remain protected. Repeat delivery reuses the child token; edit its vault entry deliberately and redeliver for rotation. On the host, `systemctl start skillet-ddns-apply.service` reuses the encrypted credential. -Test-environment DDNS delivery is currently disabled. See the +Use `test vm provision --with-ddns` for disposable DDNS. Its +record/token ownership is cleaned up by `test vm destroy`; do not use direct +`secret deliver` with test policy. See the [DDNS design](../design/cloudflare-ddns.md) and [remaining lifecycle plan](../plan/CLAMPS-CLOUDFLARE-DDNS.md). diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 70aef568..92180b1a 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -19,7 +19,7 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { != skillet_workstation::provisioning_policy::Environment::Production { return Err(anyhow!( - "disposable DDNS is not enabled until record ownership and cleanup are implemented" + "test DDNS must be provisioned through an owned disposable VM" )); } let database = match &args.database { @@ -83,21 +83,19 @@ fn deliver_ddns_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Resul let policy = args.environment.policy(); if policy.environment() != skillet_workstation::provisioning_policy::Environment::Production { return Err(anyhow!( - "disposable DDNS is not enabled until record ownership and cleanup are implemented" + "test DDNS must be provisioned through an owned disposable VM" )); } let zone_path = policy.cloudflare_zone_entry(); let zone_id = vault .get(&zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; - let config_path = format!( - "skillet/environments/{}/hosts/{}/cloudflare/ddns-config", + let config = skillet_workstation::configuration_templates::render( + "ddns", + &args.hostname, policy.vault_name(), - args.hostname - ); - let config = vault - .get(&config_path)? - .ok_or_else(|| anyhow!("KeePassXC DDNS config is missing: {config_path}"))?; + vault, + )?; let creator = vault .get("skillet/cloudflare/token-creator")? .ok_or_else(|| anyhow!("KeePassXC Cloudflare token creator is missing"))?; @@ -402,14 +400,12 @@ fn provision_vm_ddns( let zone_id = vault .get(&zone_path)? .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; - let config_path = format!( - "skillet/environments/{}/hosts/{}/cloudflare/ddns-config", + let config = skillet_workstation::configuration_templates::render( + "ddns", + &args.hostname, policy.vault_name(), - args.hostname - ); - let config = vault - .get(&config_path)? - .ok_or_else(|| anyhow!("KeePassXC DDNS config is missing: {config_path}"))?; + vault, + )?; let creator = vault .get("skillet/cloudflare/token-creator")? .ok_or_else(|| anyhow!("KeePassXC Cloudflare token creator is missing"))?; diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 222e4d9e..445d0513 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -28,7 +28,9 @@ fn ddns_commands_parse_and_test_delivery_refuses_before_vault_access() { panic!("wrong parsed command"); }; let error = super::deliver_from_vault(&args).unwrap_err(); - assert!(error.to_string().contains("disposable DDNS is not enabled")); + assert!(error + .to_string() + .contains("test DDNS must be provisioned through an owned disposable VM")); assert!(crate::Args::try_parse_from([ "skillet", "apply", "--host", "clamps", "--phase", "ddns" ]) diff --git a/ublue/skillet/crates/workstation/src/configuration-templates.json b/ublue/skillet/crates/workstation/src/configuration-templates.json new file mode 100644 index 00000000..6f1f949c --- /dev/null +++ b/ublue/skillet/crates/workstation/src/configuration-templates.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "services": { + "ddns": { + "default": { + "version": 1, + "records": [ + { + "name": { + "$secret": "skillet/environments/{environment}/hosts/{host}/cloudflare/ddns-dns-name" + }, + "proxied": false + } + ], + "takeover_existing": false + }, + "hosts": {} + } + } +} diff --git a/ublue/skillet/crates/workstation/src/configuration_templates.rs b/ublue/skillet/crates/workstation/src/configuration_templates.rs new file mode 100644 index 00000000..e7b6b45d --- /dev/null +++ b/ublue/skillet/crates/workstation/src/configuration_templates.rs @@ -0,0 +1,154 @@ +//! Workstation-side configuration templates with explicit `KeePassXC` references. +use crate::vault::{Vault, VaultError}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::BTreeMap; +use thiserror::Error; + +const TEMPLATE_CATALOG: &str = include_str!("configuration-templates.json"); + +#[derive(Debug, Error)] +pub enum ConfigurationTemplateError { + #[error("configuration template catalog is invalid: {0}")] + Catalog(#[source] serde_json::Error), + #[error("could not serialize rendered configuration")] + Serialization(#[source] serde_json::Error), + #[error("unsupported configuration template catalog version")] + Version, + #[error("no configuration template registered for service {service} and host {host}")] + MissingTemplate { service: String, host: String }, + #[error("host or environment is not a valid template path component")] + InvalidContext, + #[error("invalid secret reference in configuration template")] + InvalidReference, + #[error("KeePassXC entry is missing: {0}")] + MissingSecret(String), + #[error(transparent)] + Vault(#[from] VaultError), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TemplateCatalog { + version: u32, + services: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ServiceTemplates { + default: Value, + #[serde(default)] + hosts: BTreeMap, +} + +/// Render the selected service template and resolve typed references through +/// `KeePassXC`. A host-specific template replaces the complete shared template. +/// Secret values are inserted as JSON strings and are never interpolated into +/// surrounding text. +pub fn render( + service: &str, + host: &str, + environment: &str, + vault: &Vault, +) -> Result { + render_catalog(TEMPLATE_CATALOG, service, host, environment, &|path| { + vault.get(path) + }) +} + +fn render_catalog( + catalog: &str, + service: &str, + host: &str, + environment: &str, + lookup: &impl Fn(&str) -> Result, VaultError>, +) -> Result { + if !valid_component(host) || !valid_component(environment) { + return Err(ConfigurationTemplateError::InvalidContext); + } + let catalog: TemplateCatalog = + serde_json::from_str(catalog).map_err(ConfigurationTemplateError::Catalog)?; + if catalog.version != 1 { + return Err(ConfigurationTemplateError::Version); + } + let service_templates = catalog.services.get(service).ok_or_else(|| { + ConfigurationTemplateError::MissingTemplate { + service: service.to_string(), + host: host.to_string(), + } + })?; + let mut value = service_templates + .hosts + .get(host) + .unwrap_or(&service_templates.default) + .clone(); + resolve_value(&mut value, host, environment, lookup)?; + serde_json::to_string(&value).map_err(ConfigurationTemplateError::Serialization) +} + +fn resolve_value( + value: &mut Value, + host: &str, + environment: &str, + lookup: &impl Fn(&str) -> Result, VaultError>, +) -> Result<(), ConfigurationTemplateError> { + match value { + Value::Object(fields) => { + if let Some(reference) = fields.get("$secret") { + if fields.len() != 1 { + return Err(ConfigurationTemplateError::InvalidReference); + } + let Value::String(reference) = reference else { + return Err(ConfigurationTemplateError::InvalidReference); + }; + let path = resolve_entry_path(reference, host, environment)?; + let secret = lookup(&path)? + .ok_or_else(|| ConfigurationTemplateError::MissingSecret(path.clone()))?; + *value = Value::String(secret); + } else { + for child in fields.values_mut() { + resolve_value(child, host, environment, lookup)?; + } + } + } + Value::Array(values) => { + for child in values { + resolve_value(child, host, environment, lookup)?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + Ok(()) +} + +fn resolve_entry_path( + reference: &str, + host: &str, + environment: &str, +) -> Result { + let path = reference + .replace("{host}", host) + .replace("{environment}", environment); + if !path.starts_with("skillet/") + || path.contains('{') + || path.contains('}') + || path + .split('/') + .any(|component| component.is_empty() || component == "." || component == "..") + { + return Err(ConfigurationTemplateError::InvalidReference); + } + Ok(path) +} + +fn valid_component(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +#[cfg(test)] +#[path = "configuration_templates/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/configuration_templates/tests.rs b/ublue/skillet/crates/workstation/src/configuration_templates/tests.rs new file mode 100644 index 00000000..60e93de8 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/configuration_templates/tests.rs @@ -0,0 +1,127 @@ +use super::*; +use serde_json::json; +use std::cell::Cell; + +fn catalog(default: &Value, hosts: &Value) -> String { + json!({"version": 1, "services": {"example": {"default": default, "hosts": hosts}}}).to_string() +} + +#[test] +fn embedded_ddns_template_resolves_each_environment_and_validates_consumer_schema() { + for environment in ["prod", "test"] { + let expected = + format!("skillet/environments/{environment}/hosts/clamps/cloudflare/ddns-dns-name"); + let rendered = render_catalog(TEMPLATE_CATALOG, "ddns", "clamps", environment, &|path| { + assert_eq!(path, expected); + Ok(Some("edge".into())) + }) + .unwrap(); + let config = skillet_ddns::config::PrivateConfig::parse(&rendered).unwrap(); + assert_eq!(config.records.len(), 1); + assert_eq!(config.records[0].name, "edge"); + assert!(!config.records[0].proxied); + assert!(!config.takeover_existing); + } +} + +#[test] +fn host_override_replaces_default_without_merging_or_reading_default_secrets() { + let source = catalog( + &json!({"default_only": {"$secret": "skillet/missing"}, "records": [1, 2]}), + &json!({"clamps": {"records": [3]}}), + ); + let rendered = render_catalog(&source, "example", "clamps", "test", &|_| { + panic!("replaced defaults must not access the vault") + }) + .unwrap(); + assert_eq!( + serde_json::from_str::(&rendered).unwrap(), + json!({"records": [3]}) + ); +} + +#[test] +fn nested_secrets_are_escaped_strings_and_never_evaluated_recursively() { + let secret = "\"\\\n{\"$secret\":\"skillet/other\"}"; + let source = catalog( + &json!({"values": [{"nested": {"$secret": "skillet/{environment}/{host}/value"}}], + "literal": "secret{skillet/value} {host}"}), + &json!({}), + ); + let reads = Cell::new(0); + let rendered = render_catalog(&source, "example", "clamps", "test", &|path| { + assert_eq!(path, "skillet/test/clamps/value"); + reads.set(reads.get() + 1); + Ok(Some(secret.into())) + }) + .unwrap(); + let result: Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(result["values"][0]["nested"], secret); + assert_eq!(result["literal"], "secret{skillet/value} {host}"); + assert_eq!(reads.get(), 1); +} + +#[test] +fn malformed_references_fail_before_lookup() { + for marker in [ + json!({"$secret": 12}), + json!({"$secret": "skillet/value", "extra": true}), + json!({"$secret": "elsewhere/value"}), + json!({"$secret": "skillet/{instance}/value"}), + json!({"$secret": "skillet//value"}), + json!({"$secret": "skillet/../value"}), + json!({"$secret": "skillet/./value"}), + ] { + let source = catalog(&marker, &json!({})); + assert!(matches!( + render_catalog(&source, "example", "clamps", "test", &|_| { + panic!("invalid references must not reach the vault") + }), + Err(ConfigurationTemplateError::InvalidReference) + )); + } +} + +#[test] +fn invalid_context_and_catalog_fail_before_lookup() { + for (host, environment) in [("../clamps", "test"), ("clamps", ""), ("clamps", "test/x")] { + assert!(matches!( + render_catalog(TEMPLATE_CATALOG, "ddns", host, environment, &|_| { + panic!("invalid context must not reach the vault") + }), + Err(ConfigurationTemplateError::InvalidContext) + )); + } + assert!(matches!( + render_catalog( + "{\"version\":2,\"services\":{}}", + "ddns", + "clamps", + "test", + &|_| Ok(None) + ), + Err(ConfigurationTemplateError::Version) + )); + assert!(matches!( + render_catalog(TEMPLATE_CATALOG, "unknown", "clamps", "test", &|_| Ok(None)), + Err(ConfigurationTemplateError::MissingTemplate { .. }) + )); +} + +#[test] +fn missing_entries_fail_and_invalid_secret_values_still_require_consumer_validation() { + let source = catalog(&json!({"$secret": "skillet/missing"}), &json!({})); + assert!(matches!( + render_catalog(&source, "example", "clamps", "test", &|_| Ok(None)), + Err(ConfigurationTemplateError::MissingSecret(path)) if path == "skillet/missing" + )); + let secret = "private.invalid/record"; + let rendered = render_catalog(TEMPLATE_CATALOG, "ddns", "clamps", "test", &|_| { + Ok(Some(secret.into())) + }) + .unwrap(); + let error = skillet_ddns::config::PrivateConfig::parse(&rendered) + .err() + .unwrap(); + assert!(!error.to_string().contains(secret)); +} diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index 47defe67..4525dbd8 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -1,6 +1,7 @@ //! Workstation-only provisioning capabilities. pub mod cloudflare; +pub mod configuration_templates; pub mod credential_delivery; pub mod ddns_provisioning; pub mod provisioning_policy; From ce6878315777d6b40a76fa66c3749f1012e95f39 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 7 Oct 2026 14:56:49 +0200 Subject: [PATCH 209/252] feat(datadog): add optional rootful agent and service monitoring --- ublue/AGENTS.md | 3 + ublue/butane/ACCEPTANCE.md | 23 ++ ublue/butane/includes/skillet.bu | 17 ++ ublue/design/configuration-templates.md | 5 + ublue/design/monitoring.md | 70 ++++- ublue/design/secrets.md | 8 + ublue/plan/CLAMPS-DATADOG.md | 121 ++++++++ ublue/plan/CLAMPS-PARITY.md | 2 +- ublue/skillet/Cargo.lock | 18 ++ ublue/skillet/Cargo.toml | 2 + ublue/skillet/README.md | 23 ++ ublue/skillet/crates/caddy/Cargo.toml | 1 + ublue/skillet/crates/caddy/src/lib.rs | 1 + ublue/skillet/crates/cli-common/src/hosts.rs | 2 + ublue/skillet/crates/cli-common/src/lib.rs | 1 + ublue/skillet/crates/cli/src/main.rs | 8 +- .../skillet/crates/cli/src/secret_delivery.rs | 50 +++- .../crates/cli/src/secret_delivery/tests.rs | 48 ++++ ublue/skillet/crates/datadog/Cargo.toml | 17 ++ .../skillet/crates/datadog/examples/agent.rs | 29 ++ .../crates/datadog/examples/autodiscovery.rs | 8 + ublue/skillet/crates/datadog/src/lib.rs | 40 +++ ublue/skillet/crates/datadog/src/runtime.rs | 263 ++++++++++++++++++ .../crates/datadog/src/runtime/tests.rs | 112 ++++++++ ublue/skillet/crates/datadog/src/tests.rs | 31 +++ ublue/skillet/crates/ddns/Cargo.toml | 1 + ublue/skillet/crates/ddns/src/lib.rs | 5 +- ublue/skillet/crates/host-profiles/Cargo.toml | 1 + .../crates/host-profiles/src/hosts_tests.rs | 45 ++- ublue/skillet/crates/host-profiles/src/lib.rs | 54 +++- .../crates/host-profiles/src/profile.rs | 28 +- ublue/skillet/crates/pihole/Cargo.toml | 1 + ublue/skillet/crates/pihole/src/lib.rs | 1 + ublue/skillet/crates/syncthing/Cargo.toml | 1 + ublue/skillet/crates/syncthing/src/lib.rs | 1 + ublue/skillet/crates/unifi/Cargo.toml | 1 + ublue/skillet/crates/unifi/src/lib.rs | 1 + ublue/skillet/crates/workstation/Cargo.toml | 1 + .../src/configuration-templates.json | 13 + .../workstation/src/credential_delivery.rs | 24 ++ .../src/credential_delivery/tests.rs | 22 ++ 41 files changed, 1079 insertions(+), 24 deletions(-) create mode 100644 ublue/plan/CLAMPS-DATADOG.md create mode 100644 ublue/skillet/crates/datadog/Cargo.toml create mode 100644 ublue/skillet/crates/datadog/examples/agent.rs create mode 100644 ublue/skillet/crates/datadog/examples/autodiscovery.rs create mode 100644 ublue/skillet/crates/datadog/src/lib.rs create mode 100644 ublue/skillet/crates/datadog/src/runtime.rs create mode 100644 ublue/skillet/crates/datadog/src/runtime/tests.rs create mode 100644 ublue/skillet/crates/datadog/src/tests.rs diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 1cc86d33..b5a1e9d3 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -42,6 +42,9 @@ declaration. Shared baseline services retain their common defaults; the presence of environment configuration or credentials must not enable an optional service implicitly. +- Service modules own their monitoring declarations. Monitoring consumes the + same selected services as application composition; do not maintain an + independent monitoring inventory or place credentials in discovery metadata. - Shared helpers receive identity and configuration from validated inputs or declarations. Keep deployment-specific assumptions in the selected profile; diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index cd7f2020..d718b921 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,28 @@ # Clamps VM acceptance log +## Datadog offline validation, 2026-10-07 + +- Approved rootful Agent recipe and delivery implemented; Clamps selects it + separately from ordinary full apply. No live telemetry was enabled here. +- Formatting, pedantic Clippy, all 291 workspace tests, the Fedora base + integration scenario (two applies, repeat with no service activation), and + all eight Butane bootstrap regressions passed on the musl target. +- The installed Quadlet generator accepted the actual Agent recipe emitted by + the offline `skillet_datadog` `agent` example. Generated ExecStart retains + host PID/cgroup/network namespaces, optional eBPF capabilities, read-only + mounts without host relabeling, and native Podman environment-secret + references. No literal API key or published ports appear in the Quadlet. +- Unit regressions cover input validation, storage prerequisites, independent + credential phase, environment tags, stdin-only delivery, repeat apply, + secret rotation, and retry after a failed restart. +- Official pinned image metadata reports Agent 7.84.1. The image's startup + requires `DD_API_KEY`; native Podman environment-secret injection is used. +- Live named-VM Agent acceptance is unverified: `/dev/kvm` is absent on this + workstation. Agent status/version, socket discovery, actual host/Btrfs + observations, systemd/SSH checks, eBPF under uCore Secure Boot/SELinux, + listener exposure, reboot recovery, and Datadog ingestion still need tests + with test-environment API-key/site leaves. No vault unlock was attempted. + ## Disposable DDNS attempt and cleanup, 2026-10-06 - Built the current Skillet CLI with Rust 1.99 and created a separate diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index a0159c28..51e182b3 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -57,5 +57,22 @@ systemd: LoadCredentialEncrypted=cloudflare_ddns_config:/etc/credstore.encrypted/skillet/cloudflare_ddns_config.cred ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase ddns + [Install] + WantedBy=multi-user.target + - name: skillet-datadog-apply.service + enabled: true + contents: | + [Unit] + Description=Apply Skillet optional Datadog monitoring configuration + Requires=skillet-apply.service skillet-data-prepare.service + After=skillet-apply.service skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data + ConditionPathExists=/etc/credstore.encrypted/skillet/datadog_config.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=datadog_config:/etc/credstore.encrypted/skillet/datadog_config.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase datadog + [Install] WantedBy=multi-user.target diff --git a/ublue/design/configuration-templates.md b/ublue/design/configuration-templates.md index b4181642..9285726c 100644 --- a/ublue/design/configuration-templates.md +++ b/ublue/design/configuration-templates.md @@ -35,3 +35,8 @@ For DDNS, move `name` to `ddns-dns-name`; keep `proxied` and `takeover_existing` in the template. Multi-record hosts can provide a complete host override with one typed reference per record. Remove the obsolete whole document entry after the new template and leaf values have been validated. + +Datadog uses the same catalog to resolve environment-level `datadog/api-key` +and `datadog/site` leaves, with optional host overrides/private tags. Delivery +adds the selected environment tag and rejects a conflicting one; credentials +alone never select the Agent capability. diff --git a/ublue/design/monitoring.md b/ublue/design/monitoring.md index 57bfa008..86a0ba40 100644 --- a/ublue/design/monitoring.md +++ b/ublue/design/monitoring.md @@ -1,10 +1,64 @@ -# Fleet monitoring +# Monitoring composition -Decision, 2026-10-06: keep Datadog when migrating Rupik to Clamps. Preserve the -required host, process, network, filesystem, and service checks after auditing -the effective Chef configuration. Caddy replaces nginx, so nginx-specific -monitoring cannot be copied unchanged. +Keep Datadog for the Rupik-to-Clamps migration, retaining required host, +process, network, filesystem, and service checks. Caddy replaces nginx, so its +checks need a different implementation. Local metrics/Grafana remain a later +discussion outside this migration. -Local metrics and Grafana may be added later; that decision and implementation -are outside the current migration. Datadog implementation is pending and follows -the DDNS milestone. Keep credentials and private check targets in the vault. +Decision, 2026-10-06: services own their monitoring declarations. Datadog is an +optional host capability; the agent consumes the checks belonging to the +services selected by the canonical host caller. Adding or removing a service +must also add or remove its monitoring without a second service inventory. + +Container modules declare credential-free Datadog Autodiscovery labels. The +shared Datadog library renders and escapes those labels. Checks use discovered +container addresses, so private UI ports stay unpublished. Container/process +liveness and application correctness are different checks; process presence +does not establish DNS publication, synchronization, or controller health. +Non-container services will contribute explicit systemd units to the host check. + +Approved agent deployment, 2026-10-07: a pinned official rootful Agent container managed by +Skillet. Datadog recommends container deployment for Podman over native host +installation, which requires additional permission handling. The OS image +continues to provide Podman and kernel tooling; Skillet owns activation, +credentials, and changing integration configuration. Community integrations, +if required, belong in a separately built agent image, not runtime downloads. + +Implemented agent runtime and credential delivery. Host monitoring +needs host PID/cgroup visibility, read-only proc/cgroup/filesystem mounts, and +SELinux access. Podman Autodiscovery through the rootful API socket grants +container-management authority even with a read-only socket bind mount. +Preserving Chef's network/service monitoring additionally needs the +system-probe's eBPF capabilities and debugfs access. Those privileges must be +explicit caller policy and tested on uCore with its Secure Boot/SELinux settings. + +The agent has a separate credential-gated apply phase. Installing Skillet +or declaring a monitored service must not start telemetry without the Datadog +capability and credentials. Use configuration templates to combine public +policy with environment API-key/site leaves in KeePassXC. The application key +used by Chef's handler is unnecessary for metric submission; future dashboard +or monitor management would be a separate workstation responsibility. Private +tags, endpoint credentials, and ping targets follow the existing encrypted +delivery path and must not appear in labels or recorded files. + +Host networking preserves host interface visibility and localhost SSH checks; +Autodiscovery reaches container bridge addresses without published UI ports. +The pinned official image requires `DD_API_KEY` at startup, so Podman injects +API key, site, and private tags through native environment secrets. No literal +secret-bearing `Environment=` directives are written. Production and test use +different vault leaves; delivery adds an authoritative environment tag. Test +telemetry requires explicit `--with-datadog`; destroying a VM does not remove +historical telemetry from Datadog. + +Implemented: labels for Syncthing HTTP health and process liveness for +Pi-hole, DDNS, UniFi, Caddy, and Tailscale; Agent deployment/delivery and +host/process/network/Btrfs/SSH/systemd configuration. Pending: live Agent +acceptance, private ping targets, richer +Syncthing metrics and Pi-hole's authenticated API integration. The existing +Pi-hole community integration requires compatibility review against Pi-hole 6. + +Sources: [Podman deployment](https://docs.datadoghq.com/containers/guide/podman-support-with-docker-integration/), +[Autodiscovery](https://docs.datadoghq.com/containers/docker/integrations/), +[network monitoring](https://docs.datadoghq.com/network_monitoring/cloud_network_monitoring/setup/), +[Btrfs](https://docs.datadoghq.com/integrations/btrfs/), +[systemd](https://docs.datadoghq.com/integrations/systemd/). diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 68e092ab..3de464d5 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -163,6 +163,14 @@ expiry, and owned record IDs in run metadata, without token values. Existing VM credentials survive re-apply and reboot. Expired tokens require explicit renewal; cleanup and recovery belong to the [VM lifecycle](smoke-vms.md). +Optional Datadog delivery resolves environment `datadog/api-key` and +`datadog/site` leaves through the shared templates. A single `datadog_config` +credential uses the existing encrypted SSH/systemd path and independently +activates the Agent phase. The Agent consumes native Podman environment secrets +for API key, site, and tags; none are written into Quadlet `Environment=` values. +Disposable delivery is explicit and uses test credentials plus an authoritative +environment tag. VM destruction does not delete historical Datadog telemetry. + References: [KeePassXC](https://keepassxc.org/docs/KeePassXC_GettingStarted), [Rust KDBX reader](https://github.com/sseemayer/keepass-rs), [Cloudflare token creation](https://developers.cloudflare.com/fundamentals/api/how-to/create-via-api/), diff --git a/ublue/plan/CLAMPS-DATADOG.md b/ublue/plan/CLAMPS-DATADOG.md new file mode 100644 index 00000000..37051f55 --- /dev/null +++ b/ublue/plan/CLAMPS-DATADOG.md @@ -0,0 +1,121 @@ +# Datadog port + +Status, 2026-10-07: user approved the rootful Podman runtime. Service-owned +labels, optional Agent recipe, separate apply phase, templates, and shared +encrypted delivery are implemented. No workstation agent was installed. +Live uCore/telemetry acceptance remains pending; see `../butane/ACCEPTANCE.md`. + +Validated, 2026-10-07: 291 workspace tests, pedantic Clippy, formatting, +Fedora base integration, eight bootstrap regressions, and actual Agent Quadlet +generation passed. Live acceptance is blocked here by absent `/dev/kvm`. +Next: supply test environment API-key/site leaves, then execute section 4's +named-VM Agent checks on a workstation with KVM. Private ping targets and richer +application metrics remain additional implementation work. + +Read `../AGENTS.md`, `../skillet/AGENTS.md`, `../design/monitoring.md`, +`../design/secrets.md`, and `../design/configuration-templates.md`. +Install nothing on the workstation. Keep routine CI offline and vault-free. + +## 1. Module declarations + +Implemented: shared `skillet_datadog` Autodiscovery renderer, consumed by each +container recipe. Syncthing owns an internal unauthenticated health probe; +Pi-hole, DDNS, UniFi, Caddy, and Tailscale own process-liveness declarations. +Labels contain public configuration only. They are inert without an agent. + +Local validation, 2026-10-06: all 284 workspace tests, pedantic Clippy and +formatting passed on the musl target. The installed Quadlet generator accepted +the passive fixture and retained `%%%%host%%%%` in ExecStart, which systemd +expands to Datadog's `%%host%%`. The CI Fedora base scenario passed two applies +without service start/restart on repeat apply. No live agent checks or telemetry +ingestion were exercised. + +- Validate labels through the actual Quadlet generator, especially escaping of + Datadog `%%host%%` variables through generated systemd ExecStart specifiers. +- Cover JSON quoting and actual service declarations in CI. +- Keep discovery independent of fixed container IPs or publicly exposed UIs. + +Exit: renderer tests and actual Quadlet generation preserve the JSON and +Autodiscovery placeholders; relevant local CI checks pass. + +## 2. Agent and host profile + +Implemented: official Agent container as an optional canonical host service. +Clamps opts in. Agent image index evaluated for this port: +`registry.datadoghq.com/agent@sha256:161a43ad2b290f7527a70e70c8880c1b3d65b39411552a8cb41398049546daf5`. +Pinned metadata reports Agent 7.84.1; runtime version still needs live acceptance. + +Reviewable runtime boundary: + +- Rootful Agent with host PID/cgroup namespaces and host networking for + container HTTP probes; no published Agent, DogStatsD, or APM ports. +- Rootful `/run/podman/podman.sock` mounted read-only for supported Docker API + Autodiscovery. Enable/start `podman.socket` only for the opted-in agent. The + socket can mutate containers despite the read-only bind mount. +- Read-only `/proc`, `/sys/fs/cgroup`, `/run/systemd`, and host filesystem mounts + for process, Btrfs, disk, and systemd data. Never relabel host paths with `Z`. +- SELinux label separation disabled for this monitoring container if required + by the evaluated runtime; record the reason and actual uCore test evidence. +- Explicit `network_monitoring` caller policy. To retain Chef's eBPF network and + service monitoring, add the documented system-probe capabilities: + `SYS_ADMIN SYS_RESOURCE SYS_PTRACE NET_ADMIN NET_BROADCAST NET_RAW IPC_LOCK CHOWN` + and required debugfs access. Do not enable unrelated APM/log collection. + +Use the Podman-socket backend first. Do not infer the standard Podman graphroot: +this fleet stores containers under `/var/lib/data`, and any DB-backend alternative +must use the authoritative storage configuration. Verify bridge-network probes +and eBPF packet visibility before claiming parity. + +Exit: optional eligibility, required data mount, generated privileges/mounts, +no plaintext credentials, no-op repeat apply, secret rotation, and failed-start +recovery are covered by local tests. Ordinary base/full apply and smoke do not +require Datadog credentials or start telemetry. + +## 3. Configuration and delivery + +Implemented: template, schema validation, native Podman environment secrets, +separate credential-gated phase, production delivery, explicit disposable +enrollment, and authoritative `env:prod`/`env:test` tags. The official image's +startup requires `DD_API_KEY`; file-secret injection is not used. Live delivery +and ingestion remain unverified. + +- Add a public `datadog` template with typed references to planned vault leaves + `skillet/environments//datadog/api-key` and + `skillet/environments//datadog/site`. Host overrides can select + a different API key and private tags. Do not require an application key for + ingestion. Version and validate the rendered payload before delivery. +- Add a shared `--phase datadog`, `skillet-datadog-apply.service`, and + `datadog_config` encrypted credential. Production command: + `secret deliver datadog`. Use existing verified stdin-only transport. +- Mount API keys as native Podman secrets, preferably files if supported by the + pinned image; native secret environment injection is an explicit supported + fallback. Never use literal `Environment=DD_API_KEY=...`. +- Disposable enrollment is explicit through `--with-datadog`, with test-only + credentials and the actual VM hostname; record that remote telemetry/host + retention is distinct from VM/token cleanup. Do not reuse production keys + silently. Use dummy credentials and disabled networking for offline checks. + +Exit: successful template validation, deferred activation, rotation, and missing +credential handling; no vault/API requirement in routine CI. + +## 4. Checks and acceptance + +- Agent owns shared host system/process/network/Btrfs metrics and localhost SSH + TCP check. Aggregate systemd unit contributions from the canonical service + declarations, including Btrbk's timer; do not maintain a second service list. +- Configure private ping targets through templates and reviewed integration + dependencies. Preserve retained Chef tags privately after inventory. +- Verify Syncthing's no-auth health endpoint on the deployed image. Later add + synchronization metrics through a reviewed supported integration or exporter; + do not claim that HTTP health establishes folder/device synchronization. +- Review Pi-hole 6 authentication and community-check compatibility before + installing it. DNS-query checks and API statistics must have separate evidence. +- DDNS process liveness is insufficient to prove publication; retain its + separately parked ownership/publication acceptance. +- Named VM: verify Agent status, detected containers, check success, host rather + than container metrics, Btrfs data, systemd units, eBPF under Secure Boot, + no-op/reboot recovery, and explicit test ingestion. Record results in + `../butane/ACCEPTANCE.md`; report unavailable checks honestly. + +Exit: retained Chef capabilities and deliberate changes reconciled in parity +docs. Rich application checks remain explicit if acceptance is unfinished. diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 323122c9..2f5984e9 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -19,7 +19,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply acceptance | Isolated backup restore, version compatibility, adoption and Site Manager access; see `CLAMPS-UNIFI.md` | | Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | | Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Live disposable DNS acceptance (pending test DDNS name entry), private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | -| Datadog | Retained by user decision, 2026-10-06; not implemented | Port metrics/process/network monitoring and retained checks; replace nginx-specific check as appropriate. Later local metrics/Grafana is separate work | +| Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live uCore/ingestion acceptance and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | | EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | | Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | | Users, SSH, sudo, shell/dotfiles | Generic user environment/bootstrap and hardening exist; user acceptance previously passed | Compare additional Chef-managed accounts, permissions, and relevant hardening settings. Dotfiles install hook was explicitly deferred | diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 2241bb57..fb515784 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2348,6 +2348,7 @@ dependencies = [ "serde", "serde_json", "skillet_core", + "skillet_datadog", "skillet_podman", "thiserror 1.0.69", ] @@ -2384,6 +2385,17 @@ dependencies = [ "zbus", ] +[[package]] +name = "skillet_datadog" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "skillet_core", + "skillet_podman", + "thiserror 1.0.69", +] + [[package]] name = "skillet_ddns" version = "0.1.0" @@ -2391,6 +2403,7 @@ dependencies = [ "serde", "serde_json", "skillet_core", + "skillet_datadog", "skillet_podman", "thiserror 1.0.69", ] @@ -2414,6 +2427,7 @@ dependencies = [ "skillet_btrbk", "skillet_caddy", "skillet_core", + "skillet_datadog", "skillet_ddns", "skillet_hardening", "skillet_pihole", @@ -2429,6 +2443,7 @@ version = "0.1.0" dependencies = [ "askama", "skillet_core", + "skillet_datadog", "skillet_podman", "thiserror 1.0.69", "tracing", @@ -2453,6 +2468,7 @@ name = "skillet_syncthing" version = "0.1.0" dependencies = [ "skillet_core", + "skillet_datadog", "skillet_podman", "thiserror 1.0.69", "tracing", @@ -2463,6 +2479,7 @@ name = "skillet_unifi" version = "0.1.0" dependencies = [ "skillet_core", + "skillet_datadog", "skillet_podman", "thiserror 1.0.69", "tracing", @@ -2500,6 +2517,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "skillet_caddy", + "skillet_datadog", "skillet_ddns", "skillet_hosts", "skillet_vm", diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 930448c3..ca82a9ae 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/btrbk", "crates/caddy", "crates/ddns", + "crates/datadog", "crates/podman", "crates/cli", "crates/hosts/beezelbot", @@ -29,6 +30,7 @@ skillet_syncthing = { path = "crates/syncthing" } skillet_btrbk = { path = "crates/btrbk" } skillet_caddy = { path = "crates/caddy" } skillet_ddns = { path = "crates/ddns" } +skillet_datadog = { path = "crates/datadog" } skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } skillet_vm = { path = "crates/vm" } diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index b73d2b28..407b096f 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -1,5 +1,28 @@ # Skillet +Monitoring composition and Datadog Agent acceptance are described +in the [monitoring design](../design/monitoring.md) and +[Datadog plan](../plan/CLAMPS-DATADOG.md). Container modules currently emit +credential-free Autodiscovery labels. The optional Agent uses a separate +credential-gated apply phase. + +## Datadog + +For an opted-in host, create these KeePassXC entries (Password field): + +- `skillet/environments/prod/datadog/api-key`: Datadog ingestion API key. +- `skillet/environments/prod/datadog/site`: site such as `datadoghq.eu`. + +Use `test` instead of `prod` for disposable telemetry. No application key is +required. The `datadog` configuration template supports host overrides and +private tags using the shared configuration-template pattern. + +Deliver using `skillet secret deliver datadog` with the usual SSH +identity, known-hosts, and target options. For an owned disposable VM, use +`skillet test vm provision --with-datadog`. Unlock the vault +through the existing `secret unlock` workflow. Ordinary provisioning and smoke +do not enable Datadog. Historical telemetry remains after VM destruction. + Skillet is a Rust tool for idempotent host configuration management. Design decisions: [secret storage and delivery](../design/secrets.md) and diff --git a/ublue/skillet/crates/caddy/Cargo.toml b/ublue/skillet/crates/caddy/Cargo.toml index fe00f271..3e27bd82 100644 --- a/ublue/skillet/crates/caddy/Cargo.toml +++ b/ublue/skillet/crates/caddy/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_datadog.workspace = true skillet_core.workspace = true skillet_podman.workspace = true serde.workspace = true diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index aff62683..c56a4eac 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -296,6 +296,7 @@ where vec![ "AutoUpdate=registry".to_string(), "ContainerName=caddy".to_string(), + skillet_datadog::process("caddy", "caddy run"), ], ); container.insert("Service".to_string(), vec!["Restart=always".to_string()]); diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index fd250e6a..06ebeb35 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -16,6 +16,7 @@ pub enum ApplyPhase { Full, Caddy, Ddns, + Datadog, } pub fn apply_host_phase( @@ -30,6 +31,7 @@ pub fn apply_host_phase( ApplyPhase::Full => skillet_hosts::HostApplyPhase::Full, ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, ApplyPhase::Ddns => skillet_hosts::HostApplyPhase::Ddns, + ApplyPhase::Datadog => skillet_hosts::HostApplyPhase::Datadog, }; skillet_hosts::apply_host_phase(hostname, phase, system, files, credentials) } diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 88f4fc0e..6d00d496 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -156,6 +156,7 @@ where ApplyPhase::Full => skillet_hosts::HostApplyPhase::Full, ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, ApplyPhase::Ddns => skillet_hosts::HostApplyPhase::Ddns, + ApplyPhase::Datadog => skillet_hosts::HostApplyPhase::Datadog, }; let required = skillet_hosts::credentials_for_phase(hostname, phase) .map_err(|error| CliCommonError::Config(error.to_string()))?; diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 11b416c9..5cc1781f 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -70,7 +70,7 @@ struct SecretLockArgs { #[derive(clap::Args, Debug)] struct SecretDeliverArgs { hostname: String, - #[arg(value_parser = ["pihole", "tailscale", "caddy", "ddns"])] + #[arg(value_parser = ["pihole", "tailscale", "caddy", "ddns", "datadog"])] service: String, #[arg(long)] database: Option, @@ -178,6 +178,8 @@ struct VmDestroyArgs { } #[derive(clap::Args, Debug)] +// These CLI switches select independent optional provisioning operations. +#[allow(clippy::struct_excessive_bools)] struct VmProvisionArgs { hostname: String, instance: String, @@ -187,6 +189,9 @@ struct VmProvisionArgs { /// Provision public DDNS records from test-environment `KeePassXC` config #[arg(long)] with_ddns: bool, + /// Send test-environment Datadog credentials and enable test VM telemetry + #[arg(long)] + with_datadog: bool, #[arg(long)] database: Option, #[arg(long)] @@ -1209,6 +1214,7 @@ fn run_twice_and_check(name: &str, phase: ApplyPhase, inspect: bool) -> Result<( ApplyPhase::Full => "full", ApplyPhase::Caddy => "caddy", ApplyPhase::Ddns => "ddns", + ApplyPhase::Datadog => "datadog", }; for round in ["first", "second"] { let status = Command::new("podman") diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 92180b1a..4c561b96 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -14,12 +14,17 @@ use std::path::Path; pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { validate_delivery_service(&args.hostname, &args.service)?; - if args.service == "ddns" + if matches!(args.service.as_str(), "ddns" | "datadog") && args.environment.policy().environment() != skillet_workstation::provisioning_policy::Environment::Production { + let service = if args.service == "ddns" { + "DDNS" + } else { + "Datadog" + }; return Err(anyhow!( - "test DDNS must be provisioned through an owned disposable VM" + "test {service} must be provisioned through an owned disposable VM" )); } let database = match &args.database { @@ -60,6 +65,15 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { } "caddy" => deliver_caddy_from_vault(args, &mut vault), "ddns" => deliver_ddns_from_vault(args, &mut vault), + "datadog" => { + let transport = credential_transport(args)?; + deliver_datadog_from_vault( + &args.hostname, + args.environment.policy(), + &vault, + &transport, + ) + } _ => Err(anyhow!("unsupported secret service {}", args.service)), } } @@ -68,17 +82,35 @@ fn validate_delivery_service(hostname: &str, service: &str) -> Result Err(anyhow!( - "host {hostname} does not declare {service} credential delivery" - )), + "pihole" | "tailscale" | "ddns" | "datadog" if !profile.supports_service(service) => Err( + anyhow!("host {hostname} does not declare {service} credential delivery"), + ), "caddy" if profile.ui_services().is_empty() => { Err(anyhow!("host {hostname} declares no UI services")) } - "pihole" | "tailscale" | "caddy" | "ddns" => Ok(profile), + "pihole" | "tailscale" | "caddy" | "ddns" | "datadog" => Ok(profile), unsupported => Err(anyhow!("unsupported secret service {unsupported}")), } } +fn deliver_datadog_from_vault( + host: &str, + policy: skillet_workstation::provisioning_policy::ProvisioningPolicy, + vault: &Vault, + transport: &impl GuestTransport, +) -> Result<()> { + let input = skillet_workstation::configuration_templates::render( + "datadog", + host, + policy.vault_name(), + vault, + )?; + skillet_workstation::credential_delivery::deliver_datadog_credential( + host, policy, &input, transport, + )?; + Ok(()) +} + fn deliver_ddns_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Result<()> { let policy = args.environment.policy(); if policy.environment() != skillet_workstation::provisioning_policy::Environment::Production { @@ -234,6 +266,9 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { if args.with_ddns && !profile.supports_service("ddns") { return Err(anyhow!("host {} does not declare DDNS", args.hostname)); } + if args.with_datadog && !profile.supports_service("datadog") { + return Err(anyhow!("host {} does not declare Datadog", args.hostname)); + } let butane = butane_root()?; let run_identity = RunIdentity::new(&args.hostname, &args.instance)?; let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; @@ -283,6 +318,9 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { if args.with_ddns { provision_vm_ddns(args, &run_dir, &transport, &mut vault)?; } + if args.with_datadog { + deliver_datadog_from_vault(&args.hostname, policy, &vault, &transport)?; + } provisioning_state::remove_tailscale_pending(&run_dir)?; println!("Tailscale connected VM {expected_hostname}"); Ok(()) diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 445d0513..b6131eb7 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -49,6 +49,8 @@ fn rejects_vm_without_tailscale_systemd_credential() { #[test] fn delivery_eligibility_uses_declared_service_capabilities() { + assert!(validate_delivery_service("beezelbot", "datadog").is_err()); + assert!(validate_delivery_service("clamps", "datadog").is_ok()); assert!(validate_delivery_service("beezelbot", "pihole").is_err()); assert!(validate_delivery_service("beezelbot", "tailscale").is_err()); assert!(validate_delivery_service("beezelbot", "caddy").is_ok()); @@ -57,3 +59,49 @@ fn delivery_eligibility_uses_declared_service_capabilities() { assert!(validate_delivery_service("missing-host", "caddy").is_err()); assert!(validate_delivery_service("clamps", "unknown").is_err()); } + +#[test] +fn datadog_commands_parse_and_test_delivery_refuses_before_vault_access() { + assert!(crate::Args::try_parse_from([ + "skillet", "apply", "--host", "clamps", "--phase", "datadog" + ]) + .is_ok()); + assert!(crate::Args::try_parse_from([ + "skillet", + "test", + "vm", + "provision", + "clamps", + "monitoring", + "--with-datadog" + ]) + .is_ok()); + let parsed = crate::Args::try_parse_from([ + "skillet", + "secret", + "deliver", + "clamps", + "datadog", + "--environment", + "test", + "--database", + "/nonexistent/skillet-test.kdbx", + "--target", + "user@localhost", + "--identity", + "/nonexistent/key", + "--known-hosts", + "/nonexistent/known_hosts", + ]) + .unwrap(); + let crate::Commands::Secret { + command: crate::SecretCommands::Deliver(args), + } = parsed.command + else { + panic!("wrong parsed command"); + }; + assert!(super::deliver_from_vault(&args) + .unwrap_err() + .to_string() + .contains("test Datadog must be provisioned through an owned disposable VM")); +} diff --git a/ublue/skillet/crates/datadog/Cargo.toml b/ublue/skillet/crates/datadog/Cargo.toml new file mode 100644 index 00000000..fbc23036 --- /dev/null +++ b/ublue/skillet/crates/datadog/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skillet_datadog" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/datadog/examples/agent.rs b/ublue/skillet/crates/datadog/examples/agent.rs new file mode 100644 index 00000000..f040d0b4 --- /dev/null +++ b/ublue/skillet/crates/datadog/examples/agent.rs @@ -0,0 +1,29 @@ +//! Offline Quadlet fixture: no real containers, credentials, or host mutations. +use skillet_core::test_utils::{MockFiles, MockSystem}; +use skillet_datadog::{apply, RuntimeConfig}; +use std::path::Path; + +fn main() -> Result<(), Box> { + let files = MockFiles::new(); + files.record_btrfs_mount(Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount(Path::new("/var/lib/data"), "/dev/test", "/data", "btrfs"); + apply( + &MockSystem::new(), + &files, + r#"{"version":1,"api_key":"00000000000000000000000000000000","site":"datadoghq.eu","tags":["env:test"]}"#, + &RuntimeConfig { + hostname: "fixture-test-monitoring", + network_monitoring: true, + monitored_units: &["syncthing.service".into(), "btrbk.timer".into()], + }, + )?; + let state = files + .files + .lock() + .map_err(|_| std::io::Error::other("fixture lock poisoned"))?; + let quadlet = state + .get("/etc/containers/systemd/datadog-agent.container") + .ok_or_else(|| std::io::Error::other("fixture Quadlet missing"))?; + print!("{}", std::str::from_utf8(quadlet)?); + Ok(()) +} diff --git a/ublue/skillet/crates/datadog/examples/autodiscovery.rs b/ublue/skillet/crates/datadog/examples/autodiscovery.rs new file mode 100644 index 00000000..7a8c455b --- /dev/null +++ b/ublue/skillet/crates/datadog/examples/autodiscovery.rs @@ -0,0 +1,8 @@ +//! A passive fixture for checking labels with the real Quadlet generator. +fn main() { + println!("[Container]\nImage=registry.fedoraproject.org/fedora:latest"); + println!( + "{}", + skillet_datadog::http("syncthing", "http://%%host%%:8384/rest/noauth/health") + ); +} diff --git a/ublue/skillet/crates/datadog/src/lib.rs b/ublue/skillet/crates/datadog/src/lib.rs new file mode 100644 index 00000000..1a11945a --- /dev/null +++ b/ublue/skillet/crates/datadog/src/lib.rs @@ -0,0 +1,40 @@ +//! Service-owned, credential-free Datadog Autodiscovery declarations. +//! +//! Agent activation is separate from ordinary application convergence. +mod runtime; +pub use runtime::{apply, DatadogError, Input, RuntimeConfig, CREDENTIAL, IMAGE}; +use serde_json::{json, Value}; + +fn label(integration: &str, instance: &Value) -> String { + let definition = json!({integration: {"init_config": {}, "instances": [instance]}}).to_string(); + // Quote the complete value for Quadlet's systemd word parser. Quadlet + // preserves specifiers; the generated service then expands %% to %. + let escaped = definition + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%"); + format!("Label=\"com.datadoghq.ad.checks={escaped}\"") +} + +/// An unauthenticated internal HTTP probe. Values must be public configuration; +/// credentials must never be supplied through container labels. +pub fn http(name: &str, url: &str) -> String { + label( + "http_check", + &json!({"name": name, "url": url, "timeout": 5, + "tags": [format!("service:{name}")]}), + ) +} + +/// Host-PID process liveness, distinct from application correctness. +pub fn process(name: &str, search: &str) -> String { + label( + "process", + &json!({"name": name, "search_string": [search], "exact_match": false, + "tags": [format!("service:{name}")]}), + ) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/datadog/src/runtime.rs b/ublue/skillet/crates/datadog/src/runtime.rs new file mode 100644 index 00000000..b1643a5d --- /dev/null +++ b/ublue/skillet/crates/datadog/src/runtime.rs @@ -0,0 +1,263 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use skillet_core::{ + files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}, + system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, + }, +}; +use skillet_podman::{ + MountDependency, NetworkAttachment, PodmanConfig, PodmanError, ProcessIdentity, QuadletSecret, + SecretTarget, Volume, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; + +pub const CREDENTIAL: &str = "datadog_config"; +/// Official Agent 7 index selected on 2026-10-06. +pub const IMAGE: &str = "registry.datadoghq.com/agent@sha256:161a43ad2b290f7527a70e70c8880c1b3d65b39411552a8cb41398049546daf5"; +const CONFIG_DIR: &str = "/etc/skillet/datadog/conf.d"; + +#[derive(Debug, Error)] +pub enum DatadogError { + #[error("invalid Datadog configuration or credentials")] + Invalid, + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Podman(#[from] PodmanError), +} + +// Deliberately no Debug: this contains an API key and private tags. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Input { + version: u32, + api_key: String, + site: String, + #[serde(default)] + tags: Vec, +} + +impl Input { + pub fn render_for_environment(mut self, environment: &str) -> Result { + if !matches!(environment, "prod" | "test") { + return Err(DatadogError::Invalid); + } + let expected = format!("env:{environment}"); + if self + .tags + .iter() + .any(|tag| tag.starts_with("env:") && tag != &expected) + { + return Err(DatadogError::Invalid); + } + if !self.tags.contains(&expected) { + self.tags.push(expected); + } + serde_json::to_string(&self).map_err(|_| DatadogError::Invalid) + } + + pub fn parse(input: &str) -> Result { + let value: Self = serde_json::from_str(input).map_err(|_| DatadogError::Invalid)?; + if value.version != 1 + || value.api_key.len() != 32 + || !value.api_key.bytes().all(|byte| byte.is_ascii_hexdigit()) + || !matches!( + value.site.as_str(), + "datadoghq.com" + | "datadoghq.eu" + | "us3.datadoghq.com" + | "us5.datadoghq.com" + | "ap1.datadoghq.com" + | "ap2.datadoghq.com" + | "ddog-gov.com" + | "us2.ddog-gov.com" + ) + || value.tags.iter().any(|tag| { + tag.is_empty() + || tag.len() > 200 + || tag + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + }) + { + return Err(DatadogError::Invalid); + } + Ok(value) + } +} + +pub struct RuntimeConfig<'a> { + pub hostname: &'a str, + pub network_monitoring: bool, + pub monitored_units: &'a [String], +} + +pub fn apply( + system: &S, + files: &F, + input: &str, + runtime: &RuntimeConfig<'_>, +) -> Result<(), DatadogError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, +{ + let input = Input::parse(input)?; + if runtime.hostname.is_empty() + || runtime.hostname.len() > 63 + || !runtime + .hostname + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + || runtime.monitored_units.is_empty() + || runtime.monitored_units.iter().any(|unit| { + !matches!(unit.rsplit_once('.'), Some((_, "service" | "timer"))) + || !unit + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) + { + return Err(DatadogError::Invalid); + } + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + let mut revisions = Vec::new(); + for (name, instance) in [ + ("btrfs", json!({})), + ("network", json!({})), + ( + "tcp_check", + json!({"name": "host-ssh", "host": "127.0.0.1", "port": 22}), + ), + ( + "systemd", + json!({"unit_names": runtime.monitored_units, "private_socket": "/host/run/systemd/private"}), + ), + ] { + let directory = Path::new(CONFIG_DIR).join(format!("{name}.d")); + files.ensure_directory( + &directory, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + let config = json!({"init_config": {}, "instances": [instance]}).to_string(); + files.ensure_file( + &directory.join("conf.yaml"), + config.as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + revisions.push(config.into_bytes()); + } + let tags = input + .tags + .into_iter() + .chain(["service:host".into()]) + .collect::>() + .join(" "); + for (name, value) in [ + ("datadog_api_key", input.api_key), + ("datadog_site", input.site), + ("datadog_tags", tags), + ] { + system.ensure_podman_secret(name, &value)?; + revisions.push(value.into_bytes()); + } + system.service_enable("podman.socket")?; + if !system.service_is_active("podman.socket")? { + system.service_start("podman.socket")?; + } + skillet_podman::container(system, files, container_config(runtime, revisions))?; + Ok(()) +} + +fn container_config(runtime: &RuntimeConfig<'_>, revisions: Vec>) -> PodmanConfig { + let mut settings = vec![ + "ContainerName=datadog-agent".into(), + "PodmanArgs=--pid=host --cgroupns=host --security-opt=label=disable".into(), + "Environment=DOCKER_HOST=unix:///run/podman/podman.sock".into(), + format!("Environment=DD_HOSTNAME={}", runtime.hostname), + "Environment=DD_PROCESS_AGENT_ENABLED=true".into(), + "Environment=DD_APM_ENABLED=false".into(), + "Environment=DD_LOGS_ENABLED=false".into(), + "Environment=DD_USE_DOGSTATSD=false".into(), + ]; + if runtime.network_monitoring { + settings.extend([ + "Environment=DD_SYSTEM_PROBE_NETWORK_ENABLED=true".into(), + "Environment=DD_SYSTEM_PROBE_SERVICE_MONITORING_ENABLED=true".into(), + "AddCapability=SYS_ADMIN SYS_RESOURCE SYS_PTRACE NET_ADMIN NET_BROADCAST NET_RAW IPC_LOCK CHOWN".into(), + ]); + } else { + settings.push("Environment=DD_SYSTEM_PROBE_ENABLED=false".into()); + } + let mut volumes = [ + ("/run/podman/podman.sock", "/run/podman/podman.sock"), + ("/proc", "/host/proc"), + ("/sys/fs/cgroup", "/host/sys/fs/cgroup"), + ("/run/systemd", "/host/run/systemd"), + ("/", "/host/root"), + (CONFIG_DIR, "/etc/datadog-agent/conf.d"), + ] + .into_iter() + .map(|(host, container)| Volume { + host_path: host.into(), + container_path: container.into(), + options: Some("ro".into()), + host_mode: None, + host_ownership: None, + }) + .collect::>(); + if runtime.network_monitoring { + volumes.push(Volume { + host_path: "/sys/kernel/debug".into(), + container_path: "/sys/kernel/debug".into(), + options: Some("ro".into()), + host_mode: None, + host_ownership: None, + }); + } + PodmanConfig { + name: "datadog-agent".into(), + image: IMAGE.into(), + network_attachments: vec![NetworkAttachment::Host], + port_publications: Vec::new(), + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::Numeric { uid: 0, gid: 0 }, + namespace_mapping: None, + volumes, + secrets: [ + ("datadog_api_key", "DD_API_KEY"), + ("datadog_site", "DD_SITE"), + ("datadog_tags", "DD_TAGS"), + ] + .into_iter() + .map(|(name, target)| QuadletSecret { + secret_name: name.into(), + target: SecretTarget::Environment { + env_var_name: target.into(), + }, + }) + .collect(), + config_revisions: revisions, + extra_config: BTreeMap::from([ + ("Container".into(), settings), + ( + "Unit".into(), + vec![ + "Requires=podman.socket".into(), + "After=podman.socket network-online.target".into(), + ], + ), + ("Service".into(), vec!["Restart=always".into()]), + ("Install".into(), vec!["WantedBy=multi-user.target".into()]), + ]), + } +} + +#[cfg(test)] +#[path = "runtime/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/datadog/src/runtime/tests.rs b/ublue/skillet/crates/datadog/src/runtime/tests.rs new file mode 100644 index 00000000..9852fa97 --- /dev/null +++ b/ublue/skillet/crates/datadog/src/runtime/tests.rs @@ -0,0 +1,112 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +fn payload(key: &str) -> String { + json!({"version":1,"api_key":key,"site":"datadoghq.eu","tags":["env:test"]}).to_string() +} + +fn mounted_files() -> MockFiles { + let files = MockFiles::new(); + files.record_btrfs_mount(Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount(Path::new("/var/lib/data"), "/dev/test", "/data", "btrfs"); + files +} + +fn runtime(network_monitoring: bool) -> RuntimeConfig<'static> { + RuntimeConfig { + hostname: "clamps-test-monitoring", + network_monitoring, + monitored_units: &[], + } +} + +fn run( + system: &MockSystem, + files: &MockFiles, + key: &str, + network: bool, +) -> Result<(), DatadogError> { + let units = vec!["syncthing.service".into(), "btrbk.timer".into()]; + let mut config = runtime(network); + config.monitored_units = &units; + apply(system, files, &payload(key), &config) +} + +#[test] +fn rootful_secrets_and_host_observation_are_explicit() { + let system = MockSystem::new(); + let files = mounted_files(); + run(&system, &files, "0123456789abcdef0123456789abcdef", true).unwrap(); + let state = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&state["/etc/containers/systemd/datadog-agent.container"]); + for expected in [ + "Network=host", + "User=0:0", + "--pid=host", + "--cgroupns=host", + "--security-opt=label=disable", + "AddCapability=", + "DD_HOSTNAME=clamps-test-monitoring", + "Secret=datadog_api_key,type=env,target=DD_API_KEY", + "/sys/kernel/debug", + ] { + assert!(quadlet.contains(expected), "missing {expected}: {quadlet}"); + } + assert!(!quadlet.contains("PublishPort=")); + assert!(!quadlet.contains("Environment=DD_API_KEY=")); + assert!(!quadlet.contains(":Z")); + assert!(state + .values() + .all(|bytes| !String::from_utf8_lossy(bytes).contains("0123456789abcdef"))); + let systemd = + String::from_utf8_lossy(&state["/etc/skillet/datadog/conf.d/systemd.d/conf.yaml"]); + assert!(systemd.contains("btrbk.timer")); +} + +#[test] +fn network_capabilities_are_optional() { + let system = MockSystem::new(); + let files = mounted_files(); + run(&system, &files, "0123456789abcdef0123456789abcdef", false).unwrap(); + let state = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&state["/etc/containers/systemd/datadog-agent.container"]); + assert!(!quadlet.contains("AddCapability=")); + assert!(!quadlet.contains("/sys/kernel/debug")); + assert!(quadlet.contains("DD_SYSTEM_PROBE_ENABLED=false")); +} + +#[test] +fn repeats_are_noops_and_rotation_retries_failed_activation() { + let system = MockSystem::new(); + let files = mounted_files(); + let first = "0123456789abcdef0123456789abcdef"; + let second = "1123456789abcdef0123456789abcdef"; + run(&system, &files, first, true).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + run(&system, &files, first, true).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(run(&system, &files, second, true).is_err()); + run(&system, &files, second, true).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts + 2); +} + +#[test] +fn rejects_missing_storage_bad_credentials_and_conflicting_environment() { + let system = MockSystem::new(); + let files = MockFiles::new(); + assert!(run(&system, &files, "0123456789abcdef0123456789abcdef", true).is_err()); + assert!(run(&system, &files, "invalid", true).is_err()); + assert!(files.files.lock().unwrap().is_empty()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); + let input = Input::parse(&payload("0123456789abcdef0123456789abcdef")).unwrap(); + assert!(input.render_for_environment("prod").is_err()); + let input = Input::parse(&payload("0123456789abcdef0123456789abcdef")).unwrap(); + assert!(input + .render_for_environment("test") + .unwrap() + .contains("env:test")); +} diff --git a/ublue/skillet/crates/datadog/src/tests.rs b/ublue/skillet/crates/datadog/src/tests.rs new file mode 100644 index 00000000..76392b1d --- /dev/null +++ b/ublue/skillet/crates/datadog/src/tests.rs @@ -0,0 +1,31 @@ +use super::*; + +// Decode the systemd word, then account for ExecStart specifier expansion. +fn delivered_definition(label: &str) -> Value { + let quoted = label.strip_prefix("Label=").unwrap(); + let decoded: String = serde_json::from_str(quoted).unwrap(); + let expanded = decoded.replace("%%", "%"); + let definition = expanded.strip_prefix("com.datadoghq.ad.checks=").unwrap(); + serde_json::from_str(definition).unwrap() +} + +#[test] +fn http_probe_preserves_autodiscovery_host_after_systemd_expansion() { + let label = http("syncthing", "http://%%host%%:8384/rest/noauth/health"); + let definition = delivered_definition(&label); + let instance = &definition["http_check"]["instances"][0]; + assert_eq!(instance["url"], "http://%%host%%:8384/rest/noauth/health"); + assert_eq!(instance["tags"], json!(["service:syncthing"])); +} + +#[test] +fn process_probe_preserves_json_strings_and_has_no_exact_match() { + let label = process("example", "command --flag=\"value\""); + let definition = delivered_definition(&label); + let instance = &definition["process"]["instances"][0]; + assert_eq!( + instance["search_string"], + json!(["command --flag=\"value\""]) + ); + assert_eq!(instance["exact_match"], false); +} diff --git a/ublue/skillet/crates/ddns/Cargo.toml b/ublue/skillet/crates/ddns/Cargo.toml index 76cad123..e5c0ebc6 100644 --- a/ublue/skillet/crates/ddns/Cargo.toml +++ b/ublue/skillet/crates/ddns/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_datadog.workspace = true skillet_core.workspace = true skillet_podman.workspace = true serde.workspace = true diff --git a/ublue/skillet/crates/ddns/src/lib.rs b/ublue/skillet/crates/ddns/src/lib.rs index a21ed64a..eb9bf185 100644 --- a/ublue/skillet/crates/ddns/src/lib.rs +++ b/ublue/skillet/crates/ddns/src/lib.rs @@ -63,7 +63,10 @@ where extra_config: BTreeMap::from([ ( "Container".into(), - vec!["ContainerName=cloudflare-ddns".into()], + vec![ + "ContainerName=cloudflare-ddns".into(), + skillet_datadog::process("ddns", "cloudflare-ddns"), + ], ), ( "Unit".into(), diff --git a/ublue/skillet/crates/host-profiles/Cargo.toml b/ublue/skillet/crates/host-profiles/Cargo.toml index 8ea7579f..7bc532a3 100644 --- a/ublue/skillet/crates/host-profiles/Cargo.toml +++ b/ublue/skillet/crates/host-profiles/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_datadog.workspace = true skillet_core.workspace = true skillet_hardening.workspace = true skillet_pihole.workspace = true diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 4d7902fd..37ba78d8 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -10,6 +10,41 @@ use skillet_core::{ }; use skillet_podman::SecretTarget; +#[test] +fn datadog_is_optional_and_separately_credential_gated() { + let files = skillet_core::test_utils::MockFiles::new(); + let system = MockSystem::new(); + assert!(super::apply_host_phase( + "clamps", + super::HostApplyPhase::Datadog, + &system, + &files, + &CredentialInputs::default() + ) + .is_err()); + assert!(files.files.lock().unwrap().is_empty()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); + let clamps = super::profile_for_name("clamps").unwrap(); + assert!(clamps.supports_service("datadog")); + assert!(!super::profile_for_name("beezelbot") + .unwrap() + .supports_service("datadog")); + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Datadog).unwrap(), + ["datadog_config"] + ); + assert!( + !super::credentials_for_phase("clamps", super::HostApplyPhase::Full) + .unwrap() + .contains(&"datadog_config") + ); + assert!(clamps + .acceptance_plan() + .services + .iter() + .all(|service| service.unit != "datadog-agent.service")); +} + #[test] fn boot_expectations_are_profile_inputs_and_unknown_profiles_are_refused() { let clamps = super::boot_policy_for_host("clamps").unwrap(); @@ -68,7 +103,15 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s .iter() .map(HostService::name) .collect::>(), - ["pihole", "tailscale", "syncthing", "unifi", "ddns", "btrbk"] + [ + "pihole", + "tailscale", + "syncthing", + "unifi", + "ddns", + "datadog", + "btrbk" + ] ); let credentials = clamps.credential_consumers(); assert!(credentials.iter().any(|use_| { diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index 27f69735..186e4999 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -22,6 +22,7 @@ pub enum HostApplyPhase { Full, Caddy, Ddns, + Datadog, } pub fn credentials_for_phase( @@ -37,11 +38,13 @@ pub fn credentials_for_phase( .credential_consumers() .into_iter() .filter(|consumer| match phase { - HostApplyPhase::Full => { - !matches!(consumer.unit, "caddy.service" | "cloudflare-ddns.service") - } + HostApplyPhase::Full => !matches!( + consumer.unit, + "caddy.service" | "cloudflare-ddns.service" | "datadog-agent.service" + ), HostApplyPhase::Caddy => consumer.unit == "caddy.service", HostApplyPhase::Ddns => consumer.unit == "cloudflare-ddns.service", + HostApplyPhase::Datadog => consumer.unit == "datadog-agent.service", HostApplyPhase::Base => false, }) .map(|consumer| consumer.credential) @@ -86,6 +89,8 @@ pub enum ApplyError { Caddy(#[from] skillet_caddy::CaddyError), #[error("DDNS apply error: {0}")] Ddns(#[from] skillet_ddns::DdnsError), + #[error("Datadog apply error: {0}")] + Datadog(#[from] skillet_datadog::DatadogError), #[error("Podman error: {0}")] Podman(#[from] skillet_podman::PodmanError), #[error("Fixture input error: {0}")] @@ -135,6 +140,7 @@ fn tailscale_config(hostname: &str, auth_key: String, state_path: &str) -> Podma "Container".to_string(), vec![ "ContainerName=tailscale".to_string(), + skillet_datadog::process("tailscale", "tailscaled"), "AddCapability=NET_ADMIN".to_string(), "AddCapability=NET_RAW".to_string(), "AddDevice=/dev/net/tun:/dev/net/tun".to_string(), @@ -247,7 +253,7 @@ fn apply_profile( for service in &profile.services { match &service.config { // Independently credential-gated optional services have their own phase. - ServiceConfig::Ddns => {} + ServiceConfig::Ddns | ServiceConfig::Datadog { .. } => {} ServiceConfig::Pihole { custom_dns } => { let password = credentials .require(PIHOLE_WEB_PASSWORD_CREDENTIAL)? @@ -321,7 +327,7 @@ fn runtime_hostname(files: &dyn FileResource, fallback: &str) -> Result apply_host(hostname, system, files, credentials), HostApplyPhase::Caddy => apply_caddy_host(hostname, system, files, credentials), HostApplyPhase::Ddns => apply_ddns_host(hostname, system, files, credentials), + HostApplyPhase::Datadog => apply_datadog_host(hostname, system, files, credentials), } } @@ -372,6 +379,43 @@ fn apply_ddns_host( Ok(()) } +fn apply_datadog_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialInputs, +) -> Result<(), ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.into()))?; + let network_monitoring = profile + .services + .iter() + .find_map(|service| match service.config { + ServiceConfig::Datadog { network_monitoring } => Some(network_monitoring), + _ => None, + }) + .ok_or_else(|| ApplyError::FixtureInput("host does not declare Datadog".into()))?; + let payload = credentials.require(skillet_datadog::CREDENTIAL)?; + let hostname = runtime_hostname(files, profile.id.as_str())?; + let monitored_units = profile + .acceptance_plan_with_ddns(true) + .services + .into_iter() + .map(|service| service.unit) + .collect::>(); + skillet_datadog::apply( + system, + files, + payload, + &skillet_datadog::RuntimeConfig { + hostname: &hostname, + network_monitoring, + monitored_units: &monitored_units, + }, + )?; + Ok(()) +} + fn apply_caddy_host( hostname: &str, system: &dyn SystemResource, diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index 31e759e0..051cfe8c 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -53,6 +53,9 @@ pub enum ServiceConfig { }, Unifi, Ddns, + Datadog { + network_monitoring: bool, + }, Tailscale { state_path: &'static str, }, @@ -74,6 +77,7 @@ impl HostService { ServiceConfig::Syncthing { .. } => "syncthing", ServiceConfig::Unifi => "unifi", ServiceConfig::Ddns => "ddns", + ServiceConfig::Datadog { .. } => "datadog", ServiceConfig::Tailscale { .. } => "tailscale", ServiceConfig::Btrbk { .. } => "btrbk", } @@ -81,6 +85,10 @@ impl HostService { pub fn credential_consumers(&self) -> Vec { match self.config { + ServiceConfig::Datadog { .. } => vec![CredentialConsumer { + credential: skillet_datadog::CREDENTIAL, + unit: "datadog-agent.service", + }], ServiceConfig::Ddns => vec![CredentialConsumer { credential: super::CLOUDFLARE_DDNS_CONFIG_CREDENTIAL, unit: "cloudflare-ddns.service", @@ -171,7 +179,10 @@ impl HostProfile { let mut services = self .services .iter() - .filter(|service| with_ddns || !matches!(service.config, ServiceConfig::Ddns)) + .filter(|service| { + !matches!(service.config, ServiceConfig::Datadog { .. }) + && (with_ddns || !matches!(service.config, ServiceConfig::Ddns)) + }) .map(|service| acceptance_service(self.id.as_str(), service)) .collect::>(); if self.services.iter().any(|service| service.ui.is_some()) { @@ -287,6 +298,15 @@ impl HostProfile { fn acceptance_service(profile: &str, service: &HostService) -> AcceptanceService { match service.config { + ServiceConfig::Datadog { .. } => AcceptanceService { + unit: "datadog-agent.service".into(), + container: Some("datadog-agent".into()), + network_mode: Some("host".into()), + bind_paths: vec!["/etc/skillet/datadog/conf.d".into()], + owner: Some(AcceptanceOwner::Numeric { uid: 0, gid: 0 }), + listeners: Vec::new(), + health_probe: None, + }, ServiceConfig::Ddns => AcceptanceService { unit: "cloudflare-ddns.service".into(), container: Some("cloudflare-ddns".into()), @@ -455,6 +475,12 @@ fn clamps() -> HostProfile { config: ServiceConfig::Ddns, ui: None, }, + HostService { + config: ServiceConfig::Datadog { + network_monitoring: true, + }, + ui: None, + }, HostService { config: ServiceConfig::Btrbk { snapshot_subvolumes: &["syncthing"], diff --git a/ublue/skillet/crates/pihole/Cargo.toml b/ublue/skillet/crates/pihole/Cargo.toml index 70783631..45b44402 100644 --- a/ublue/skillet/crates/pihole/Cargo.toml +++ b/ublue/skillet/crates/pihole/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_datadog.workspace = true skillet_core.workspace = true skillet_podman.workspace = true thiserror.workspace = true diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs index 86a971fa..021945c3 100644 --- a/ublue/skillet/crates/pihole/src/lib.rs +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -128,6 +128,7 @@ where vec![ "AutoUpdate=registry".to_string(), "ContainerName=pihole".to_string(), + skillet_datadog::process("pihole", "pihole-FTL"), "Environment=FTLCONF_dns_listeningMode=ALL".to_string(), "Environment=FTLCONF_webserver_port=8088o,[::]:8088o".to_string(), "Environment=TZ=Europe/Madrid".to_string(), diff --git a/ublue/skillet/crates/syncthing/Cargo.toml b/ublue/skillet/crates/syncthing/Cargo.toml index 57f45fab..89769bf0 100644 --- a/ublue/skillet/crates/syncthing/Cargo.toml +++ b/ublue/skillet/crates/syncthing/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_datadog.workspace = true skillet_core.workspace = true skillet_podman.workspace = true thiserror.workspace = true diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index cfcea2c7..605c09cb 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -81,6 +81,7 @@ where vec![ "AutoUpdate=registry".to_string(), "ContainerName=syncthing".to_string(), + skillet_datadog::http("syncthing", "http://%%host%%:8384/rest/noauth/health"), ], ); extra_config.insert( diff --git a/ublue/skillet/crates/unifi/Cargo.toml b/ublue/skillet/crates/unifi/Cargo.toml index 392dd266..68c3f6b1 100644 --- a/ublue/skillet/crates/unifi/Cargo.toml +++ b/ublue/skillet/crates/unifi/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_datadog.workspace = true skillet_core.workspace = true skillet_podman.workspace = true thiserror.workspace = true diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs index fc8bc283..2ca381aa 100644 --- a/ublue/skillet/crates/unifi/src/lib.rs +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -43,6 +43,7 @@ where vec![ "AutoUpdate=registry".to_string(), "ContainerName=unifi".to_string(), + skillet_datadog::process("unifi", "ace.jar"), "Environment=TZ=Europe/Madrid".to_string(), ], ); diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml index 2bc6903e..674cc658 100644 --- a/ublue/skillet/crates/workstation/Cargo.toml +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_datadog.workspace = true cloudflare.workspace = true hex.workspace = true keepass.workspace = true diff --git a/ublue/skillet/crates/workstation/src/configuration-templates.json b/ublue/skillet/crates/workstation/src/configuration-templates.json index 6f1f949c..f04bc715 100644 --- a/ublue/skillet/crates/workstation/src/configuration-templates.json +++ b/ublue/skillet/crates/workstation/src/configuration-templates.json @@ -1,6 +1,19 @@ { "version": 1, "services": { + "datadog": { + "default": { + "version": 1, + "api_key": { + "$secret": "skillet/environments/{environment}/datadog/api-key" + }, + "site": { + "$secret": "skillet/environments/{environment}/datadog/site" + }, + "tags": [] + }, + "hosts": {} + }, "ddns": { "default": { "version": 1, diff --git a/ublue/skillet/crates/workstation/src/credential_delivery.rs b/ublue/skillet/crates/workstation/src/credential_delivery.rs index 16eea7cb..c99bd862 100644 --- a/ublue/skillet/crates/workstation/src/credential_delivery.rs +++ b/ublue/skillet/crates/workstation/src/credential_delivery.rs @@ -11,6 +11,8 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum CredentialDeliveryError { + #[error(transparent)] + Datadog(#[from] skillet_datadog::DatadogError), #[error("invalid host credential delivery: {0}")] Invalid(String), #[error(transparent)] @@ -23,6 +25,28 @@ pub enum CredentialDeliveryError { Randomness(#[from] std::io::Error), } +pub fn deliver_datadog_credential( + host: &str, + policy: ProvisioningPolicy, + input: &str, + guest: &impl GuestTransport, +) -> Result<(), CredentialDeliveryError> { + let profile = skillet_hosts::profile_for_name(host) + .filter(|profile| profile.supports_service("datadog")) + .ok_or_else(|| CredentialDeliveryError::Invalid("host does not declare Datadog".into()))?; + let input = + skillet_datadog::Input::parse(input)?.render_for_environment(policy.vault_name())?; + credential::install( + guest, + profile.id.as_str(), + skillet_datadog::CREDENTIAL, + "skillet-datadog-apply.service", + credential::ActivationPolicy::StartConsumer, + input.as_bytes(), + )?; + Ok(()) +} + pub trait ProductionAuthKeyProvider { fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result; } diff --git a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs index 6ed67be2..8cd1fe33 100644 --- a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs +++ b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs @@ -49,6 +49,28 @@ struct FakeGuest { type CapturedGuestCall = (Vec, Option>); +#[test] +fn datadog_delivery_validates_capability_and_sends_only_stdin() { + let guest = FakeGuest::default(); + let key = "0123456789abcdef0123456789abcdef"; + let payload = serde_json::json!({"version":1,"api_key":key,"site":"datadoghq.eu"}).to_string(); + let policy = ProvisioningPolicy::new(Environment::Test); + assert!(deliver_datadog_credential("beezelbot", policy, &payload, &guest).is_err()); + assert!(deliver_datadog_credential("clamps", policy, "invalid", &guest).is_err()); + assert!(guest.calls.lock().unwrap().is_empty()); + deliver_datadog_credential("clamps", policy, &payload, &guest).unwrap(); + let calls = guest.calls.lock().unwrap(); + assert!(calls + .iter() + .all(|(args, _)| args.iter().all(|arg| !arg.contains(key)))); + assert!(calls + .iter() + .any(|(_, input)| input.as_ref().is_some_and(|bytes| { + let value = String::from_utf8_lossy(bytes); + value.contains(key) && value.contains("env:test") + }))); +} + impl GuestTransport for FakeGuest { fn execute( &self, From bbfcae8f817f985957766a8c7a231ce5f677760a Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 7 Oct 2026 14:57:03 +0200 Subject: [PATCH 210/252] feat(secrets): audit vault requirements and unused entries --- SECRETS.md | 88 ++++++ ublue/AGENTS.md | 16 ++ ublue/design/secrets.md | 10 + ublue/skillet/Cargo.lock | 26 ++ ublue/skillet/Cargo.toml | 3 + ublue/skillet/README.md | 26 ++ ublue/skillet/crates/cli/Cargo.toml | 3 + ublue/skillet/crates/cli/src/main.rs | 20 ++ .../skillet/crates/cli/src/secret_delivery.rs | 37 ++- .../crates/cli/src/secret_delivery/tests.rs | 32 +++ ublue/skillet/crates/cli/src/secret_output.rs | 100 +++++++ .../examples/secrets_documentation.rs | 4 + .../src/configuration_templates.rs | 15 ++ ublue/skillet/crates/workstation/src/lib.rs | 1 + .../workstation/src/secret-requirements.json | 32 +++ .../skillet/crates/workstation/src/secrets.rs | 250 ++++++++++++++++++ .../crates/workstation/src/secrets/tests.rs | 112 ++++++++ ublue/skillet/crates/workstation/src/vault.rs | 22 ++ .../crates/workstation/src/vault/tests.rs | 31 +++ 19 files changed, 827 insertions(+), 1 deletion(-) create mode 100644 SECRETS.md create mode 100644 ublue/skillet/crates/cli/src/secret_output.rs create mode 100644 ublue/skillet/crates/workstation/examples/secrets_documentation.rs create mode 100644 ublue/skillet/crates/workstation/src/secret-requirements.json create mode 100644 ublue/skillet/crates/workstation/src/secrets.rs create mode 100644 ublue/skillet/crates/workstation/src/secrets/tests.rs diff --git a/SECRETS.md b/SECRETS.md new file mode 100644 index 00000000..aca20801 --- /dev/null +++ b/SECRETS.md @@ -0,0 +1,88 @@ +# Skillet secrets checklist + +Vault: `$XDG_DATA_HOME/skillet/secrets.kdbx` (default: +`~/.local/share/skillet/secrets.kdbx`). Paths are **group path + entry title**. +Put every value in **Password**, including IDs and configuration; leave +**Username** empty. `` is `prod` or `test`; `` is the +canonical host name. Only prepare entries for enabled modules. + +Audit with `skillet secrets check` (all declared hosts, production), or +`skillet secrets check --host clamps --environment test`. It checks presence +and nonempty Password fields, without provider calls or generating secrets. +Missing optional/generated/planned entries do not fail the check. + + + +## Btrbk + +No vault entries for the current local snapshot configuration. + +## Caddy + +Prepare the shared Cloudflare entries. [UI setup](ublue/skillet/README.md#caddy-ui-credentials). + +| Entry | Status | Password value / how to obtain it | +| --- | --- | --- | +| `skillet/environments//hosts//cloudflare/acme-token` | Generated | Skillet creates the persistent scoped token; do not create disposable VM tokens manually. | +| `skillet/hosts//cloudflare/acme-token` | Generated | Legacy production token read during migration. Do not create new entries at this path. | + +## Cloudflare + +Shared inputs for Caddy and DDNS. + +| Entry | Status | Password value / how to obtain it | +| --- | --- | --- | +| `skillet/cloudflare/token-creator` | Required | Create an account-owned Cloudflare API token with Account API Tokens Write and Zone Read, restricted to the selected zones. [Permissions guide](ublue/skillet/README.md#secret-setup-and-delivery). | +| `skillet/environments//dns/cloudflare-zone-id` | Required | Copy the Zone ID from the selected zone's Cloudflare Overview page. | +| `skillet/environments//dns/ui-domain` | Optional | Choose a relative UI prefix, such as ui; absent defaults to ui. Skillet appends the zone domain. Do not enter a full domain. | + +## Datadog + +Use separate test credentials for disposable telemetry. No application key required. + +| Entry | Status | Password value / how to obtain it | +| --- | --- | --- | +| `skillet/environments//datadog/api-key` | Required | Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | +| `skillet/environments//datadog/site` | Required | Enter your account's Datadog site, e.g. datadoghq.eu. [Setup](ublue/skillet/README.md#datadog). | + +## DDNS + +Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skillet/README.md#optional-cloudflare-ddns). + +| Entry | Status | Password value / how to obtain it | +| --- | --- | --- | +| `skillet/environments//hosts//cloudflare/ddns-dns-name` | Required | Choose the relative DNS record name, not a full domain. | +| `skillet/environments/prod/hosts//cloudflare/ddns-token` | Generated | Skillet creates the persistent scoped token; disposable tokens are lifecycle-owned. | + +## Pi-hole + +[Delivery](ublue/skillet/README.md#secret-setup-and-delivery). + +| Entry | Status | Password value / how to obtain it | +| --- | --- | --- | +| `skillet/hosts//pihole/web-password` | Generated | Skillet generates and saves the web admin password for a fresh host; optionally create it to choose a password. For an existing host, restore its entry rather than inventing a replacement. | + +## Storage (planned) + +No entry required today. Keep recovery material off the encrypted host. [Encryption plan](ublue/plan/TPM-ENCRYPTED-ROOT.md). + +| Entry | Status | Password value / how to obtain it | +| --- | --- | --- | +| `skillet/hosts//storage/root-recovery-key` | Planned | LUKS recovery key; prepare through the future TPM/LUKS workflow. | + +## Syncthing + +No vault entries currently required; Skillet manages the private UI configuration. + +## Tailscale + +Create an OAuth client in Tailscale Trust credentials with Auth Keys Write and Devices Core Read/Write, restricted to the provisioner tag. [Tag and scope guide](ublue/skillet/README.md#tailscale-setup-for-skillet). Skillet mints enrollment keys; do not create per-host auth-key entries. + +| Entry | Status | Password value / how to obtain it | +| --- | --- | --- | +| `skillet/tailscale/provisioner-client-id` | Required | Copy the OAuth Client ID from the creation page; use Password, not Username. | +| `skillet/tailscale/provisioner-client-secret` | Required | Copy the OAuth Client secret from the creation page. | + +## UniFi + +No vault entries currently required. Controller/UI accounts are managed in UniFi, separately from Skillet provisioning. diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index b5a1e9d3..02af04b3 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -11,6 +11,22 @@ ## Design documentation +- Maintain the repository-root [secrets checklist](../SECRETS.md) whenever a + vault entry or secret requirement is added, changed, or removed. Group entries + by module in alphabetical order. Keep explanations brief; include the exact + path, required/optional/generated status, where to obtain the value or a short + setup link, and whether it belongs in Username or Password. Distinguish planned + requirements from implemented ones; never include real credentials or private + identifiers. +- The checklist and `secrets check` share + `skillet/crates/workstation/src/secret-requirements.json`. Update that metadata + and regenerate `SECRETS.md` with `cargo run -p skillet_workstation --example + secrets_documentation` from the Skillet workspace (redirect output to the + repository-root file). Keep template-reference coverage and the documentation + equality test passing. Required checks follow selected host capabilities; + unused-entry auditing must consider all declared hosts and both environments, + including optional/generated entries and retained migration readers. + - Read the relevant documents in [design/](design/) before changing architecture or behavior they describe, including [secrets](design/secrets.md) and [smoke VM lifecycle](design/smoke-vms.md). diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 3de464d5..343e9cbc 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -82,6 +82,16 @@ re-apply can reuse credentials already installed on that host. ## Configuration templates +Secret auditing uses shared public requirement metadata to render the root +checklist and supply missing-entry instructions. Required manual entries follow +the selected canonical host capabilities and environment; template references +come from the delivery resolver, including host overrides. Generated, optional, +and planned entries are distinguished. A read-only inventory of entry paths +finds unused Skillet entries across all declared hosts and both environments, +retaining known migration paths. It never emits values, mutates the vault, or +uses provider APIs; it checks presence rather than provider permissions or host +recovery state. `secrets` is the canonical CLI group; `secret` remains an alias. + Use the generic [configuration template design](configuration-templates.md) when service configuration combines clear policy with KeePassXC values. The DDNS template is the first implementation. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index fb515784..9b97ce51 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -1625,6 +1625,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osc8" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2d2086a3a477709f0208181e65857a9c062064ab2b3bb9abaf64f17d148bf3" + [[package]] name = "parking" version = "2.2.1" @@ -1735,6 +1741,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -2282,6 +2299,8 @@ dependencies = [ "hex", "keepass", "keyutils", + "osc8", + "pulldown-cmark", "rpassword", "serde", "serde_json", @@ -2293,6 +2312,7 @@ dependencies = [ "skillet_podman", "skillet_vm", "skillet_workstation", + "supports-hyperlinks", "tempfile", "thiserror 1.0.69", "tracing", @@ -2571,6 +2591,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + [[package]] name = "syn" version = "2.0.117" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index ca82a9ae..54ccfa89 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -22,6 +22,9 @@ members = [ ] [workspace.dependencies] +osc8 = "0.1" +pulldown-cmark = { version = "0.13", default-features = false } +supports-hyperlinks = "3.2" skillet_core = { path = "crates/core" } skillet_hardening = { path = "crates/hardening" } skillet_pihole = { path = "crates/pihole" } diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 407b096f..f0c9e5c7 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -169,6 +169,32 @@ that VM's host key beside the private key. ## Secret setup and delivery +Start with the repository-root [secrets checklist](../../SECRETS.md) for the +entries required by each module. + +The command group is `skillet secrets`; `skillet secret` remains an alias. + +```bash +cargo run --release -p skillet -- secrets unlock +cargo run --release -p skillet -- secrets check +cargo run --release -p skillet -- secrets check --host clamps --environment test +``` + +`check` defaults to all declared host profiles and production requirements. +It reads the vault, reports missing or invalid required Password fields with +the checklist's setup instructions, and exits unsuccessfully if any are missing +or invalid. Optional and automatically generated entries need not exist. +Unused `skillet/` entries are reported against **all hosts and both prod/test**, +regardless of the check filter; unrelated personal entries are excluded. Unused +entries are advisory and are never deleted. No provider calls or credential +generation occur. These commands accept `--database` and `--key-file` overrides. + +Audit output uses plain labels and setup instructions. Supporting terminals get +clickable web links through OSC 8; pipes and unsupported terminals get readable +link destinations. Repository-relative documentation links remain file paths. +Set `FORCE_HYPERLINK=0` to disable links, or `FORCE_HYPERLINK=1` to override +terminal detection when stdout is a terminal. + Manage durable secrets in your Syncthing-synced KeePassXC database. Use group paths and entry titles from the [secret design](../design/secrets.md), storing each value in its Password field. The workstation prompts for the database diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index db4f09b8..7f1364c2 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -7,6 +7,9 @@ edition = "2021" workspace = true [dependencies] +osc8.workspace = true +pulldown-cmark.workspace = true +supports-hyperlinks.workspace = true skillet_cli_common.workspace = true skillet_hosts.workspace = true skillet_caddy.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 5cc1781f..08db5fed 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -6,6 +6,7 @@ use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; mod secret_delivery; +mod secret_output; mod vm; #[derive(Parser, Debug)] @@ -20,6 +21,7 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { /// Manage host secrets backed by `KeePassXC` + #[command(name = "secrets", visible_alias = "secret")] Secret { #[command(subcommand)] command: SecretCommands, @@ -45,6 +47,8 @@ enum Commands { #[derive(clap::Subcommand, Debug)] enum SecretCommands { + /// Audit required and unused entries without changing the vault or providers + Check(SecretCheckArgs), /// Deliver credentials for a configured host service Deliver(SecretDeliverArgs), /// Verify and cache the `KeePassXC` password for this session @@ -53,6 +57,19 @@ enum SecretCommands { Lock(SecretLockArgs), } +#[derive(clap::Args, Debug)] +struct SecretCheckArgs { + /// Check requirements for this host; defaults to all declared profiles + #[arg(long)] + host: Option, + #[arg(long, value_enum, default_value_t = UiEnvironmentName::Production)] + environment: UiEnvironmentName, + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, +} + #[derive(clap::Args, Debug)] struct SecretUnlockArgs { #[arg(long)] @@ -255,6 +272,9 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { + Commands::Secret { + command: SecretCommands::Check(args), + } => secret_delivery::check_vault(&args)?, Commands::Secret { command: SecretCommands::Deliver(args), } => { diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 4c561b96..60a97fd0 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -1,4 +1,7 @@ -use super::{butane_root, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, VmProvisionArgs}; +use super::{ + butane_root, SecretCheckArgs, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, + VmProvisionArgs, +}; use anyhow::{anyhow, Context, Result}; use skillet_vm::{ backend::{VirshBackend, VmBackend}, @@ -201,6 +204,38 @@ pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { Ok(()) } +pub(super) fn check_vault(args: &SecretCheckArgs) -> Result<()> { + let all_profiles = skillet_hosts::profile::declared_profiles(); + let profiles = if let Some(host) = &args.host { + vec![skillet_hosts::profile_for_name(host) + .ok_or_else(|| anyhow!("unknown host profile: {host}"))?] + } else { + all_profiles.clone() + }; + let path = args + .database + .clone() + .map_or_else(default_database_path, Ok)?; + let vault = Vault::open(&path, args.key_file.as_deref())?; + let report = + skillet_workstation::secrets::check(&profiles, args.environment.policy(), &|path| { + vault.get(path) + })?; + let unused = skillet_workstation::secrets::unused_paths(&all_profiles, &vault.entry_paths())?; + let hyperlinks = std::io::IsTerminal::is_terminal(&std::io::stdout()) + && supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout); + print!( + "{}", + crate::secret_output::render(&report, &unused, hyperlinks) + ); + if !report.missing.is_empty() { + return Err(anyhow!( + "required vault entries need attention; see instructions above" + )); + } + Ok(()) +} + pub(super) fn unlock_vault(path: Option<&Path>, key_file: Option<&Path>) -> Result<()> { let path = match path { Some(path) => path.to_path_buf(), diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index b6131eb7..d96f18da 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -1,6 +1,38 @@ use super::{validate_delivery_service, validate_tailscale_unit_config}; use clap::Parser; +#[test] +fn plural_secrets_commands_parse_and_unknown_check_host_fails_before_unlock() { + for command in ["unlock", "lock", "check"] { + assert!(crate::Args::try_parse_from(["skillet", "secrets", command]).is_ok()); + assert!(crate::Args::try_parse_from(["skillet", "secret", command]).is_ok()); + } + let parsed = crate::Args::try_parse_from([ + "skillet", + "secrets", + "check", + "--host", + "unknown", + "--environment", + "test", + "--database", + "/nonexistent/secrets.kdbx", + "--key-file", + "/nonexistent/key", + ]) + .unwrap(); + let crate::Commands::Secret { + command: crate::SecretCommands::Check(args), + } = parsed.command + else { + panic!("wrong command") + }; + assert!(super::check_vault(&args) + .unwrap_err() + .to_string() + .contains("unknown host profile")); +} + #[test] fn ddns_commands_parse_and_test_delivery_refuses_before_vault_access() { let parsed = crate::Args::try_parse_from([ diff --git a/ublue/skillet/crates/cli/src/secret_output.rs b/ublue/skillet/crates/cli/src/secret_output.rs new file mode 100644 index 00000000..560713ec --- /dev/null +++ b/ublue/skillet/crates/cli/src/secret_output.rs @@ -0,0 +1,100 @@ +//! Terminal presentation of public secret setup instructions. +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use skillet_workstation::secrets::CheckReport; +use std::fmt::Write as _; + +// Parse shared documentation using CommonMark, preserving destinations in +// plain output and emitting OSC 8 only for supported web links. +fn terminal_guide(guide: &str, hyperlinks: bool) -> String { + let mut output = String::new(); + let mut link = None; + for event in Parser::new(guide) { + match event { + Event::Start(Tag::Link { dest_url, .. }) => { + let clickable = hyperlinks + && (dest_url.starts_with("https://") || dest_url.starts_with("http://")); + if clickable { + let _ = write!(output, "{}", osc8::Hyperlink::new(&dest_url)); + } + link = Some((dest_url, clickable)); + } + Event::End(TagEnd::Link) => { + if let Some((destination, clickable)) = link.take() { + if clickable { + let _ = write!(output, "{}", osc8::Hyperlink::END); + } else { + let _ = write!(output, ": {destination}"); + } + } + } + Event::Text(text) | Event::Code(text) => output.push_str(&text), + Event::SoftBreak | Event::HardBreak | Event::End(TagEnd::Paragraph) => output.push(' '), + _ => {} + } + } + output.trim().to_string() +} + +fn write_guide(output: &mut String, guide: &str, hyperlinks: bool) { + let guide = terminal_guide(guide, hyperlinks); + // Let the terminal wrap hyperlinks without counting invisible escapes or + // splitting link control sequences. Plain output is explicitly wrapped. + if hyperlinks { + let _ = writeln!(output, " {guide}"); + return; + } + let mut column = 6; + output.push_str(" "); + for word in guide.split_whitespace() { + let length = word.chars().count(); + if column > 6 { + if column + 1 + length > 88 { + output.push_str("\n "); + column = 6; + } else { + output.push(' '); + column += 1; + } + } + output.push_str(word); + column += length; + } + output.push('\n'); +} + +pub(super) fn render(report: &CheckReport, unused: &[String], hyperlinks: bool) -> String { + let mut output = String::new(); + if !report.missing.is_empty() { + output.push_str("Required entries needing attention\n"); + let mut previous_module = ""; + for entry in &report.missing { + if previous_module != entry.module { + let _ = write!(output, "\n{}\n", entry.module); + previous_module = &entry.module; + } + let state = if entry.invalid { + "INVALID (empty or ambiguous)" + } else { + "MISSING" + }; + let _ = writeln!(output, " {state}: {}", entry.path); + output.push_str(" Field: Password (leave Username empty)\n Setup:\n"); + write_guide(&mut output, &entry.guide, hyperlinks); + output.push('\n'); + } + } + if !unused.is_empty() { + output.push_str("\nUnused Skillet entries\n Scope: all declared hosts, prod and test\n Review these entries; nothing has been deleted.\n"); + for path in unused { + let _ = writeln!(output, " {path}"); + } + } + let _ = writeln!( + output, + "\nSummary: {} required checked, {} missing/invalid, {} unused.", + report.checked, + report.missing.len(), + unused.len() + ); + output +} diff --git a/ublue/skillet/crates/workstation/examples/secrets_documentation.rs b/ublue/skillet/crates/workstation/examples/secrets_documentation.rs new file mode 100644 index 00000000..bf575fb9 --- /dev/null +++ b/ublue/skillet/crates/workstation/examples/secrets_documentation.rs @@ -0,0 +1,4 @@ +fn main() -> Result<(), skillet_workstation::secrets::CheckError> { + print!("{}", skillet_workstation::secrets::documentation()?); + Ok(()) +} diff --git a/ublue/skillet/crates/workstation/src/configuration_templates.rs b/ublue/skillet/crates/workstation/src/configuration_templates.rs index e7b6b45d..97a14a16 100644 --- a/ublue/skillet/crates/workstation/src/configuration_templates.rs +++ b/ublue/skillet/crates/workstation/src/configuration_templates.rs @@ -57,6 +57,21 @@ pub fn render( }) } +/// Discover resolved references using the same parser and host override selection +/// as delivery. No vault reads or secret values are required. +pub fn secret_paths( + service: &str, + host: &str, + environment: &str, +) -> Result, ConfigurationTemplateError> { + let paths = std::cell::RefCell::new(std::collections::BTreeSet::new()); + render_catalog(TEMPLATE_CATALOG, service, host, environment, &|path| { + paths.borrow_mut().insert(path.to_string()); + Ok(Some("audit-placeholder".into())) + })?; + Ok(paths.into_inner().into_iter().collect()) +} + fn render_catalog( catalog: &str, service: &str, diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index 4525dbd8..a70054c4 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -6,6 +6,7 @@ pub mod credential_delivery; pub mod ddns_provisioning; pub mod provisioning_policy; pub mod provisioning_state; +pub mod secrets; pub mod tailscale; pub mod tailscale_enrollment; pub mod ui_provisioning; diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json new file mode 100644 index 00000000..ea3c30be --- /dev/null +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -0,0 +1,32 @@ +[ + {"module":"Btrbk","required_by":["btrbk"],"description":"No vault entries for the current local snapshot configuration.","entries":[]}, + {"module":"Caddy","required_by":["ui"],"description":"Prepare the shared Cloudflare entries. [UI setup](ublue/skillet/README.md#caddy-ui-credentials).","entries":[ + {"path":"skillet/environments//hosts//cloudflare/acme-token","status":"generated","help":"Skillet creates the persistent scoped token; do not create disposable VM tokens manually."}, + {"path":"skillet/hosts//cloudflare/acme-token","status":"generated","help":"Legacy production token read during migration. Do not create new entries at this path."} + ]}, + {"module":"Cloudflare","required_by":["ui","ddns"],"description":"Shared inputs for Caddy and DDNS.","entries":[ + {"path":"skillet/cloudflare/token-creator","status":"required","help":"Create an account-owned Cloudflare API token with Account API Tokens Write and Zone Read, restricted to the selected zones. [Permissions guide](ublue/skillet/README.md#secret-setup-and-delivery)."}, + {"path":"skillet/environments//dns/cloudflare-zone-id","status":"required","help":"Copy the Zone ID from the selected zone's Cloudflare Overview page."}, + {"path":"skillet/environments//dns/ui-domain","status":"optional","help":"Choose a relative UI prefix, such as ui; absent defaults to ui. Skillet appends the zone domain. Do not enter a full domain."} + ]}, + {"module":"Datadog","required_by":["datadog"],"template":"datadog","description":"Use separate test credentials for disposable telemetry. No application key required.","entries":[ + {"path":"skillet/environments//datadog/api-key","status":"required","help":"Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."}, + {"path":"skillet/environments//datadog/site","status":"required","help":"Enter your account's Datadog site, e.g. datadoghq.eu. [Setup](ublue/skillet/README.md#datadog)."} + ]}, + {"module":"DDNS","required_by":["ddns"],"template":"ddns","description":"Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skillet/README.md#optional-cloudflare-ddns).","entries":[ + {"path":"skillet/environments//hosts//cloudflare/ddns-dns-name","status":"required","help":"Choose the relative DNS record name, not a full domain."}, + {"path":"skillet/environments/prod/hosts//cloudflare/ddns-token","status":"generated","help":"Skillet creates the persistent scoped token; disposable tokens are lifecycle-owned."} + ]}, + {"module":"Pi-hole","required_by":["pihole"],"description":"[Delivery](ublue/skillet/README.md#secret-setup-and-delivery).","entries":[ + {"path":"skillet/hosts//pihole/web-password","status":"generated","help":"Skillet generates and saves the web admin password for a fresh host; optionally create it to choose a password. For an existing host, restore its entry rather than inventing a replacement."} + ]}, + {"module":"Storage (planned)","required_by":[],"description":"No entry required today. Keep recovery material off the encrypted host. [Encryption plan](ublue/plan/TPM-ENCRYPTED-ROOT.md).","entries":[ + {"path":"skillet/hosts//storage/root-recovery-key","status":"planned","help":"LUKS recovery key; prepare through the future TPM/LUKS workflow."} + ]}, + {"module":"Syncthing","required_by":["syncthing"],"description":"No vault entries currently required; Skillet manages the private UI configuration.","entries":[]}, + {"module":"Tailscale","required_by":["tailscale"],"description":"Create an OAuth client in Tailscale Trust credentials with Auth Keys Write and Devices Core Read/Write, restricted to the provisioner tag. [Tag and scope guide](ublue/skillet/README.md#tailscale-setup-for-skillet). Skillet mints enrollment keys; do not create per-host auth-key entries.","entries":[ + {"path":"skillet/tailscale/provisioner-client-id","status":"required","help":"Copy the OAuth Client ID from the creation page; use Password, not Username."}, + {"path":"skillet/tailscale/provisioner-client-secret","status":"required","help":"Copy the OAuth Client secret from the creation page."} + ]}, + {"module":"UniFi","required_by":["unifi"],"description":"No vault entries currently required. Controller/UI accounts are managed in UniFi, separately from Skillet provisioning.","entries":[]} +] diff --git a/ublue/skillet/crates/workstation/src/secrets.rs b/ublue/skillet/crates/workstation/src/secrets.rs new file mode 100644 index 00000000..b0678f67 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/secrets.rs @@ -0,0 +1,250 @@ +//! Read-only vault requirement auditing and its shared human documentation. +use crate::{configuration_templates, provisioning_policy::ProvisioningPolicy, vault::VaultError}; +use serde::Deserialize; +use skillet_hosts::HostProfile; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt::Write as _, +}; +use thiserror::Error; + +const CATALOG: &str = include_str!("secret-requirements.json"); +const INTRO: &str = "# Skillet secrets checklist\n\nVault: `$XDG_DATA_HOME/skillet/secrets.kdbx` (default:\n`~/.local/share/skillet/secrets.kdbx`). Paths are **group path + entry title**.\nPut every value in **Password**, including IDs and configuration; leave\n**Username** empty. `` is `prod` or `test`; `` is the\ncanonical host name. Only prepare entries for enabled modules.\n\nAudit with `skillet secrets check` (all declared hosts, production), or\n`skillet secrets check --host clamps --environment test`. It checks presence\nand nonempty Password fields, without provider calls or generating secrets.\nMissing optional/generated/planned entries do not fail the check.\n\n\n"; + +#[derive(Debug, Error)] +pub enum CheckError { + #[error("invalid secret requirements catalog: {0}")] + Catalog(#[from] serde_json::Error), + #[error("secret requirements catalog is inconsistent: {0}")] + InvalidCatalog(String), + #[error(transparent)] + Template(#[from] configuration_templates::ConfigurationTemplateError), + #[error(transparent)] + Vault(#[from] VaultError), +} + +#[derive(Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum Status { + Required, + Optional, + Generated, + Planned, +} + +impl Status { + const fn label(self) -> &'static str { + match self { + Self::Required => "Required", + Self::Optional => "Optional", + Self::Generated => "Generated", + Self::Planned => "Planned", + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RequirementGroup { + module: String, + required_by: Vec, + description: String, + template: Option, + entries: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct Entry { + path: String, + status: Status, + help: String, +} + +fn catalog() -> Result, CheckError> { + let modules: Vec = serde_json::from_str(CATALOG)?; + if modules + .windows(2) + .any(|pair| pair[0].module.to_lowercase() >= pair[1].module.to_lowercase()) + { + return Err(CheckError::InvalidCatalog( + "modules must be alphabetical".into(), + )); + } + Ok(modules) +} + +/// Missing/invalid entry metadata only; never retains or renders secret values. +pub struct MissingEntry { + pub module: String, + pub path: String, + pub guide: String, + pub invalid: bool, +} + +pub struct CheckReport { + pub checked: usize, + pub missing: Vec, +} + +/// Find unreferenced Skillet entry paths across all supplied profiles and both +/// environments. Optional/generated entries and legacy readers count as used; +/// planned requirements do not. Personal vault entries are outside this audit. +pub fn unused_paths( + profiles: &[HostProfile], + stored: &[String], +) -> Result, CheckError> { + let mut used = BTreeSet::new(); + for module in catalog()? { + for profile in profiles.iter().filter(|profile| selected(&module, profile)) { + for environment in ["prod", "test"] { + let references = module + .template + .as_deref() + .map(|service| { + configuration_templates::secret_paths( + service, + profile.id.as_str(), + environment, + ) + }) + .transpose()?; + for entry in &module.entries { + if entry.status == Status::Planned { + continue; + } + let path = expand(&entry.path, profile.id.as_str(), environment); + if entry.status != Status::Required + || references + .as_ref() + .is_none_or(|paths| paths.contains(&path)) + { + used.insert(path); + } + } + if let Some(paths) = references { + used.extend(paths); + } + } + } + } + Ok(stored + .iter() + .filter(|path| path.starts_with("skillet/") && !used.contains(*path)) + .cloned() + .collect::>() + .into_iter() + .collect()) +} + +fn selected(module: &RequirementGroup, profile: &HostProfile) -> bool { + module.required_by.iter().any(|service| { + if service == "ui" { + !profile.ui_services().is_empty() + } else { + profile.supports_service(service) + } + }) +} + +fn expand(path: &str, host: &str, environment: &str) -> String { + path.replace("", host) + .replace("", environment) +} + +/// Check required manual entries for the selected canonical profiles. +/// Generated values may be absent; their presence alone does not prove recovery. +pub fn check( + profiles: &[HostProfile], + policy: ProvisioningPolicy, + lookup: &impl Fn(&str) -> Result, VaultError>, +) -> Result { + let mut required = BTreeMap::new(); + for module in catalog()? { + for profile in profiles.iter().filter(|profile| selected(&module, profile)) { + let host = profile.id.as_str(); + let paths = module + .template + .as_deref() + .map(|service| { + configuration_templates::secret_paths(service, host, policy.vault_name()) + }) + .transpose()?; + let entries = module + .entries + .iter() + .filter(|entry| entry.status == Status::Required); + let documented = entries + .map(|entry| (expand(&entry.path, host, policy.vault_name()), entry)) + .collect::>(); + let paths = paths.unwrap_or_else(|| documented.keys().cloned().collect()); + for path in paths { + let entry = documented.get(&path).ok_or_else(|| { + CheckError::InvalidCatalog(format!( + "document template reference {path} in {}", + module.module + )) + })?; + required.entry(path).or_insert_with(|| { + ( + module.module.clone(), + format!("{} {}", module.description, entry.help), + ) + }); + } + } + } + let mut report = CheckReport { + checked: required.len(), + missing: Vec::new(), + }; + for (path, (module, guide)) in required { + let invalid = match lookup(&path) { + Ok(Some(value)) if !value.is_empty() => continue, + Ok(None) => false, + Ok(Some(_)) | Err(VaultError::Invalid(_)) => true, + Err(error) => return Err(error.into()), + }; + report.missing.push(MissingEntry { + module, + path, + guide, + invalid, + }); + } + report.missing.sort_by(|a, b| { + a.module + .to_lowercase() + .cmp(&b.module.to_lowercase()) + .then(a.path.cmp(&b.path)) + }); + Ok(report) +} + +/// Render the same instructions used by the checker as the root checklist. +pub fn documentation() -> Result { + let mut output = INTRO.to_string(); + for module in catalog()? { + // Writing to String is infallible. + let _ = write!(output, "\n## {}\n\n{}\n", module.module, module.description); + if !module.entries.is_empty() { + output.push_str( + "\n| Entry | Status | Password value / how to obtain it |\n| --- | --- | --- |\n", + ); + for entry in module.entries { + let _ = writeln!( + output, + "| `{}` | {} | {} |", + entry.path, + entry.status.label(), + entry.help + ); + } + } + } + Ok(output) +} + +#[cfg(test)] +#[path = "secrets/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/secrets/tests.rs b/ublue/skillet/crates/workstation/src/secrets/tests.rs new file mode 100644 index 00000000..5058d86d --- /dev/null +++ b/ublue/skillet/crates/workstation/src/secrets/tests.rs @@ -0,0 +1,112 @@ +use super::*; +use crate::provisioning_policy::Environment; + +#[test] +fn documentation_matches_the_shared_requirement_metadata() { + assert_eq!( + documentation().unwrap(), + include_str!("../../../../../../SECRETS.md") + ); + let modules = catalog().unwrap(); + for profile in skillet_hosts::profile::declared_profiles() { + for service in profile.services { + assert!( + modules + .iter() + .any(|module| module.required_by.iter().any(|name| name == service.name())), + "undocumented service {}", + service.name() + ); + } + } +} + +#[test] +fn manual_requirements_follow_profiles_environment_and_template_references() { + let profile = skillet_hosts::profile_for_name("clamps").unwrap(); + let report = check( + &[profile], + ProvisioningPolicy::new(Environment::Test), + &|_| Ok(None), + ) + .unwrap(); + assert_eq!(report.checked, 7); + assert_eq!(report.missing.len(), 7); + assert!(report.missing.iter().any( + |entry| entry.path == "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name" + )); + assert!(report + .missing + .iter() + .all(|entry| !entry.path.contains("/prod/") + && !entry.path.ends_with("web-password") + && !entry.path.ends_with("acme-token"))); + let profile = skillet_hosts::profile_for_name("beezelbot").unwrap(); + let report = check( + &[profile], + ProvisioningPolicy::new(Environment::Production), + &|_| Ok(Some("not-displayed".into())), + ) + .unwrap(); + assert_eq!(report.checked, 2); + assert!(report.missing.is_empty()); +} + +#[test] +fn invalid_and_empty_entries_are_reported_without_values_or_lookup_error_text() { + let profiles = skillet_hosts::profile::declared_profiles(); + let policy = ProvisioningPolicy::new(Environment::Production); + for lookup in [false, true] { + let report = check(&profiles, policy, &|_| { + if lookup { + Err(VaultError::Invalid("private-value-do-not-print".into())) + } else { + Ok(Some(String::new())) + } + }) + .unwrap(); + assert!(report + .missing + .iter() + .all(|entry| entry.invalid && !entry.guide.contains("private-value-do-not-print"))); + assert_eq!(report.checked, report.missing.len()); + } +} + +#[test] +fn unused_audit_keeps_both_environments_hosts_generated_and_legacy_paths() { + let stored = [ + "skillet/environments/prod/datadog/api-key", + "skillet/environments/test/datadog/api-key", + "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name", + "skillet/environments/prod/hosts/beezelbot/cloudflare/acme-token", + "skillet/environments/test/hosts/beezelbot/cloudflare/acme-token", + "skillet/hosts/clamps/cloudflare/acme-token", + "skillet/hosts/clamps/pihole/web-password", + "personal/not-for-skillet", + "skillet/environments/dns/cloudflare-zone-id", + "skillet/environments/prod/hosts/unknown/cloudflare/acme-token", + "skillet/environments/test/hosts/beezelbot/cloudflare/ddns-dns-name", + ] + .map(str::to_string); + let unused = unused_paths(&skillet_hosts::profile::declared_profiles(), &stored).unwrap(); + assert_eq!(unused.len(), 3); + assert!(unused.contains(&"skillet/environments/dns/cloudflare-zone-id".into())); + assert!( + unused.contains(&"skillet/environments/prod/hosts/unknown/cloudflare/acme-token".into()) + ); + assert!(unused + .contains(&"skillet/environments/test/hosts/beezelbot/cloudflare/ddns-dns-name".into())); +} + +#[test] +fn template_reference_discovery_is_vault_free() { + let paths = configuration_templates::secret_paths("datadog", "clamps", "test").unwrap(); + assert_eq!( + paths, + [ + "skillet/environments/test/datadog/api-key", + "skillet/environments/test/datadog/site" + ] + ); +} diff --git a/ublue/skillet/crates/workstation/src/vault.rs b/ublue/skillet/crates/workstation/src/vault.rs index 3614049e..86a351a1 100644 --- a/ublue/skillet/crates/workstation/src/vault.rs +++ b/ublue/skillet/crates/workstation/src/vault.rs @@ -49,6 +49,28 @@ pub struct Vault { } impl Vault { + /// Enumerate entry paths without accessing Password or Username values. + pub fn entry_paths(&self) -> Vec { + let mut paths = Vec::new(); + let mut pending = vec![(self.database.root().id(), String::new())]; + while let Some((id, prefix)) = pending.pop() { + let Some(group) = self.database.group(id) else { + continue; + }; + for entry in group.entry_ids().filter_map(|id| self.database.entry(id)) { + paths.push(format!( + "{prefix}{}", + entry.get_title().unwrap_or("") + )); + } + for child in group.group_ids().filter_map(|id| self.database.group(id)) { + pending.push((child.id(), format!("{prefix}{}/", child.name))); + } + } + paths.sort(); + paths + } + pub fn default_path() -> Result { let xdg = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); let home = std::env::var_os("HOME").map(PathBuf::from); diff --git a/ublue/skillet/crates/workstation/src/vault/tests.rs b/ublue/skillet/crates/workstation/src/vault/tests.rs index c32556ef..3b181d1b 100644 --- a/ublue/skillet/crates/workstation/src/vault/tests.rs +++ b/ublue/skillet/crates/workstation/src/vault/tests.rs @@ -4,6 +4,37 @@ use super::{ }; use keepass::{Database, DatabaseKey}; +#[test] +fn entry_inventory_returns_only_paths_without_password_fields() { + let mut database = Database::new(); + super::create_entry( + &mut database, + "skillet/environments/test/datadog/api-key", + "private-do-not-display", + ) + .unwrap(); + super::create_entry( + &mut database, + "personal/account/password", + "another-private-value", + ) + .unwrap(); + let vault = Vault { + path: std::path::PathBuf::new(), + original: Vec::new(), + database, + password: "unlock-value".into(), + password_cached: false, + }; + assert_eq!( + vault.entry_paths(), + [ + "personal/account/password", + "skillet/environments/test/datadog/api-key" + ] + ); +} + #[test] fn exact_entry_lookup_preserves_whitespace_and_reports_absence() { let mut database = Database::new(); From 28ed7a334ecb6117341916582abaea686d3d3d37 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 7 Oct 2026 20:00:43 +0200 Subject: [PATCH 211/252] fix(podman): preserve literal Quadlet directives --- ublue/skillet/AGENTS.md | 7 +++++++ ublue/skillet/crates/podman/src/lib.rs | 2 +- ublue/skillet/crates/podman/src/tests.rs | 12 ++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 9e7814f5..3c0914b3 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -86,6 +86,13 @@ tracks existing violations and their migration, not completed implementation. - Use a common ownership representation for named and numeric identities. Model application identity, host filesystem ownership, and namespace mapping separately. Existing numeric ownership must not require a named host account. +- Render machine configuration with the target format's escaping rules; + disable HTML escaping for non-HTML templates. Test literal directives + through both the renderer and the actual downstream consumer. +- Match bind-mount sources to the resource helper's convergence contract. + A helper that creates directories cannot manage a socket or regular-file + source; use an appropriate typed resource or mount its existing directory + without changing application-managed metadata. Verify this on the real runtime. - Give common configuration fields one authoritative representation. Reject conflicts between typed fields and extension directives; preserve ordering where repeated directives are semantically ordered. Global runtime policy diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs index 945f3f8f..1b4432e0 100644 --- a/ublue/skillet/crates/podman/src/lib.rs +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -48,7 +48,7 @@ pub enum PodmanError { } #[derive(Template)] -#[template(path = "quadlet.container.j2")] +#[template(path = "quadlet.container.j2", escape = "none")] struct QuadletTemplate { sections: BTreeMap>, } diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs index 4574f6fc..75ff0332 100644 --- a/ublue/skillet/crates/podman/src/tests.rs +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -569,3 +569,15 @@ fn filesystem_fake_enforces_object_types_and_injected_failures() { ) .is_err()); } + +#[test] +fn quadlet_directives_preserve_configuration_quotes() { + let directive = r#"Label="example.config={\"name\":\"a&b\"}""#; + let template = QuadletTemplate { + sections: BTreeMap::from([("Container".into(), vec![directive.into()])]), + }; + let quadlet = template.render().unwrap(); + assert!(quadlet.lines().any(|line| line == directive)); + assert!(!quadlet.contains(""")); + assert!(!quadlet.contains("&")); +} From 511de0695e19763d225c425d20dd6c303392ad10 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 7 Oct 2026 20:00:58 +0200 Subject: [PATCH 212/252] fix(datadog): share API key and preserve Agent integrations --- SECRETS.md | 5 ++- ublue/AGENTS.md | 3 ++ ublue/design/configuration-templates.md | 5 +-- ublue/design/monitoring.md | 25 ++++++++++---- ublue/design/secrets.md | 10 +++--- ublue/skillet/README.md | 20 ++++++----- ublue/skillet/crates/cli/src/main.rs | 2 +- ublue/skillet/crates/datadog/src/runtime.rs | 33 +++++++++++++------ .../crates/datadog/src/runtime/tests.rs | 11 +++++++ .../src/configuration-templates.json | 6 ++-- .../workstation/src/secret-requirements.json | 5 ++- .../crates/workstation/src/secrets/tests.rs | 24 +++++++------- .../crates/workstation/src/vault/tests.rs | 7 ++-- 13 files changed, 97 insertions(+), 59 deletions(-) diff --git a/SECRETS.md b/SECRETS.md index aca20801..1bfaf223 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -38,12 +38,11 @@ Shared inputs for Caddy and DDNS. ## Datadog -Use separate test credentials for disposable telemetry. No application key required. +One shared key serves production and test. No application key required. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog). | Entry | Status | Password value / how to obtain it | | --- | --- | --- | -| `skillet/environments//datadog/api-key` | Required | Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | -| `skillet/environments//datadog/site` | Required | Enter your account's Datadog site, e.g. datadoghq.eu. [Setup](ublue/skillet/README.md#datadog). | +| `skillet/datadog/api-key` | Required | Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | ## DDNS diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 02af04b3..d0e0edbd 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -70,6 +70,9 @@ workstation boundary; do not store whole configuration documents in the vault or interpolate secret text into configuration strings. Record template behavior and schema changes in the relevant design document. +- Scope vault credentials to their actual provider/account needs; do not + require separate copies by environment when a shared credential is intended. + Keep public endpoint/site choices in configuration, with documented defaults. - Give each resource lifecycle one owner. Every public entry point must use the same ownership validation, recovery, and cleanup policy; transport adapters must not provide a shortcut that bypasses those guarantees. diff --git a/ublue/design/configuration-templates.md b/ublue/design/configuration-templates.md index 9285726c..8152f4b7 100644 --- a/ublue/design/configuration-templates.md +++ b/ublue/design/configuration-templates.md @@ -36,7 +36,8 @@ For DDNS, move `name` to `ddns-dns-name`; keep `proxied` and host override with one typed reference per record. Remove the obsolete whole document entry after the new template and leaf values have been validated. -Datadog uses the same catalog to resolve environment-level `datadog/api-key` -and `datadog/site` leaves, with optional host overrides/private tags. Delivery +Datadog resolves one shared `skillet/datadog/api-key` for both environments. +Its site is ordinary template configuration, defaulting to `datadoghq.com`, +with optional host overrides/private tags. Delivery adds the selected environment tag and rejects a conflicting one; credentials alone never select the Agent capability. diff --git a/ublue/design/monitoring.md b/ublue/design/monitoring.md index 86a0ba40..fe6c424f 100644 --- a/ublue/design/monitoring.md +++ b/ublue/design/monitoring.md @@ -35,7 +35,8 @@ explicit caller policy and tested on uCore with its Secure Boot/SELinux settings The agent has a separate credential-gated apply phase. Installing Skillet or declaring a monitored service must not start telemetry without the Datadog capability and credentials. Use configuration templates to combine public -policy with environment API-key/site leaves in KeePassXC. The application key +policy with one shared `skillet/datadog/api-key` in KeePassXC. Site is public +template configuration, defaulting to `datadoghq.com`. The application key used by Chef's handler is unnecessary for metric submission; future dashboard or monitor management would be a separate workstation responsibility. Private tags, endpoint credentials, and ping targets follow the existing encrypted @@ -43,17 +44,27 @@ delivery path and must not appear in labels or recorded files. Host networking preserves host interface visibility and localhost SSH checks; Autodiscovery reaches container bridge addresses without published UI ports. -The pinned official image requires `DD_API_KEY` at startup, so Podman injects -API key, site, and private tags through native environment secrets. No literal -secret-bearing `Environment=` directives are written. Production and test use -different vault leaves; delivery adds an authoritative environment tag. Test +The socket is accessed through a read-only bind of `/run/podman`; the volume +helper converges directory roots, not socket files. Integration directories +are mounted individually so the official image retains its default system and +container checks. The pinned official image +requires `DD_API_KEY` at startup, so Podman injects +API key and private tags through native environment secrets; the public site +uses an ordinary `Environment=DD_SITE` directive. No literal secret-bearing +`Environment=` directives are written. Production and test use the shared key; +delivery adds an authoritative environment tag. Test telemetry requires explicit `--with-datadog`; destroying a VM does not remove historical telemetry from Datadog. Implemented: labels for Syncthing HTTP health and process liveness for Pi-hole, DDNS, UniFi, Caddy, and Tailscale; Agent deployment/delivery and -host/process/network/Btrfs/SSH/systemd configuration. Pending: live Agent -acceptance, private ping targets, richer +host/process/network/Btrfs/SSH/systemd configuration. Named-VM acceptance +confirmed host/container checks, Autodiscovery, and successful submissions with +SELinux enforcing. eBPF tracing remains unaccepted: the VM returned unsupported +perf probe attachment and read-only fallback tracing errors. Review the required +seccomp/kernel/tracing access separately before changing privileges. Pending: +automatic post-reboot container-label discovery (Agent restart currently +restores application checks), private ping targets, richer Syncthing metrics and Pi-hole's authenticated API integration. The existing Pi-hole community integration requires compatibility review against Pi-hole 6. diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 343e9cbc..b9e31023 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -173,12 +173,14 @@ expiry, and owned record IDs in run metadata, without token values. Existing VM credentials survive re-apply and reboot. Expired tokens require explicit renewal; cleanup and recovery belong to the [VM lifecycle](smoke-vms.md). -Optional Datadog delivery resolves environment `datadog/api-key` and -`datadog/site` leaves through the shared templates. A single `datadog_config` +Optional Datadog delivery resolves the shared `skillet/datadog/api-key` through +the templates. Site is public configuration, defaulting to `datadoghq.com`, +and has no vault entry. A single `datadog_config` credential uses the existing encrypted SSH/systemd path and independently activates the Agent phase. The Agent consumes native Podman environment secrets -for API key, site, and tags; none are written into Quadlet `Environment=` values. -Disposable delivery is explicit and uses test credentials plus an authoritative +for API key and tags; those values are never written into Quadlet `Environment=` +directives. Site uses a normal `Environment=DD_SITE` directive. Disposable +delivery is explicit and uses the shared key plus an authoritative environment tag. VM destruction does not delete historical Datadog telemetry. References: [KeePassXC](https://keepassxc.org/docs/KeePassXC_GettingStarted), diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index f0c9e5c7..6c5229bf 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -8,14 +8,18 @@ credential-gated apply phase. ## Datadog -For an opted-in host, create these KeePassXC entries (Password field): - -- `skillet/environments/prod/datadog/api-key`: Datadog ingestion API key. -- `skillet/environments/prod/datadog/site`: site such as `datadoghq.eu`. - -Use `test` instead of `prod` for disposable telemetry. No application key is -required. The `datadog` configuration template supports host overrides and -private tags using the shared configuration-template pattern. +For an opted-in host, create this KeePassXC entry (Password field): + +- `skillet/datadog/api-key`: shared Datadog ingestion API key. + +Production and test use the same key; telemetry is tagged `env:prod` or +`env:test`. No application key is required. Site is public configuration in +the `datadog` default template in +[configuration-templates.json](crates/workstation/src/configuration-templates.json), +defaulting to `datadoghq.com`. Change it there for another Datadog region; +host overrides and private tags use the shared configuration-template pattern. +Move any previous environment-specific key to the shared entry. Obsolete +Datadog key/site vault paths are reported as unused; the checker never deletes them. Deliver using `skillet secret deliver datadog` with the usual SSH identity, known-hosts, and target options. For an owned disposable VM, use diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 08db5fed..f21023c3 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -206,7 +206,7 @@ struct VmProvisionArgs { /// Provision public DDNS records from test-environment `KeePassXC` config #[arg(long)] with_ddns: bool, - /// Send test-environment Datadog credentials and enable test VM telemetry + /// Deliver the shared Datadog key and enable test-tagged VM telemetry #[arg(long)] with_datadog: bool, #[arg(long)] diff --git a/ublue/skillet/crates/datadog/src/runtime.rs b/ublue/skillet/crates/datadog/src/runtime.rs index b1643a5d..f98000ea 100644 --- a/ublue/skillet/crates/datadog/src/runtime.rs +++ b/ublue/skillet/crates/datadog/src/runtime.rs @@ -158,11 +158,7 @@ where .chain(["service:host".into()]) .collect::>() .join(" "); - for (name, value) in [ - ("datadog_api_key", input.api_key), - ("datadog_site", input.site), - ("datadog_tags", tags), - ] { + for (name, value) in [("datadog_api_key", input.api_key), ("datadog_tags", tags)] { system.ensure_podman_secret(name, &value)?; revisions.push(value.into_bytes()); } @@ -170,16 +166,25 @@ where if !system.service_is_active("podman.socket")? { system.service_start("podman.socket")?; } - skillet_podman::container(system, files, container_config(runtime, revisions))?; + skillet_podman::container( + system, + files, + container_config(runtime, revisions, &input.site), + )?; Ok(()) } -fn container_config(runtime: &RuntimeConfig<'_>, revisions: Vec>) -> PodmanConfig { +fn container_config( + runtime: &RuntimeConfig<'_>, + revisions: Vec>, + site: &str, +) -> PodmanConfig { let mut settings = vec![ "ContainerName=datadog-agent".into(), "PodmanArgs=--pid=host --cgroupns=host --security-opt=label=disable".into(), "Environment=DOCKER_HOST=unix:///run/podman/podman.sock".into(), format!("Environment=DD_HOSTNAME={}", runtime.hostname), + format!("Environment=DD_SITE={site}"), "Environment=DD_PROCESS_AGENT_ENABLED=true".into(), "Environment=DD_APM_ENABLED=false".into(), "Environment=DD_LOGS_ENABLED=false".into(), @@ -195,12 +200,11 @@ fn container_config(runtime: &RuntimeConfig<'_>, revisions: Vec>) -> Pod settings.push("Environment=DD_SYSTEM_PROBE_ENABLED=false".into()); } let mut volumes = [ - ("/run/podman/podman.sock", "/run/podman/podman.sock"), + ("/run/podman", "/run/podman"), ("/proc", "/host/proc"), ("/sys/fs/cgroup", "/host/sys/fs/cgroup"), ("/run/systemd", "/host/run/systemd"), ("/", "/host/root"), - (CONFIG_DIR, "/etc/datadog-agent/conf.d"), ] .into_iter() .map(|(host, container)| Volume { @@ -211,6 +215,16 @@ fn container_config(runtime: &RuntimeConfig<'_>, revisions: Vec>) -> Pod host_ownership: None, }) .collect::>(); + // Preserve the image's built-in integrations and auto-configuration. + for name in ["btrfs", "network", "tcp_check", "systemd"] { + volumes.push(Volume { + host_path: format!("{CONFIG_DIR}/{name}.d"), + container_path: format!("/etc/datadog-agent/conf.d/{name}.d"), + options: Some("ro".into()), + host_mode: None, + host_ownership: None, + }); + } if runtime.network_monitoring { volumes.push(Volume { host_path: "/sys/kernel/debug".into(), @@ -231,7 +245,6 @@ fn container_config(runtime: &RuntimeConfig<'_>, revisions: Vec>) -> Pod volumes, secrets: [ ("datadog_api_key", "DD_API_KEY"), - ("datadog_site", "DD_SITE"), ("datadog_tags", "DD_TAGS"), ] .into_iter() diff --git a/ublue/skillet/crates/datadog/src/runtime/tests.rs b/ublue/skillet/crates/datadog/src/runtime/tests.rs index 9852fa97..13ca32fa 100644 --- a/ublue/skillet/crates/datadog/src/runtime/tests.rs +++ b/ublue/skillet/crates/datadog/src/runtime/tests.rs @@ -48,6 +48,8 @@ fn rootful_secrets_and_host_observation_are_explicit() { "--cgroupns=host", "--security-opt=label=disable", "AddCapability=", + "Volume=/run/podman:/run/podman:ro", + "Environment=DOCKER_HOST=unix:///run/podman/podman.sock", "DD_HOSTNAME=clamps-test-monitoring", "Secret=datadog_api_key,type=env,target=DD_API_KEY", "/sys/kernel/debug", @@ -55,7 +57,16 @@ fn rootful_secrets_and_host_observation_are_explicit() { assert!(quadlet.contains(expected), "missing {expected}: {quadlet}"); } assert!(!quadlet.contains("PublishPort=")); + assert!(quadlet.contains("/etc/datadog-agent/conf.d/btrfs.d:ro")); + assert!(!quadlet.contains("/etc/datadog-agent/conf.d:ro")); assert!(!quadlet.contains("Environment=DD_API_KEY=")); + assert!(quadlet.contains("Environment=DD_SITE=datadoghq.eu")); + assert!(!quadlet.contains("Secret=datadog_site")); + assert!(!system + .podman_secrets + .lock() + .unwrap() + .contains("datadog_site")); assert!(!quadlet.contains(":Z")); assert!(state .values() diff --git a/ublue/skillet/crates/workstation/src/configuration-templates.json b/ublue/skillet/crates/workstation/src/configuration-templates.json index f04bc715..776e3bd1 100644 --- a/ublue/skillet/crates/workstation/src/configuration-templates.json +++ b/ublue/skillet/crates/workstation/src/configuration-templates.json @@ -5,11 +5,9 @@ "default": { "version": 1, "api_key": { - "$secret": "skillet/environments/{environment}/datadog/api-key" - }, - "site": { - "$secret": "skillet/environments/{environment}/datadog/site" + "$secret": "skillet/datadog/api-key" }, + "site": "datadoghq.com", "tags": [] }, "hosts": {} diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json index ea3c30be..8f073bfe 100644 --- a/ublue/skillet/crates/workstation/src/secret-requirements.json +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -9,9 +9,8 @@ {"path":"skillet/environments//dns/cloudflare-zone-id","status":"required","help":"Copy the Zone ID from the selected zone's Cloudflare Overview page."}, {"path":"skillet/environments//dns/ui-domain","status":"optional","help":"Choose a relative UI prefix, such as ui; absent defaults to ui. Skillet appends the zone domain. Do not enter a full domain."} ]}, - {"module":"Datadog","required_by":["datadog"],"template":"datadog","description":"Use separate test credentials for disposable telemetry. No application key required.","entries":[ - {"path":"skillet/environments//datadog/api-key","status":"required","help":"Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."}, - {"path":"skillet/environments//datadog/site","status":"required","help":"Enter your account's Datadog site, e.g. datadoghq.eu. [Setup](ublue/skillet/README.md#datadog)."} + {"module":"Datadog","required_by":["datadog"],"template":"datadog","description":"One shared key serves production and test. No application key required. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog).","entries":[ + {"path":"skillet/datadog/api-key","status":"required","help":"Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."} ]}, {"module":"DDNS","required_by":["ddns"],"template":"ddns","description":"Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skillet/README.md#optional-cloudflare-ddns).","entries":[ {"path":"skillet/environments//hosts//cloudflare/ddns-dns-name","status":"required","help":"Choose the relative DNS record name, not a full domain."}, diff --git a/ublue/skillet/crates/workstation/src/secrets/tests.rs b/ublue/skillet/crates/workstation/src/secrets/tests.rs index 5058d86d..3196c337 100644 --- a/ublue/skillet/crates/workstation/src/secrets/tests.rs +++ b/ublue/skillet/crates/workstation/src/secrets/tests.rs @@ -30,8 +30,8 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { &|_| Ok(None), ) .unwrap(); - assert_eq!(report.checked, 7); - assert_eq!(report.missing.len(), 7); + assert_eq!(report.checked, 6); + assert_eq!(report.missing.len(), 6); assert!(report.missing.iter().any( |entry| entry.path == "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name" )); @@ -76,8 +76,9 @@ fn invalid_and_empty_entries_are_reported_without_values_or_lookup_error_text() #[test] fn unused_audit_keeps_both_environments_hosts_generated_and_legacy_paths() { let stored = [ - "skillet/environments/prod/datadog/api-key", + "skillet/datadog/api-key", "skillet/environments/test/datadog/api-key", + "skillet/environments/test/datadog/site", "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name", "skillet/environments/prod/hosts/beezelbot/cloudflare/acme-token", "skillet/environments/test/hosts/beezelbot/cloudflare/acme-token", @@ -90,7 +91,9 @@ fn unused_audit_keeps_both_environments_hosts_generated_and_legacy_paths() { ] .map(str::to_string); let unused = unused_paths(&skillet_hosts::profile::declared_profiles(), &stored).unwrap(); - assert_eq!(unused.len(), 3); + assert_eq!(unused.len(), 5); + assert!(unused.contains(&"skillet/environments/test/datadog/api-key".into())); + assert!(unused.contains(&"skillet/environments/test/datadog/site".into())); assert!(unused.contains(&"skillet/environments/dns/cloudflare-zone-id".into())); assert!( unused.contains(&"skillet/environments/prod/hosts/unknown/cloudflare/acme-token".into()) @@ -101,12 +104,9 @@ fn unused_audit_keeps_both_environments_hosts_generated_and_legacy_paths() { #[test] fn template_reference_discovery_is_vault_free() { - let paths = configuration_templates::secret_paths("datadog", "clamps", "test").unwrap(); - assert_eq!( - paths, - [ - "skillet/environments/test/datadog/api-key", - "skillet/environments/test/datadog/site" - ] - ); + for environment in ["prod", "test"] { + let paths = + configuration_templates::secret_paths("datadog", "clamps", environment).unwrap(); + assert_eq!(paths, ["skillet/datadog/api-key"]); + } } diff --git a/ublue/skillet/crates/workstation/src/vault/tests.rs b/ublue/skillet/crates/workstation/src/vault/tests.rs index 3b181d1b..b4bdd97e 100644 --- a/ublue/skillet/crates/workstation/src/vault/tests.rs +++ b/ublue/skillet/crates/workstation/src/vault/tests.rs @@ -9,7 +9,7 @@ fn entry_inventory_returns_only_paths_without_password_fields() { let mut database = Database::new(); super::create_entry( &mut database, - "skillet/environments/test/datadog/api-key", + "skillet/datadog/api-key", "private-do-not-display", ) .unwrap(); @@ -28,10 +28,7 @@ fn entry_inventory_returns_only_paths_without_password_fields() { }; assert_eq!( vault.entry_paths(), - [ - "personal/account/password", - "skillet/environments/test/datadog/api-key" - ] + ["personal/account/password", "skillet/datadog/api-key"] ); } From 869c438ac8c27572a69b4a6507e71900a623dbe8 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Wed, 7 Oct 2026 20:01:40 +0200 Subject: [PATCH 213/252] docs(acceptance): record DDNS and Datadog VM results --- ublue/butane/ACCEPTANCE.md | 57 ++++++++++++++++++++++-- ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md | 19 ++++---- ublue/plan/CLAMPS-DATADOG.md | 66 ++++++++++++++++++++-------- ublue/plan/CLAMPS-PARITY.md | 10 +++-- 4 files changed, 115 insertions(+), 37 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index d718b921..78253c83 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,54 @@ # Clamps VM acceptance log +## DDNS and Datadog named-VM acceptance, 2026-10-07 + +- Used separate `clamps-test-monitoring`, instance `monitoring`, SSH port 2202, + on native `qemu:///session`. The retained `clamps-test-smoke` stayed running + on port 2201. KVM is available outside the sandbox. Tested working-tree + changes based on `bbfcae8`; the run's original capture preceded live fixes. +- Shared Datadog API-key/public site change passed the six-required-entry vault + audit, formatting, pedantic workspace Clippy, all 299 workspace tests, and + Fedora base integration (two applies, no repeat service activation). The + actual Quadlet generator accepted the Agent and Autodiscovery fixtures. +- Provisioned Pi-hole, Tailscale, DDNS and Agent using cached vault access and + the ownership-checked CLI. DDNS published one allowlisted, marked public A + record and verified publication on repeat provisioning and after reboot. + Disposable reprovisioning replaces its bounded token by design; it does not + establish token reuse. Ordinary repeat apply preserved all container IDs. +- Live acceptance found and fixed three defects: socket-as-directory volume + convergence, HTML escaping of Quadlet label quotes, and replacement of the + Agent's built-in integration directory. Mounted the socket's parent and + individual integration directories; machine templates preserve literal text. +- Confirmed Agent 7.84.1, VM hostname, SELinux enforcing, API key validation, + successful submissions with no submission errors, Btrfs/network/systemd/SSH + checks, default host/container checks, Syncthing HTTP and four process checks. + Configured checks had no current errors; Docker had an initial historical + error but no current error/warning. Agent listeners observed on loopback + 5000/5001; no 8125/8126 listener was observed. Dashboard/graph verification + and detailed host metric-value comparison were not performed. +- `skillet test smoke clamps --instance monitoring` passed all synthetic + fixture cases across reboot and readiness. It did not run `--with-applications`. + Readiness reinstalled original captured binaries/definitions; restored the + current build with `test vm update`, reconverged phases, then used an owned + direct reboot for the monitoring recovery check. +- DDNS and Agent units and credential apply phases recovered successfully. + Host checks and submissions resumed. Application Autodiscovery did not + recover automatically: HTTP/process instances were absent despite running + labelled containers. A manual Agent restart restored one HTTP and four + process checks. Automatic application monitoring recovery remains failed. +- eBPF network tracing remains failed: probe attachment reported “function not + implemented”, and fallback kprobe registration could not write read-only + tracing files. Secure Boot state was not verified. Do not claim eBPF parity; + the active container and successful host network check do not prove it. +- `test vm destroy clamps monitoring` completed Cloudflare owned-record/token + cleanup, Tailscale cleanup and VM disposal. The run directory was removed; + native libvirt listed only retained `clamps-test-smoke`. No production + handover was attempted. Unrelated provider-resource preservation has offline + ownership coverage; a full live provider inventory comparison was not done. +- Next: fix boot-time Autodiscovery reconciliation and review eBPF kernel, + seccomp and tracing access; see `../plan/CLAMPS-DATADOG.md`. Private ping, + richer application metrics and production acceptance remain separate work. + ## Datadog offline validation, 2026-10-07 - Approved rootful Agent recipe and delivery implemented; Clamps selects it @@ -17,11 +66,13 @@ secret rotation, and retry after a failed restart. - Official pinned image metadata reports Agent 7.84.1. The image's startup requires `DD_API_KEY`; native Podman environment-secret injection is used. -- Live named-VM Agent acceptance is unverified: `/dev/kvm` is absent on this - workstation. Agent status/version, socket discovery, actual host/Btrfs +- Live named-VM Agent acceptance was unverified at this stage. The absent + `/dev/kvm` observation was inside the sandbox; a later native workstation + check confirmed KVM and session libvirt are available. Agent status/version, socket discovery, actual host/Btrfs observations, systemd/SSH checks, eBPF under uCore Secure Boot/SELinux, listener exposure, reboot recovery, and Datadog ingestion still need tests - with test-environment API-key/site leaves. No vault unlock was attempted. + with the shared `skillet/datadog/api-key` and public template site configuration. + No vault unlock was attempted for this Agent validation. ## Disposable DDNS attempt and cleanup, 2026-10-06 diff --git a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md index 4bc20097..fc002702 100644 --- a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md +++ b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md @@ -1,15 +1,11 @@ # Cloudflare DDNS port -Status: slices 1–3 and disposable test provisioning/cleanup implementation -complete, 2026-10-06. Shared DDNS crate, caller selection, separate apply -phase/unit, validated rendered config, token use-or-create, production encrypted -delivery, embedded templates with typed KeePassXC references, and journaled -test-only DNS/token ownership are implemented. Routine -and fake-provider tests cover cleanup. Live DDNS DNS ownership acceptance is -parked until the test `ddns-dns-name` entry is configured. The disposable VM was -created, reached signed boot, partially provisioned, and destroyed with its -Tailscale identity removed; no DDNS token or DNS record was created. Syncthing -live acceptance remains parked and remote CI evidence is pending. +Status, 2026-10-07: implementation complete. The test-only DDNS leaf is now +configured. `clamps-test-monitoring` activated the updater and published one +journaled A record; repeated provisioning verified publication and renewed +the bounded disposable token. Repeat apply preserved its container ID. +Publication after reboot and owned disposal passed; see `../butane/ACCEPTANCE.md`. Production DNS +handover and explicit credential-rotation acceptance remain separate work. Template review validation, 2026-10-06: six resolver tests cover environment paths, complete host overrides, nested references, safe JSON string escaping, @@ -139,7 +135,8 @@ workstation issuer. ## 4. Disposable acceptance and production handover -Implementation complete; live DNS acceptance remains parked. The disposable +Implementation complete; live publication and repeat apply passed on the +monitoring VM. Publication after reboot and owned disposal passed; see the acceptance log. The disposable provisioner persists VM/environment/zone identity, a unique test ownership marker, exact configured record names, token identity and cleanup-token state before provider mutation. It records created record IDs after verifying the diff --git a/ublue/plan/CLAMPS-DATADOG.md b/ublue/plan/CLAMPS-DATADOG.md index 37051f55..dfca5207 100644 --- a/ublue/plan/CLAMPS-DATADOG.md +++ b/ublue/plan/CLAMPS-DATADOG.md @@ -3,14 +3,26 @@ Status, 2026-10-07: user approved the rootful Podman runtime. Service-owned labels, optional Agent recipe, separate apply phase, templates, and shared encrypted delivery are implemented. No workstation agent was installed. -Live uCore/telemetry acceptance remains pending; see `../butane/ACCEPTANCE.md`. - -Validated, 2026-10-07: 291 workspace tests, pedantic Clippy, formatting, -Fedora base integration, eight bootstrap regressions, and actual Agent Quadlet -generation passed. Live acceptance is blocked here by absent `/dev/kvm`. -Next: supply test environment API-key/site leaves, then execute section 4's -named-VM Agent checks on a workstation with KVM. Private ping targets and richer -application metrics remain additional implementation work. +Live startup/submission acceptance passed. Automatic application discovery after +reboot and eBPF tracing remain failed; see `../butane/ACCEPTANCE.md`. + +Validated, 2026-10-07: formatting, pedantic Clippy, 299 workspace tests, +Fedora base repeat apply, and named-VM Agent startup/delivery passed. The earlier +missing KVM observation was sandbox visibility; native session libvirt works. +`clamps-test-monitoring` confirmed Agent 7.84.1, SELinux enforcing, hostname, +valid API key, successful submissions, default host/container checks, Syncthing +HTTP health and four process instances, and no-op container identities. +Live acceptance found and fixed socket-as-directory convergence, HTML escaping +of Quadlet labels, and shadowing of built-in integrations. eBPF tracing remains +failed: perf probe attachment returned “function not implemented” and fallback +registration could not write to read-only tracing files. Review the required +kernel/seccomp/tracing access before claiming network-monitoring parity. +Named-VM smoke passed. Both optional services and submissions recover after +reboot, but HTTP/process Autodiscovery does not: restarting the Agent restores +one HTTP and four process checks. Fix boot discovery/reconciliation before +claiming application monitoring recovery. Owned Cloudflare/Tailscale cleanup +and VM disposal passed; evidence is in the acceptance log. +Private ping targets and richer application metrics remain additional work. Read `../AGENTS.md`, `../skillet/AGENTS.md`, `../design/monitoring.md`, `../design/secrets.md`, and `../design/configuration-templates.md`. @@ -49,7 +61,7 @@ Reviewable runtime boundary: - Rootful Agent with host PID/cgroup namespaces and host networking for container HTTP probes; no published Agent, DogStatsD, or APM ports. -- Rootful `/run/podman/podman.sock` mounted read-only for supported Docker API +- Rootful `/run/podman` directory mounted read-only, exposing `podman.sock`, for supported Docker API Autodiscovery. Enable/start `podman.socket` only for the opted-in agent. The socket can mutate containers despite the read-only bind mount. - Read-only `/proc`, `/sys/fs/cgroup`, `/run/systemd`, and host filesystem mounts @@ -76,13 +88,13 @@ require Datadog credentials or start telemetry. Implemented: template, schema validation, native Podman environment secrets, separate credential-gated phase, production delivery, explicit disposable enrollment, and authoritative `env:prod`/`env:test` tags. The official image's -startup requires `DD_API_KEY`; file-secret injection is not used. Live delivery -and ingestion remain unverified. +startup requires `DD_API_KEY`; file-secret injection is not used. Live encrypted delivery, API-key validation and successful submissions passed; +account-side graph/dashboard acceptance remains separate. -- Add a public `datadog` template with typed references to planned vault leaves - `skillet/environments//datadog/api-key` and - `skillet/environments//datadog/site`. Host overrides can select - a different API key and private tags. Do not require an application key for +- Use the public `datadog` template with one typed secret reference to + `skillet/datadog/api-key`, shared by production and test. Site is ordinary + template configuration, defaulting to `datadoghq.com`. Host overrides can + select a different site and private tags. Do not require an application key for ingestion. Version and validate the rendered payload before delivery. - Add a shared `--phase datadog`, `skillet-datadog-apply.service`, and `datadog_config` encrypted credential. Production command: @@ -90,16 +102,32 @@ and ingestion remain unverified. - Mount API keys as native Podman secrets, preferably files if supported by the pinned image; native secret environment injection is an explicit supported fallback. Never use literal `Environment=DD_API_KEY=...`. -- Disposable enrollment is explicit through `--with-datadog`, with test-only - credentials and the actual VM hostname; record that remote telemetry/host - retention is distinct from VM/token cleanup. Do not reuse production keys - silently. Use dummy credentials and disabled networking for offline checks. +- Disposable enrollment is explicit through `--with-datadog`, with the shared + key, `env:test` tagging, and the actual VM hostname; record that remote + telemetry/host retention is distinct from VM/token cleanup. Use dummy + credentials and disabled networking for offline checks. Exit: successful template validation, deferred activation, rotation, and missing credential handling; no vault/API requirement in routine CI. ## 4. Checks and acceptance +Next implementation slice, from live acceptance: + +1. Fix container-label discovery at boot. Reproduce on a fresh named VM with + concurrent application startup; evaluate supported provider polling/event + reconciliation or explicit declared startup dependencies. Keep shared service + selection authoritative. Do not rely on a manual Agent restart or fixed sleep. + Add offline regressions for the selected policy; reboot must restore Syncthing + HTTP and each enabled process check without workstation intervention. +2. Resolve eBPF probe attachment separately. Inspect effective seccomp, kernel + support and tracefs/debugfs access; use official Agent requirements and the + narrowest supported runtime policy. Test with real uCore SELinux and report + Secure Boot state explicitly. Do not claim parity from an active container or + host interface counters. Document any additional privileges before enabling + them; do not silently broaden the Agent boundary. + + - Agent owns shared host system/process/network/Btrfs metrics and localhost SSH TCP check. Aggregate systemd unit contributions from the canonical service declarations, including Btrbk's timer; do not maintain a second service list. diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 2f5984e9..8ce415a9 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -18,8 +18,8 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | btrbk | Caller-selected Syncthing snapshots, hourly timer, retention and restore accepted | Off-machine backup policy and physical cutover acceptance; nested source subvolumes unsupported | | UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply acceptance | Isolated backup restore, version compatibility, adoption and Site Manager access; see `CLAMPS-UNIFI.md` | | Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | -| Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Live disposable DNS acceptance (pending test DDNS name entry), private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | -| Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live uCore/ingestion acceptance and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | +| Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Explicit rotation acceptance and private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | +| Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks and submissions passed; eBPF tracing, automatic post-reboot application discovery and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | | EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | | Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | | Users, SSH, sudo, shell/dotfiles | Generic user environment/bootstrap and hardening exist; user acceptance previously passed | Compare additional Chef-managed accounts, permissions, and relevant hardening settings. Dotfiles install hook was explicitly deferred | @@ -36,9 +36,11 @@ exists. Additional private run-list recipes remain an inventory question. ## Work order -1. Complete live disposable DDNS acceptance once the test-only name entry exists; +1. Disposable DDNS publication/recovery/cleanup passed; finish explicit rotation + acceptance and privately inventory production records; do not mutate production DNS. -2. Port Datadog, then handle isolated UniFi backup/restore planning and execution. +2. Close Datadog eBPF compatibility and remaining application monitoring gaps, + then handle isolated UniFi backup/restore planning and execution. 3. Close exit-node and remaining host-baseline gaps before cutover. 4. Run the parked VM and deferred network/production acceptance when their required infrastructure is available; transfer state only with a recovery path. From 196ce80bd8ddb0fb96a314f82a40d1a8699e4070 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 8 Oct 2026 10:11:31 +0200 Subject: [PATCH 214/252] fix(datadog): order boot discovery after monitored workloads --- ublue/butane/ACCEPTANCE.md | 37 +++++++++++++++ ublue/design/monitoring.md | 20 ++++++-- ublue/plan/CLAMPS-DATADOG.md | 36 ++++++++------- ublue/plan/CLAMPS-PARITY.md | 2 +- ublue/skillet/AGENTS.md | 4 ++ ublue/skillet/crates/datadog/src/runtime.rs | 12 ++++- .../crates/datadog/src/runtime/tests.rs | 46 +++++++++++++++++++ 7 files changed, 133 insertions(+), 24 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 78253c83..3e8ab227 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,42 @@ # Clamps VM acceptance log +## Datadog automatic boot discovery, 2026-10-08 + +- Implemented Agent `After=` ordering from the existing caller-selected + monitored unit list. No application `Wants=` or `Requires=` was added; missing + optional credentials do not activate their consumers. Reject Agent self-ordering + before any mutation. No changes to container privileges or eBPF policy. +- Formatting, pedantic workspace Clippy, all 301 workspace tests and Fedora + base integration passed on 2026-10-07. Repeat base apply issued no service + activation. The actual Quadlet compiler retained the ordering. New offline + regressions cover caller-provided units, ordering versus activation, and + self-dependency rejection; routine CI needs no vault or external runtime. +- Created fresh `clamps-test-discovery` on port 2202 from the working-tree fix + based on `869c438`; the retained smoke VM on port 2201 was untouched. The + source capture includes the fix. Signed boot/readiness and Pi-hole, Tailscale, + DDNS and Datadog provisioning succeeded. Local debug image hashing was stopped + before VM definition and the recorded preparation resumed using release CLI. +- Initial status: one Syncthing HTTP check, four process instances, no current + check/submission errors. Direct owned reboot automatically restored the same + checks without an Agent restart. Startup timestamps confirmed the five active + monitored containers started before the Agent. Status was sampled again after + initial Agent warm-up; an early probe before auth-token creation was inconclusive. +- Repeating the Datadog credential phase preserved every container ID. + `test smoke clamps --instance discovery` passed all fixture cases across a + second reboot and readiness recovery, using release binaries. Smoke initially + refused the Started lifecycle state after direct reboot; explicit `test vm + ready` completed before retry. `--with-applications` was not used. +- After the second reboot: one HTTP and four process checks, no current check, + Autodiscovery or submission errors; Agent `NRestarts=0`. No manual Agent + restart was used during either recovery check. Caddy was not provisioned; + later application-start/event reconciliation is not established by this test. +- eBPF remains the separate unresolved acceptance gap from 2026-10-07. This + slice changes boot ordering only; it does not establish probe/kernel/Secure + Boot compatibility or richer application metrics. +- Owned Cloudflare/Tailscale cleanup and VM disposal completed successfully. + The run directory was removed; the retained smoke VM remained running. + Private zones, tokens and status payloads were kept out of tracked files. + ## DDNS and Datadog named-VM acceptance, 2026-10-07 - Used separate `clamps-test-monitoring`, instance `monitoring`, SSH port 2202, diff --git a/ublue/design/monitoring.md b/ublue/design/monitoring.md index fe6c424f..3a34c631 100644 --- a/ublue/design/monitoring.md +++ b/ublue/design/monitoring.md @@ -15,7 +15,19 @@ shared Datadog library renders and escapes those labels. Checks use discovered container addresses, so private UI ports stay unpublished. Container/process liveness and application correctness are different checks; process presence does not establish DNS publication, synchronization, or controller health. -Non-container services will contribute explicit systemd units to the host check. +Non-container services contribute explicit systemd units to the host check. + +Boot discovery policy: order the Agent after the same caller-selected monitored +units, using `After=` without `Wants=` or additional `Requires=` dependencies. +The pinned Agent collector lists existing containers once, then consumes events; +Podman's boot-time event path did not recover application labels in acceptance. +Ordering makes the initial inventory follow concurrent application startup and +leaves services without delivered credentials inactive. It does not guarantee +reconciliation of missing events later in runtime. Verify reboot discovery on +real Podman; provider polling is not a supported replacement for the pinned +streaming container provider. Two live reboots restored application checks +without restarting the Agent; see the acceptance record. + Approved agent deployment, 2026-10-07: a pinned official rootful Agent container managed by Skillet. Datadog recommends container deployment for Podman over native host @@ -63,12 +75,12 @@ confirmed host/container checks, Autodiscovery, and successful submissions with SELinux enforcing. eBPF tracing remains unaccepted: the VM returned unsupported perf probe attachment and read-only fallback tracing errors. Review the required seccomp/kernel/tracing access separately before changing privileges. Pending: -automatic post-reboot container-label discovery (Agent restart currently -restores application checks), private ping targets, richer +private ping targets and richer Syncthing metrics and Pi-hole's authenticated API integration. The existing Pi-hole community integration requires compatibility review against Pi-hole 6. -Sources: [Podman deployment](https://docs.datadoghq.com/containers/guide/podman-support-with-docker-integration/), +Sources: [pinned container collector](https://github.com/DataDog/datadog-agent/blob/8e795c5e/comp/core/workloadmeta/collectors/internal/docker/docker.go), +[Podman deployment](https://docs.datadoghq.com/containers/guide/podman-support-with-docker-integration/), [Autodiscovery](https://docs.datadoghq.com/containers/docker/integrations/), [network monitoring](https://docs.datadoghq.com/network_monitoring/cloud_network_monitoring/setup/), [Btrfs](https://docs.datadoghq.com/integrations/btrfs/), diff --git a/ublue/plan/CLAMPS-DATADOG.md b/ublue/plan/CLAMPS-DATADOG.md index dfca5207..2d8dd61c 100644 --- a/ublue/plan/CLAMPS-DATADOG.md +++ b/ublue/plan/CLAMPS-DATADOG.md @@ -1,12 +1,13 @@ # Datadog port -Status, 2026-10-07: user approved the rootful Podman runtime. Service-owned -labels, optional Agent recipe, separate apply phase, templates, and shared -encrypted delivery are implemented. No workstation agent was installed. -Live startup/submission acceptance passed. Automatic application discovery after -reboot and eBPF tracing remain failed; see `../butane/ACCEPTANCE.md`. - -Validated, 2026-10-07: formatting, pedantic Clippy, 299 workspace tests, +Status, 2026-10-08: rootful Agent, credential delivery and caller-derived boot +ordering are implemented. Two reboots of `clamps-test-discovery` automatically restored one Syncthing +HTTP and four process checks, without an Agent restart. Offline checks pass +(301 tests, Clippy, formatting, Fedora repeat apply), and named-VM smoke passed. +Owned Cloudflare/Tailscale cleanup and VM disposal passed. eBPF tracing +and richer metrics remain pending; see `../butane/ACCEPTANCE.md`. + +Prior validation, 2026-10-07: formatting, pedantic Clippy, 299 workspace tests, Fedora base repeat apply, and named-VM Agent startup/delivery passed. The earlier missing KVM observation was sandbox visibility; native session libvirt works. `clamps-test-monitoring` confirmed Agent 7.84.1, SELinux enforcing, hostname, @@ -55,7 +56,7 @@ Autodiscovery placeholders; relevant local CI checks pass. Implemented: official Agent container as an optional canonical host service. Clamps opts in. Agent image index evaluated for this port: `registry.datadoghq.com/agent@sha256:161a43ad2b290f7527a70e70c8880c1b3d65b39411552a8cb41398049546daf5`. -Pinned metadata reports Agent 7.84.1; runtime version still needs live acceptance. +Pinned metadata and live runtime both report Agent 7.84.1. Reviewable runtime boundary: @@ -112,14 +113,15 @@ credential handling; no vault/API requirement in routine CI. ## 4. Checks and acceptance -Next implementation slice, from live acceptance: +Follow-ups from live acceptance: -1. Fix container-label discovery at boot. Reproduce on a fresh named VM with - concurrent application startup; evaluate supported provider polling/event - reconciliation or explicit declared startup dependencies. Keep shared service - selection authoritative. Do not rely on a manual Agent restart or fixed sleep. - Add offline regressions for the selected policy; reboot must restore Syncthing - HTTP and each enabled process check without workstation intervention. +1. Implemented: order Agent startup after caller-declared monitored units, + without activating optional consumers. Pinned workload metadata/provider + source uses an initial inventory followed by events, not periodic collection. + Offline tests cover caller-derived order, absence of activation dependencies, + and self-dependency rejection. Both live reboots and repeat apply passed; + named-VM smoke passed. Later runtime event + reconciliation remains outside this boot fix's evidence. 2. Resolve eBPF probe attachment separately. Inspect effective seccomp, kernel support and tracefs/debugfs access; use official Agent requirements and the narrowest supported runtime policy. Test with real uCore SELinux and report @@ -138,8 +140,8 @@ Next implementation slice, from live acceptance: do not claim that HTTP health establishes folder/device synchronization. - Review Pi-hole 6 authentication and community-check compatibility before installing it. DNS-query checks and API statistics must have separate evidence. -- DDNS process liveness is insufficient to prove publication; retain its - separately parked ownership/publication acceptance. +- DDNS process liveness is insufficient to prove publication; keep its separate + ownership/publication acceptance evidence and production cutover plan. - Named VM: verify Agent status, detected containers, check success, host rather than container metrics, Btrfs data, systemd units, eBPF under Secure Boot, no-op/reboot recovery, and explicit test ingestion. Record results in diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 8ce415a9..71307ccf 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -19,7 +19,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply acceptance | Isolated backup restore, version compatibility, adoption and Site Manager access; see `CLAMPS-UNIFI.md` | | Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | | Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Explicit rotation acceptance and private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | -| Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks and submissions passed; eBPF tracing, automatic post-reboot application discovery and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | +| Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks, submissions and automatic boot discovery passed; eBPF tracing and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | | EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | | Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | | Users, SSH, sudo, shell/dotfiles | Generic user environment/bootstrap and hardening exist; user acceptance previously passed | Compare additional Chef-managed accounts, permissions, and relevant hardening settings. Dotfiles install hook was explicitly deferred | diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 3c0914b3..4ee8be45 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -97,6 +97,10 @@ tracks existing violations and their migration, not completed implementation. conflicts between typed fields and extension directives; preserve ordering where repeated directives are semantically ordered. Global runtime policy belongs to the host baseline, not an arbitrary container resource. +- Discovery acceptance must cover concurrent boot startup as well as steady + state. Derive startup ordering from existing caller declarations and keep + ordering separate from dependencies that activate optional services; an + active observer alone does not prove it discovered the intended workloads. - State what every convergence result means. Distinguish definition changes, activation, recovery, and no-op outcomes. Persist applied state only after the consuming operation succeeds and retry pending activation after interruption. diff --git a/ublue/skillet/crates/datadog/src/runtime.rs b/ublue/skillet/crates/datadog/src/runtime.rs index f98000ea..2b5e8307 100644 --- a/ublue/skillet/crates/datadog/src/runtime.rs +++ b/ublue/skillet/crates/datadog/src/runtime.rs @@ -115,7 +115,8 @@ where .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') || runtime.monitored_units.is_empty() || runtime.monitored_units.iter().any(|unit| { - !matches!(unit.rsplit_once('.'), Some((_, "service" | "timer"))) + unit == "datadog-agent.service" + || !matches!(unit.rsplit_once('.'), Some((_, "service" | "timer"))) || !unit .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) @@ -262,7 +263,14 @@ fn container_config( "Unit".into(), vec![ "Requires=podman.socket".into(), - "After=podman.socket network-online.target".into(), + // The Agent's container metadata collector takes an initial + // inventory and then consumes runtime events. Order that + // inventory after concurrent application startup, without + // pulling in services whose credentials are not delivered. + format!( + "After=podman.socket network-online.target {}", + runtime.monitored_units.join(" ") + ), ], ), ("Service".into(), vec!["Restart=always".into()]), diff --git a/ublue/skillet/crates/datadog/src/runtime/tests.rs b/ublue/skillet/crates/datadog/src/runtime/tests.rs index 13ca32fa..bfd4e76f 100644 --- a/ublue/skillet/crates/datadog/src/runtime/tests.rs +++ b/ublue/skillet/crates/datadog/src/runtime/tests.rs @@ -89,6 +89,52 @@ fn network_capabilities_are_optional() { assert!(quadlet.contains("DD_SYSTEM_PROBE_ENABLED=false")); } +#[test] +fn startup_order_follows_caller_units_without_enabling_them() { + let system = MockSystem::new(); + let files = mounted_files(); + let units = vec![ + "example-app.service".into(), + "example-optional.service".into(), + ]; + let mut config = runtime(false); + config.monitored_units = &units; + apply( + &system, + &files, + &payload("0123456789abcdef0123456789abcdef"), + &config, + ) + .unwrap(); + let state = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&state["/etc/containers/systemd/datadog-agent.container"]); + assert!(quadlet.contains( + "After=podman.socket network-online.target example-app.service example-optional.service" + )); + assert!(quadlet.lines().any(|line| line == "Requires=podman.socket")); + assert!(!quadlet.contains("Wants=")); + assert!(!quadlet.contains("Requires=example")); +} + +#[test] +fn rejects_agent_self_dependency_before_mutation() { + let system = MockSystem::new(); + let files = mounted_files(); + let units = vec!["datadog-agent.service".into()]; + let mut config = runtime(false); + config.monitored_units = &units; + assert!(apply( + &system, + &files, + &payload("0123456789abcdef0123456789abcdef"), + &config, + ) + .is_err()); + assert!(files.files.lock().unwrap().is_empty()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); +} + #[test] fn repeats_are_noops_and_rotation_retries_failed_activation() { let system = MockSystem::new(); From 59fea9d964b933454c7897395aef45cb8b38f95d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 8 Oct 2026 10:56:46 +0200 Subject: [PATCH 215/252] feat(datadog): separate lab telemetry from production regions --- SECRETS.md | 3 +- ublue/butane/ACCEPTANCE.md | 17 ++++++++ ublue/design/monitoring.md | 10 ++++- ublue/plan/CLAMPS-DATADOG.md | 15 +++++++ ublue/skillet/README.md | 13 +++++- ublue/skillet/crates/datadog/src/runtime.rs | 22 +++++++++- .../crates/datadog/src/runtime/tests.rs | 43 ++++++++++++++++++- ublue/skillet/crates/host-profiles/src/lib.rs | 4 +- .../crates/host-profiles/src/profile.rs | 2 + .../workstation/src/credential_delivery.rs | 14 +++++- .../src/credential_delivery/tests.rs | 24 ++++++++++- .../workstation/src/secret-requirements.json | 5 ++- 12 files changed, 159 insertions(+), 13 deletions(-) diff --git a/SECRETS.md b/SECRETS.md index 1bfaf223..c993bae7 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -38,11 +38,12 @@ Shared inputs for Caddy and DDNS. ## Datadog -One shared key serves production and test. No application key required. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog). +One shared key serves production and test. No application key required for ingestion. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog). | Entry | Status | Password value / how to obtain it | | --- | --- | --- | | `skillet/datadog/api-key` | Required | Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | +| `skillet/datadog/application-key` | Optional | For workstation monitor administration only: create a scoped Application Key in Datadog Organization Settings → Application Keys with monitors_read and monitors_write. Never deliver this key to hosts. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | ## DDNS diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 3e8ab227..da43c1b1 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,22 @@ # Clamps VM acceptance log +## Datadog test alert isolation, 2026-10-08 + +- Credential delivery retains `env:prod`/`env:test` and now derives production + region from the canonical host caller (`ftwo` for Clamps). Every test uses + `region:lab`; conflicting template region tags are replaced. +- Formatting, pedantic workspace Clippy, workspace tests and the Fedora base + scenario passed. New offline tests cover production/test tag policy, different + caller regions, retained unrelated tags, conflicting template regions, + idempotent rendering, invalid inputs and stdin-only delivery. Fedora repeat + apply issued no service start/restart. +- Provider monitor edits remain pending: the ingestion API key does not grant + monitor administration. The optional workstation application key requires + `monitors_read` and `monitors_write`. No monitors were changed or claimed muted. +- Live Datadog tag/submission acceptance is deferred until test monitor scopes + exclude `env:test`, to avoid generating more unwanted notifications. No VM + was created, provisioned or destroyed for this slice. + ## Datadog automatic boot discovery, 2026-10-08 - Implemented Agent `After=` ordering from the existing caller-selected diff --git a/ublue/design/monitoring.md b/ublue/design/monitoring.md index 3a34c631..19bf1f00 100644 --- a/ublue/design/monitoring.md +++ b/ublue/design/monitoring.md @@ -64,7 +64,15 @@ requires `DD_API_KEY` at startup, so Podman injects API key and private tags through native environment secrets; the public site uses an ordinary `Environment=DD_SITE` directive. No literal secret-bearing `Environment=` directives are written. Production and test use the shared key; -delivery adds an authoritative environment tag. Test +delivery adds an authoritative environment tag and region. Production region +is declared by the host caller (Clamps inherits Chef's `region:ftwo`); every +test deployment uses `region:lab`, replacing any template region tags. +Monitor notification policy excludes `env:test` in the data query, preserving +legacy production hosts with `env:home` or no environment tag. Monitor metadata +tags alone do not filter host data. One-time monitor administration uses a +separate optional workstation-only application key; it is never delivered to +the Agent. API changes and their live validation are pending until that key is +available. Test telemetry requires explicit `--with-datadog`; destroying a VM does not remove historical telemetry from Datadog. diff --git a/ublue/plan/CLAMPS-DATADOG.md b/ublue/plan/CLAMPS-DATADOG.md index 2d8dd61c..0c3d5a94 100644 --- a/ublue/plan/CLAMPS-DATADOG.md +++ b/ublue/plan/CLAMPS-DATADOG.md @@ -111,6 +111,21 @@ account-side graph/dashboard acceptance remains separate. Exit: successful template validation, deferred activation, rotation, and missing credential handling; no vault/API requirement in routine CI. +## Test alert isolation + +Implemented: authoritative `env:prod`/`env:test` tagging plus caller-declared +production region (`ftwo` on Clamps) and `lab` for every test deployment. +Templates cannot override the region policy. Optional workstation administration +key: `skillet/datadog/application-key`, with `monitors_read`/`monitors_write`. + +Pending live one-time setup: inventory existing monitors through the API, exclude +`env:test` from relevant host/service queries, validate before updating, retain a +private rollback snapshot, and read back changes. Preserve existing Chef +`env:home` coverage, thresholds, PagerDuty routing and production no-data alerts. +Check existing historical test groups after the update; tags do not erase old +telemetry. Re-deliver Datadog credentials to any retained test hosts for `lab`. +Do not claim alert suppression before provider-side verification. + ## 4. Checks and acceptance Follow-ups from live acceptance: diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 6c5229bf..22bf21cf 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -13,7 +13,9 @@ For an opted-in host, create this KeePassXC entry (Password field): - `skillet/datadog/api-key`: shared Datadog ingestion API key. Production and test use the same key; telemetry is tagged `env:prod` or -`env:test`. No application key is required. Site is public configuration in +`env:test`. Production region comes from the host caller (Clamps: `region:ftwo`); +all tests use `region:lab`. Delivery replaces template region tags. +No application key is required for ingestion. Site is public configuration in the `datadog` default template in [configuration-templates.json](crates/workstation/src/configuration-templates.json), defaulting to `datadoghq.com`. Change it there for another Datadog region; @@ -27,6 +29,15 @@ identity, known-hosts, and target options. For an owned disposable VM, use through the existing `secret unlock` workflow. Ordinary provisioning and smoke do not enable Datadog. Historical telemetry remains after VM destruction. +For one-time monitor administration, optionally add +`skillet/datadog/application-key` (Password field) with `monitors_read` and +`monitors_write` scopes from Datadog Organization Settings → Application Keys. +This credential stays on the workstation. Exclude `env:test` in host/service +monitor data scopes; retain coverage for legacy Chef hosts tagged `env:home`. +Changing monitor metadata tags does not exclude matching telemetry. +Existing running test hosts need Datadog credential redelivery for the region +change; destroying a VM does not erase historical telemetry. + Skillet is a Rust tool for idempotent host configuration management. Design decisions: [secret storage and delivery](../design/secrets.md) and diff --git a/ublue/skillet/crates/datadog/src/runtime.rs b/ublue/skillet/crates/datadog/src/runtime.rs index 2b5e8307..a9050282 100644 --- a/ublue/skillet/crates/datadog/src/runtime.rs +++ b/ublue/skillet/crates/datadog/src/runtime.rs @@ -42,8 +42,17 @@ pub struct Input { } impl Input { - pub fn render_for_environment(mut self, environment: &str) -> Result { - if !matches!(environment, "prod" | "test") { + pub fn render_for_environment( + mut self, + environment: &str, + production_region: &str, + ) -> Result { + if !matches!(environment, "prod" | "test") + || production_region.is_empty() + || !production_region + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { return Err(DatadogError::Invalid); } let expected = format!("env:{environment}"); @@ -57,6 +66,15 @@ impl Input { if !self.tags.contains(&expected) { self.tags.push(expected); } + let region = if environment == "test" { + "lab" + } else { + production_region + }; + // Region is deployment policy: templates cannot move test telemetry + // into a production location or override the host caller's location. + self.tags.retain(|tag| !tag.starts_with("region:")); + self.tags.push(format!("region:{region}")); serde_json::to_string(&self).map_err(|_| DatadogError::Invalid) } diff --git a/ublue/skillet/crates/datadog/src/runtime/tests.rs b/ublue/skillet/crates/datadog/src/runtime/tests.rs index bfd4e76f..094ee9a5 100644 --- a/ublue/skillet/crates/datadog/src/runtime/tests.rs +++ b/ublue/skillet/crates/datadog/src/runtime/tests.rs @@ -160,10 +160,49 @@ fn rejects_missing_storage_bad_credentials_and_conflicting_environment() { assert!(files.files.lock().unwrap().is_empty()); assert!(system.podman_secrets.lock().unwrap().is_empty()); let input = Input::parse(&payload("0123456789abcdef0123456789abcdef")).unwrap(); - assert!(input.render_for_environment("prod").is_err()); + assert!(input + .render_for_environment("prod", "example-region") + .is_err()); let input = Input::parse(&payload("0123456789abcdef0123456789abcdef")).unwrap(); assert!(input - .render_for_environment("test") + .render_for_environment("test", "example-region") .unwrap() .contains("env:test")); } + +#[test] +fn environment_and_region_follow_deployment_policy() { + for (environment, production_region, expected_region) in [ + ("prod", "example-region", "example-region"), + ("test", "example-region", "lab"), + ("test", "another-region", "lab"), + ] { + let payload = json!({"version":1,"api_key":"0123456789abcdef0123456789abcdef", + "site":"datadoghq.com","tags":["region:old", "region:duplicate", "role:sync"]}) + .to_string(); + let rendered = Input::parse(&payload) + .unwrap() + .render_for_environment(environment, production_region) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!( + value["tags"], + json!([ + "role:sync", + format!("env:{environment}"), + format!("region:{expected_region}") + ]) + ); + let again = Input::parse(&rendered) + .unwrap() + .render_for_environment(environment, production_region) + .unwrap(); + assert_eq!(rendered, again); + } + for (environment, region) in [("unknown", "valid"), ("prod", ""), ("test", "bad region")] { + assert!(Input::parse(&payload("0123456789abcdef0123456789abcdef")) + .unwrap() + .render_for_environment(environment, region) + .is_err()); + } +} diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index 186e4999..eb9456f2 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -391,7 +391,9 @@ fn apply_datadog_host( .services .iter() .find_map(|service| match service.config { - ServiceConfig::Datadog { network_monitoring } => Some(network_monitoring), + ServiceConfig::Datadog { + network_monitoring, .. + } => Some(network_monitoring), _ => None, }) .ok_or_else(|| ApplyError::FixtureInput("host does not declare Datadog".into()))?; diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index 051cfe8c..27086044 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -54,6 +54,7 @@ pub enum ServiceConfig { Unifi, Ddns, Datadog { + production_region: &'static str, network_monitoring: bool, }, Tailscale { @@ -477,6 +478,7 @@ fn clamps() -> HostProfile { }, HostService { config: ServiceConfig::Datadog { + production_region: "ftwo", network_monitoring: true, }, ui: None, diff --git a/ublue/skillet/crates/workstation/src/credential_delivery.rs b/ublue/skillet/crates/workstation/src/credential_delivery.rs index c99bd862..f9c08e4e 100644 --- a/ublue/skillet/crates/workstation/src/credential_delivery.rs +++ b/ublue/skillet/crates/workstation/src/credential_delivery.rs @@ -34,8 +34,18 @@ pub fn deliver_datadog_credential( let profile = skillet_hosts::profile_for_name(host) .filter(|profile| profile.supports_service("datadog")) .ok_or_else(|| CredentialDeliveryError::Invalid("host does not declare Datadog".into()))?; - let input = - skillet_datadog::Input::parse(input)?.render_for_environment(policy.vault_name())?; + let production_region = profile + .services + .iter() + .find_map(|service| match service.config { + skillet_hosts::ServiceConfig::Datadog { + production_region, .. + } => Some(production_region), + _ => None, + }) + .ok_or_else(|| CredentialDeliveryError::Invalid("host does not declare Datadog".into()))?; + let input = skillet_datadog::Input::parse(input)? + .render_for_environment(policy.vault_name(), production_region)?; credential::install( guest, profile.id.as_str(), diff --git a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs index 8cd1fe33..347bc898 100644 --- a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs +++ b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs @@ -67,7 +67,7 @@ fn datadog_delivery_validates_capability_and_sends_only_stdin() { .iter() .any(|(_, input)| input.as_ref().is_some_and(|bytes| { let value = String::from_utf8_lossy(bytes); - value.contains(key) && value.contains("env:test") + value.contains(key) && value.contains("env:test") && value.contains("region:lab") }))); } @@ -262,3 +262,25 @@ fn disposable_pihole_inspection_errors_do_not_generate_a_replacement() { assert!(ensure_disposable_pihole_credential("clamps", false, &guest).is_err()); assert_eq!(guest.calls.lock().unwrap().len(), 1); } + +#[test] +fn datadog_production_delivery_uses_caller_region() { + let guest = FakeGuest::default(); + let payload = serde_json::json!({"version":1,"api_key":"0123456789abcdef0123456789abcdef", + "site":"datadoghq.com","tags":["region:obsolete"]}) + .to_string(); + deliver_datadog_credential( + "clamps", + ProvisioningPolicy::new(Environment::Production), + &payload, + &guest, + ) + .unwrap(); + let calls = guest.calls.lock().unwrap(); + let input = calls.iter().find_map(|(_, input)| input.as_ref()).unwrap(); + let value: serde_json::Value = serde_json::from_slice(input).unwrap(); + assert_eq!( + value["tags"], + serde_json::json!(["env:prod", "region:ftwo"]) + ); +} diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json index 8f073bfe..2969ee12 100644 --- a/ublue/skillet/crates/workstation/src/secret-requirements.json +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -9,8 +9,9 @@ {"path":"skillet/environments//dns/cloudflare-zone-id","status":"required","help":"Copy the Zone ID from the selected zone's Cloudflare Overview page."}, {"path":"skillet/environments//dns/ui-domain","status":"optional","help":"Choose a relative UI prefix, such as ui; absent defaults to ui. Skillet appends the zone domain. Do not enter a full domain."} ]}, - {"module":"Datadog","required_by":["datadog"],"template":"datadog","description":"One shared key serves production and test. No application key required. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog).","entries":[ - {"path":"skillet/datadog/api-key","status":"required","help":"Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."} + {"module":"Datadog","required_by":["datadog"],"template":"datadog","description":"One shared key serves production and test. No application key required for ingestion. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog).","entries":[ + {"path":"skillet/datadog/api-key","status":"required","help":"Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."}, + {"path":"skillet/datadog/application-key","status":"optional","help":"For workstation monitor administration only: create a scoped Application Key in Datadog Organization Settings → Application Keys with monitors_read and monitors_write. Never deliver this key to hosts. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."} ]}, {"module":"DDNS","required_by":["ddns"],"template":"ddns","description":"Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skillet/README.md#optional-cloudflare-ddns).","entries":[ {"path":"skillet/environments//hosts//cloudflare/ddns-dns-name","status":"required","help":"Choose the relative DNS record name, not a full domain."}, From 69e908d15d67ecd5fa75218905051224ceeeeb19 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 8 Oct 2026 11:30:56 +0200 Subject: [PATCH 216/252] docs(datadog): record test monitor exclusions and setup key --- SECRETS.md | 2 +- ublue/butane/ACCEPTANCE.md | 32 ++++++++++++++++--- ublue/design/monitoring.md | 7 ++-- ublue/plan/CLAMPS-DATADOG.md | 23 +++++++------ ublue/skillet/README.md | 4 ++- .../workstation/src/secret-requirements.json | 2 +- 6 files changed, 51 insertions(+), 19 deletions(-) diff --git a/SECRETS.md b/SECRETS.md index c993bae7..78e83ebd 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -43,7 +43,7 @@ One shared key serves production and test. No application key required for inges | Entry | Status | Password value / how to obtain it | | --- | --- | --- | | `skillet/datadog/api-key` | Required | Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | -| `skillet/datadog/application-key` | Optional | For workstation monitor administration only: create a scoped Application Key in Datadog Organization Settings → Application Keys with monitors_read and monitors_write. Never deliver this key to hosts. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | +| `skillet/datadog/monitor-setup-key` | Optional | For workstation monitor administration only: create a scoped Application Key in Datadog Organization Settings → Application Keys with monitors_read and monitors_write. Never deliver this key to hosts. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | ## DDNS diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index da43c1b1..b7fff128 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,25 @@ # Clamps VM acceptance log +## Datadog monitor setup, 2026-10-08 + +- Verified the API/application key pair from KeePassXC using + `skillet/datadog/api-key` and `skillet/datadog/monitor-setup-key`. + Key-pair validation and the six-monitor inventory returned HTTP 200. +- The all-host Agent no-data and NTP service-check queries now include + `.exclude("env:test")`. Both provider validations succeeded before mutations; + query-only updates returned HTTP 200 and read-back confirmed the exclusion. +- Compared options, messages, names, types, tags and priority before/after; + notification routing and production no-data policy were preserved. Other + monitors have explicit production targets and were not changed. Historical + test-group reevaluation and actual PagerDuty notification behavior were not + observed in this check. +- Complete original monitor snapshots are in a private local temporary + directory outside the repository. The setup key was used only in memory on + the workstation; no credentials or real provider identifiers were committed. +- Updated the secret inventory to the user's chosen setup-key path. No VM + operations or new Agent submissions were performed. Live region-tag + ingestion remains pending. + ## Datadog test alert isolation, 2026-10-08 - Credential delivery retains `env:prod`/`env:test` and now derives production @@ -10,11 +30,13 @@ caller regions, retained unrelated tags, conflicting template regions, idempotent rendering, invalid inputs and stdin-only delivery. Fedora repeat apply issued no service start/restart. -- Provider monitor edits remain pending: the ingestion API key does not grant - monitor administration. The optional workstation application key requires - `monitors_read` and `monitors_write`. No monitors were changed or claimed muted. -- Live Datadog tag/submission acceptance is deferred until test monitor scopes - exclude `env:test`, to avoid generating more unwanted notifications. No VM +- At this slice's validation, provider monitor edits were pending. The + subsequent monitor setup above completed the query exclusions using the + optional workstation application key; ingestion credentials alone cannot + administer monitors. +- Live Datadog tag/submission acceptance was deferred to avoid generating + unwanted notifications before monitor setup. Region-tag ingestion remains + unverified. No VM was created, provisioned or destroyed for this slice. ## Datadog automatic boot discovery, 2026-10-08 diff --git a/ublue/design/monitoring.md b/ublue/design/monitoring.md index 19bf1f00..1821c21d 100644 --- a/ublue/design/monitoring.md +++ b/ublue/design/monitoring.md @@ -71,8 +71,11 @@ Monitor notification policy excludes `env:test` in the data query, preserving legacy production hosts with `env:home` or no environment tag. Monitor metadata tags alone do not filter host data. One-time monitor administration uses a separate optional workstation-only application key; it is never delivered to -the Agent. API changes and their live validation are pending until that key is -available. Test +the Agent. One-time setup completed on 2026-10-08: the all-host Agent no-data and NTP +monitors exclude `env:test`. Provider validation, updates and query read-back +passed; notification settings were preserved. Other existing monitors target +production hosts explicitly. New monitors must preserve this exclusion. +Live acceptance of the new region tags remains pending. Test telemetry requires explicit `--with-datadog`; destroying a VM does not remove historical telemetry from Datadog. diff --git a/ublue/plan/CLAMPS-DATADOG.md b/ublue/plan/CLAMPS-DATADOG.md index 0c3d5a94..ca677e2d 100644 --- a/ublue/plan/CLAMPS-DATADOG.md +++ b/ublue/plan/CLAMPS-DATADOG.md @@ -116,15 +116,20 @@ credential handling; no vault/API requirement in routine CI. Implemented: authoritative `env:prod`/`env:test` tagging plus caller-declared production region (`ftwo` on Clamps) and `lab` for every test deployment. Templates cannot override the region policy. Optional workstation administration -key: `skillet/datadog/application-key`, with `monitors_read`/`monitors_write`. - -Pending live one-time setup: inventory existing monitors through the API, exclude -`env:test` from relevant host/service queries, validate before updating, retain a -private rollback snapshot, and read back changes. Preserve existing Chef -`env:home` coverage, thresholds, PagerDuty routing and production no-data alerts. -Check existing historical test groups after the update; tags do not erase old -telemetry. Re-deliver Datadog credentials to any retained test hosts for `lab`. -Do not claim alert suppression before provider-side verification. +key: `skillet/datadog/monitor-setup-key`, with `monitors_read`/`monitors_write`. + +Completed one-time setup, 2026-10-08: key-pair validation and six-monitor +inventory succeeded. Added `.exclude("env:test")` to the all-host Agent no-data +and NTP monitors. Both full-policy validations, query-only updates and read-back +checks returned HTTP 200. Notification options, thresholds, messages and routing +were unchanged. Private rollback snapshots were retained outside the repository. +Other monitors have explicit production targets. Existing Chef `env:home` and +untagged production hosts remain included. + +Pending: observe historical test-group reevaluation and re-deliver Datadog +credentials to retained test hosts for `lab`; live region-tag ingestion has not +been verified. New monitors must also exclude test data. No VM was changed by +the one-time provider setup. ## 4. Checks and acceptance diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 22bf21cf..3f4e5479 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -30,11 +30,13 @@ through the existing `secret unlock` workflow. Ordinary provisioning and smoke do not enable Datadog. Historical telemetry remains after VM destruction. For one-time monitor administration, optionally add -`skillet/datadog/application-key` (Password field) with `monitors_read` and +`skillet/datadog/monitor-setup-key` (Password field) with `monitors_read` and `monitors_write` scopes from Datadog Organization Settings → Application Keys. This credential stays on the workstation. Exclude `env:test` in host/service monitor data scopes; retain coverage for legacy Chef hosts tagged `env:home`. Changing monitor metadata tags does not exclude matching telemetry. +The all-host Agent no-data and NTP monitors were updated on 2026-10-08; +include the same test exclusion when creating new monitors. Existing running test hosts need Datadog credential redelivery for the region change; destroying a VM does not erase historical telemetry. diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json index 2969ee12..a7d55128 100644 --- a/ublue/skillet/crates/workstation/src/secret-requirements.json +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -11,7 +11,7 @@ ]}, {"module":"Datadog","required_by":["datadog"],"template":"datadog","description":"One shared key serves production and test. No application key required for ingestion. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog).","entries":[ {"path":"skillet/datadog/api-key","status":"required","help":"Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."}, - {"path":"skillet/datadog/application-key","status":"optional","help":"For workstation monitor administration only: create a scoped Application Key in Datadog Organization Settings → Application Keys with monitors_read and monitors_write. Never deliver this key to hosts. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."} + {"path":"skillet/datadog/monitor-setup-key","status":"optional","help":"For workstation monitor administration only: create a scoped Application Key in Datadog Organization Settings → Application Keys with monitors_read and monitors_write. Never deliver this key to hosts. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."} ]}, {"module":"DDNS","required_by":["ddns"],"template":"ddns","description":"Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skillet/README.md#optional-cloudflare-ddns).","entries":[ {"path":"skillet/environments//hosts//cloudflare/ddns-dns-name","status":"required","help":"Choose the relative DNS record name, not a full domain."}, From 7b01e86356f2b4e1989c3d2579a71896f72d273b Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 8 Oct 2026 14:07:15 +0200 Subject: [PATCH 217/252] fix(datadog): enable perf events under Podman seccomp --- ublue/butane/ACCEPTANCE.md | 48 +++++++++++++++++++ ublue/design/monitoring.md | 18 +++++-- ublue/plan/CLAMPS-DATADOG.md | 35 +++++++++----- ublue/plan/CLAMPS-PARITY.md | 7 +-- ublue/skillet/AGENTS.md | 4 ++ ublue/skillet/crates/datadog/src/runtime.rs | 5 +- .../crates/datadog/src/runtime/tests.rs | 5 +- 7 files changed, 101 insertions(+), 21 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index b7fff128..62c75842 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,5 +1,53 @@ # Clamps VM acceptance log +## Datadog NPM/USM and lab isolation, 2026-10-08 + +- Created fresh `clamps-test-datadog`, native `qemu:///session`, SSH 2202, from + `69e908d`. Signed boot, base/user-environment readiness, Pi-hole/Tailscale/full + application provisioning and `--with-datadog` succeeded with the cached vault. + Caddy and DDNS were outside this scoped run. The retained smoke VM was untouched. +- Baseline Agent had `SYS_ADMIN` but no `PERFMON`. A software perf-event syscall + returned ENOSYS (38); tracer logs reproduced unsupported perf attachment and + read-only fallback tracing failures. The installed seccomp profile explicitly + gates the perf-event allow rule on `CAP_PERFMON`. +- Deployed the working-tree fix through owned `test vm update`; original capture + evidence was preserved. Added `PERFMON` only for caller-selected network + monitoring. Effective default seccomp and read-only debugfs remained in place; + no privileged mode or extra host installation. The syscall then returned a + valid descriptor (4, errno 0). NPM/USM network tracer, event monitor and discovery + modules started. Kernel: `7.2.5-200.fc44.x86_64`; SELinux enforcing; BIOS VM, so + Secure Boot was unavailable and remains unverified. +- System-probe connection endpoint returned HTTP 200 and 63 connections. A private + Syncthing health request returned OK; six tracked connections involving port + 8384 had nonzero byte counters, HTTP classification and HTTP aggregates. These + are connection records, not a count of distinct requests. USM reported Running. +- Agent reported `env:test` / `region:lab`, successful submissions (56 core + forwarder transactions at one sample), no Autodiscovery errors, one Syncthing + HTTP check, three process checks, one Btrfs and one systemd check. Repeat + Datadog apply preserved every container ID; Agent `NRestarts=0`. +- Named fixture smoke and guarded reboot/readiness recovery passed. After reboot, + without manual Agent restart, `PERFMON`, default seccomp, USM Running, tracer + polling, tags and the same application check counts were present; successful + submissions reached 92 in the sampled boot. A new health request yielded ten + bridge records with HTTP classification, HTTP aggregates and nonzero byte + counters. Agent remained active/running with `NRestarts=0`; no ERROR logs in + the sampled two-minute steady-state window. Startup socket/remote-config + retries before warmup were transient. Readiness restores original captured + binaries by design; this reboot used the explicitly deployed fix at boot. +- Provider monitor read-back before/after reboot: both all-host monitors retain + their test exclusions and return zero test-host groups. Host-tag read-back + returned HTTP 403 with the monitor-only key; remote tag receipt and PagerDuty + incident delivery were not independently verified. Appropriate host-read + access is still needed for the former. +- Formatting, pedantic workspace Clippy, workspace tests and Fedora base repeat + apply passed. Regression assertions cover the explicit capability and retained + default seccomp/read-only debugfs; the non-network caller has no added caps. + Smoke evidence was generated under `/var/lib/skillet-smoke/`; private local + command logs are under `/tmp/skillet-datadog-*.log`. +- Owned Tailscale identity cleanup and VM disposal passed. The run directory + was removed and the retained smoke VM remained running. + + ## Datadog monitor setup, 2026-10-08 - Verified the API/application key pair from KeePassXC using diff --git a/ublue/design/monitoring.md b/ublue/design/monitoring.md index 1821c21d..32730005 100644 --- a/ublue/design/monitoring.md +++ b/ublue/design/monitoring.md @@ -43,6 +43,13 @@ container-management authority even with a read-only socket bind mount. Preserving Chef's network/service monitoring additionally needs the system-probe's eBPF capabilities and debugfs access. Those privileges must be explicit caller policy and tested on uCore with its Secure Boot/SELinux settings. +For network monitoring, explicitly select `PERFMON`: the installed Podman +seccomp profile gates `perf_event_open` on that capability. `SYS_ADMIN` alone +left the syscall returning ENOSYS in the VM. Preserve default seccomp and +read-only debugfs; do not disable syscall filtering or make tracing writable +unless a separate diagnosed requirement warrants it. Named-VM acceptance on 2026-10-08 confirmed perf events, NPM/USM traffic +collection and automatic recovery after reboot with SELinux enforcing. The VM +uses BIOS; Secure Boot remains a physical/UEFI acceptance requirement. The agent has a separate credential-gated apply phase. Installing Skillet or declaring a monitored service must not start telemetry without the Datadog @@ -75,7 +82,10 @@ the Agent. One-time setup completed on 2026-10-08: the all-host Agent no-data an monitors exclude `env:test`. Provider validation, updates and query read-back passed; notification settings were preserved. Other existing monitors target production hosts explicitly. New monitors must preserve this exclusion. -Live acceptance of the new region tags remains pending. Test +Agent status and successful submissions confirm the delivered test/lab tags; +provider host-tag read-back returned HTTP 403 with the monitor-only key. Both +monitors returned zero test-host groups before and after reboot. PagerDuty +incident delivery was not separately observed. Test telemetry requires explicit `--with-datadog`; destroying a VM does not remove historical telemetry from Datadog. @@ -83,9 +93,9 @@ Implemented: labels for Syncthing HTTP health and process liveness for Pi-hole, DDNS, UniFi, Caddy, and Tailscale; Agent deployment/delivery and host/process/network/Btrfs/SSH/systemd configuration. Named-VM acceptance confirmed host/container checks, Autodiscovery, and successful submissions with -SELinux enforcing. eBPF tracing remains unaccepted: the VM returned unsupported -perf probe attachment and read-only fallback tracing errors. Review the required -seccomp/kernel/tracing access separately before changing privileges. Pending: +SELinux enforcing. eBPF acceptance now passes with explicit `PERFMON`, default +seccomp and read-only debugfs. Real Syncthing bridge traffic was classified as +HTTP with nonzero byte counters and HTTP aggregates, also after reboot. Pending: private ping targets and richer Syncthing metrics and Pi-hole's authenticated API integration. The existing Pi-hole community integration requires compatibility review against Pi-hole 6. diff --git a/ublue/plan/CLAMPS-DATADOG.md b/ublue/plan/CLAMPS-DATADOG.md index ca677e2d..7a8248af 100644 --- a/ublue/plan/CLAMPS-DATADOG.md +++ b/ublue/plan/CLAMPS-DATADOG.md @@ -4,8 +4,12 @@ Status, 2026-10-08: rootful Agent, credential delivery and caller-derived boot ordering are implemented. Two reboots of `clamps-test-discovery` automatically restored one Syncthing HTTP and four process checks, without an Agent restart. Offline checks pass (301 tests, Clippy, formatting, Fedora repeat apply), and named-VM smoke passed. -Owned Cloudflare/Tailscale cleanup and VM disposal passed. eBPF tracing -and richer metrics remain pending; see `../butane/ACCEPTANCE.md`. +Previous Cloudflare/Tailscale cleanup and VM disposal passed. +NPM/USM VM acceptance now passes with explicit `PERFMON`, default seccomp and +read-only debugfs. `clamps-test-datadog` recovered automatically after reboot, +with real bridge HTTP traffic, application discovery, lab tags and submissions. +Secure Boot, provider host-tag read-back and richer metrics remain pending; +see `../butane/ACCEPTANCE.md`. Prior validation, 2026-10-07: formatting, pedantic Clippy, 299 workspace tests, Fedora base repeat apply, and named-VM Agent startup/delivery passed. The earlier @@ -126,10 +130,13 @@ were unchanged. Private rollback snapshots were retained outside the repository. Other monitors have explicit production targets. Existing Chef `env:home` and untagged production hosts remain included. -Pending: observe historical test-group reevaluation and re-deliver Datadog -credentials to retained test hosts for `lab`; live region-tag ingestion has not -been verified. New monitors must also exclude test data. No VM was changed by -the one-time provider setup. +Live VM follow-up: Agent status confirmed `env:test` and `region:lab`, with +successful submissions before and after reboot. Both monitors returned zero +test-host groups in their evaluation state. Provider host-tag read-back returned +HTTP 403 with the monitor-only key; verifying received tags needs appropriate +host-read access. PagerDuty delivery was not separately observed. New monitors +must also exclude test data. Existing retained hosts require redelivery to get +`lab`; this acceptance used a separate disposable VM. ## 4. Checks and acceptance @@ -142,12 +149,16 @@ Follow-ups from live acceptance: and self-dependency rejection. Both live reboots and repeat apply passed; named-VM smoke passed. Later runtime event reconciliation remains outside this boot fix's evidence. -2. Resolve eBPF probe attachment separately. Inspect effective seccomp, kernel - support and tracefs/debugfs access; use official Agent requirements and the - narrowest supported runtime policy. Test with real uCore SELinux and report - Secure Boot state explicitly. Do not claim parity from an active container or - host interface counters. Document any additional privileges before enabling - them; do not silently broaden the Agent boundary. +2. Implemented and accepted on the named VM: explicitly add `PERFMON` when + network monitoring is selected. The installed Podman seccomp profile gates + `perf_event_open` on it; `SYS_ADMIN` alone returned ENOSYS. No seccomp bypass, + writable tracing/debugfs or additional host installation was needed. The + syscall succeeded, NPM/USM/event-monitor modules started, and actual bridge + HTTP traffic produced byte counters, HTTP classification and aggregates. + Repeat apply preserved container IDs; smoke/reboot passed and recovered the + same modules and application checks without an Agent restart. SELinux was + enforcing on kernel `7.2.5-200.fc44.x86_64`. Secure Boot was unavailable in + this BIOS VM and must still be tested on UEFI/physical hardware. - Agent owns shared host system/process/network/Btrfs metrics and localhost SSH diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 71307ccf..9d0fcd10 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -19,7 +19,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply acceptance | Isolated backup restore, version compatibility, adoption and Site Manager access; see `CLAMPS-UNIFI.md` | | Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | | Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Explicit rotation acceptance and private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | -| Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks, submissions and automatic boot discovery passed; eBPF tracing and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | +| Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks, submissions and automatic boot discovery passed; NPM/USM perf/HTTP traffic and reboot acceptance passed with explicit PERFMON; physical Secure Boot, provider tag read-back and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | | EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | | Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | | Users, SSH, sudo, shell/dotfiles | Generic user environment/bootstrap and hardening exist; user acceptance previously passed | Compare additional Chef-managed accounts, permissions, and relevant hardening settings. Dotfiles install hook was explicitly deferred | @@ -39,8 +39,9 @@ exists. Additional private run-list recipes remain an inventory question. 1. Disposable DDNS publication/recovery/cleanup passed; finish explicit rotation acceptance and privately inventory production records; do not mutate production DNS. -2. Close Datadog eBPF compatibility and remaining application monitoring gaps, - then handle isolated UniFi backup/restore planning and execution. +2. Datadog eBPF VM acceptance passed. Next, handle isolated UniFi backup/restore + planning and execution; retain physical Secure Boot, provider tag read-back + and richer monitoring follow-ups in `CLAMPS-DATADOG.md`. 3. Close exit-node and remaining host-baseline gaps before cutover. 4. Run the parked VM and deferred network/production acceptance when their required infrastructure is available; transfer state only with a recovery path. diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 4ee8be45..b874f2c9 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -97,6 +97,10 @@ tracks existing violations and their migration, not completed implementation. conflicts between typed fields and extension directives; preserve ordering where repeated directives are semantically ordered. Global runtime policy belongs to the host baseline, not an arbitrary container resource. +- Diagnose container kernel-feature failures against the effective capability, + seccomp, mount and LSM policy before declaring the host kernel unsupported. + Match syscall allow rules to explicitly selected capabilities; test the + smallest policy change and retain restrictions unrelated to the feature. - Discovery acceptance must cover concurrent boot startup as well as steady state. Derive startup ordering from existing caller declarations and keep ordering separate from dependencies that activate optional services; an diff --git a/ublue/skillet/crates/datadog/src/runtime.rs b/ublue/skillet/crates/datadog/src/runtime.rs index a9050282..6a76a306 100644 --- a/ublue/skillet/crates/datadog/src/runtime.rs +++ b/ublue/skillet/crates/datadog/src/runtime.rs @@ -210,10 +210,13 @@ fn container_config( "Environment=DD_USE_DOGSTATSD=false".into(), ]; if runtime.network_monitoring { + // Podman's default seccomp profile allows perf_event_open only when + // PERFMON is explicitly selected. SYS_ADMIN alone leaves the syscall + // at the profile's ENOSYS default even though the kernel supports it. settings.extend([ "Environment=DD_SYSTEM_PROBE_NETWORK_ENABLED=true".into(), "Environment=DD_SYSTEM_PROBE_SERVICE_MONITORING_ENABLED=true".into(), - "AddCapability=SYS_ADMIN SYS_RESOURCE SYS_PTRACE NET_ADMIN NET_BROADCAST NET_RAW IPC_LOCK CHOWN".into(), + "AddCapability=SYS_ADMIN SYS_RESOURCE SYS_PTRACE NET_ADMIN NET_BROADCAST NET_RAW IPC_LOCK CHOWN PERFMON".into(), ]); } else { settings.push("Environment=DD_SYSTEM_PROBE_ENABLED=false".into()); diff --git a/ublue/skillet/crates/datadog/src/runtime/tests.rs b/ublue/skillet/crates/datadog/src/runtime/tests.rs index 094ee9a5..f1a1dbdd 100644 --- a/ublue/skillet/crates/datadog/src/runtime/tests.rs +++ b/ublue/skillet/crates/datadog/src/runtime/tests.rs @@ -47,7 +47,7 @@ fn rootful_secrets_and_host_observation_are_explicit() { "--pid=host", "--cgroupns=host", "--security-opt=label=disable", - "AddCapability=", + "AddCapability=SYS_ADMIN SYS_RESOURCE SYS_PTRACE NET_ADMIN NET_BROADCAST NET_RAW IPC_LOCK CHOWN PERFMON", "Volume=/run/podman:/run/podman:ro", "Environment=DOCKER_HOST=unix:///run/podman/podman.sock", "DD_HOSTNAME=clamps-test-monitoring", @@ -57,6 +57,9 @@ fn rootful_secrets_and_host_observation_are_explicit() { assert!(quadlet.contains(expected), "missing {expected}: {quadlet}"); } assert!(!quadlet.contains("PublishPort=")); + assert!(!quadlet.contains("seccomp=unconfined")); + assert!(!quadlet.contains("--privileged")); + assert!(quadlet.contains("Volume=/sys/kernel/debug:/sys/kernel/debug:ro")); assert!(quadlet.contains("/etc/datadog-agent/conf.d/btrfs.d:ro")); assert!(!quadlet.contains("/etc/datadog-agent/conf.d:ro")); assert!(!quadlet.contains("Environment=DD_API_KEY=")); From db7ff9447c3830eebd60fdf384c02d260647203b Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Thu, 8 Oct 2026 23:54:06 +0200 Subject: [PATCH 218/252] feat(vm): forward local UI ports through owned SSH sessions --- ublue/butane/ACCEPTANCE.md | 17 ++++++ ublue/butane/README.md | 12 ++++ ublue/design/smoke-vms.md | 11 ++++ ublue/skillet/crates/cli/src/main.rs | 14 ++++- ublue/skillet/crates/cli/src/vm.rs | 8 +-- ublue/skillet/crates/cli/src/vm_ssh_tests.rs | 51 ++++++++++++++++ ublue/skillet/crates/vm/src/transport.rs | 58 ++++++++++++++++++- .../skillet/crates/vm/src/transport/tests.rs | 47 ++++++++++++++- 8 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/vm_ssh_tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 62c75842..2c3dbb28 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,20 @@ +## Owned localhost UI forwarding — 2026-10-08 + +- `test vm ssh HOST INSTANCE --forward LOCAL:GUEST` retains the existing run + lock and domain UUID/disk checks. Both listener and destination are IPv4 + loopback; duplicate ports and malformed/nonlocal targets are rejected. +- Native live check used an interactive, read-only session to the retained + `clamps-test-smoke`, forwarding workstation port 18443 to guest port 22. + Curl received `SSH-2.0-OpenSSH_10.2`; its two-second timeout was expected + because no SSH negotiation was sent. On successful shell exit the local + listener disappeared. Guest configuration was not changed. +- SSH multiplexing is disabled for forwarded sessions so a persistent master + cannot retain the listener. Bind failure is fatal. Unit tests cover the + arguments, invalid ports/addresses, duplicate ports and CLI parsing. +- Formatting, pedantic workspace Clippy, all 307 workspace tests and the + Fedora base/repeat container check passed. Flatpak backend acceptance remains + parked; this live check used native libvirt. + # Clamps VM acceptance log ## Datadog NPM/USM and lab isolation, 2026-10-08 diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 5d3a529d..946fd097 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -143,6 +143,18 @@ ssh -i runs/NAME/ssh/id_ed25519 -p PORT \ -o UserKnownHostsFile=runs/NAME/ssh/known_hosts giacomo@127.0.0.1 ``` +For a local application UI, keep the owned SSH session open with a loopback +forward (run from the Skillet workspace): + +```bash +cargo run --release -p skillet -- test vm ssh clamps unifi-restore --forward 18443:8443 +``` + +Open `https://127.0.0.1:18443` for the guest's local HTTPS endpoint. The guest's +self-signed certificate will not match localhost. `--forward LOCAL:GUEST` can +be repeated; both ends use localhost and the forward closes when SSH exits. +A port already in use causes SSH to fail. See the [VM lifecycle design](../design/smoke-vms.md). + The VM staging step grants `giacomo` passwordless sudo. The guest starts `ucore-bootstrap.service`, which reads the host image from `/etc/ucore-bootstrap-image` and rebases first to the unsigned clamps image diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 3a0b252d..d3afe40c 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -149,3 +149,14 @@ Acceptance requires a fresh create, readiness, application checks, unchanged second apply, and reboot persistence. Destruction must also demonstrate that external credentials and test-owned resources are cleaned up. An interrupted run must either resume or leave enough ownership metadata for cleanup. + +## Local UI inspection + +Implemented: `test vm ssh --forward LOCAL:GUEST` exposes a guest localhost +TCP endpoint only on workstation localhost. The shared SSH adapter owns the +forward for the interactive session; the existing lifecycle holds its run lock +and checks domain ownership before and after that session. Bind failures are +fatal and duplicate local ports are refused. This allows browser inspection +without publishing a guest application port on LAN or tailnet. A forward is +not a network isolation policy; restored production controllers still require +a persistent guest fence before import. diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index f21023c3..c8e6aaf9 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -148,7 +148,7 @@ enum VmCommands { /// Reboot a retained, owned disposable VM Reboot(VmTargetArgs), /// Open an interactive SSH shell to a retained, owned disposable VM - Ssh(VmTargetArgs), + Ssh(VmSshArgs), /// Read bootstrap and Skillet journals from a retained VM Logs(VmTargetArgs), /// Verify signed boot and apply the captured base/user environment @@ -179,6 +179,15 @@ struct VmTargetArgs { instance: String, } +#[derive(clap::Args, Debug)] +struct VmSshArgs { + #[command(flatten)] + target: VmTargetArgs, + /// Forward a workstation localhost TCP port to guest localhost (repeatable) + #[arg(long, value_name = "LOCAL:GUEST")] + forward: Vec, +} + #[derive(clap::Args, Debug)] struct VmDirectoryArgs { run_dir: PathBuf, @@ -1367,3 +1376,6 @@ mod smoke_artifact_tests; #[cfg(test)] #[path = "application_acceptance_tests.rs"] mod application_acceptance_tests; + +#[cfg(test)] +mod vm_ssh_tests; diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 15597057..f1bfe0bf 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -1,7 +1,7 @@ //! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. use super::{ butane_root, secret_delivery, workspace_root, VmCreateArgs, VmDestroyArgs, VmDirectoryArgs, - VmListArgs, VmTargetArgs, + VmListArgs, VmSshArgs, VmTargetArgs, }; use anyhow::{anyhow, Context, Result}; use skillet_vm::{ @@ -272,8 +272,8 @@ pub(super) fn reboot(args: &VmTargetArgs) -> Result<()> { Ok(()) } -pub(super) fn ssh(args: &VmTargetArgs) -> Result<()> { - let (butane, store, identity) = context(args)?; +pub(super) fn ssh(args: &VmSshArgs) -> Result<()> { + let (butane, store, identity) = context(&args.target)?; let _lock = store.lock(&identity)?; let run = store.load(&identity)?; if !matches!(run.phase, Phase::Started | Phase::Ready) { @@ -282,7 +282,7 @@ pub(super) fn ssh(args: &VmTargetArgs) -> Result<()> { let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; require_running_domain(&backend, &run)?; let transport = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Enroll)?; - let status = transport.interactive()?; + let status = transport.interactive_with_forwards(&args.forward)?; require_owned_domain(&backend, &run)?; if !status.success() { return Err(anyhow!( diff --git a/ublue/skillet/crates/cli/src/vm_ssh_tests.rs b/ublue/skillet/crates/cli/src/vm_ssh_tests.rs new file mode 100644 index 00000000..cdbb6126 --- /dev/null +++ b/ublue/skillet/crates/cli/src/vm_ssh_tests.rs @@ -0,0 +1,51 @@ +use super::*; + +#[test] +fn vm_ssh_accepts_repeated_loopback_forwards() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "vm", + "ssh", + "clamps", + "restore", + "--forward", + "18443:8443", + "--forward", + "18080:8080", + ]) + .unwrap(); + let Commands::Test { + command: TestCommands::Vm { + command: VmCommands::Ssh(args), + }, + } = parsed.command + else { + panic!("expected VM SSH"); + }; + assert_eq!(args.target.hostname, "clamps"); + assert_eq!(args.target.instance, "restore"); + assert_eq!(args.forward.len(), 2); +} + +#[test] +fn vm_ssh_rejects_nonlocal_or_zero_port_forwards() { + for value in [ + "0:8443", + "18443:0", + "0.0.0.0:18443:8443", + "18443:remote:8443", + ] { + assert!(Args::try_parse_from([ + "skillet", + "test", + "vm", + "ssh", + "clamps", + "restore", + "--forward", + value + ]) + .is_err()); + } +} diff --git a/ublue/skillet/crates/vm/src/transport.rs b/ublue/skillet/crates/vm/src/transport.rs index 65034414..cb19c7e7 100644 --- a/ublue/skillet/crates/vm/src/transport.rs +++ b/ublue/skillet/crates/vm/src/transport.rs @@ -6,6 +6,29 @@ use std::{ time::Duration, }; +/// A localhost listener forwarded only to the guest's localhost. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LoopbackForward { + local: u16, + guest: u16, +} + +impl std::str::FromStr for LoopbackForward { + type Err = Error; + + fn from_str(value: &str) -> Result { + let invalid = + || Error::Invalid("forward must be LOCAL:GUEST with nonzero TCP ports".into()); + let (local, guest) = value.split_once(':').ok_or_else(invalid)?; + let local = local.parse::().map_err(|_| invalid())?; + let guest = guest.parse::().map_err(|_| invalid())?; + if local == 0 || guest == 0 { + return Err(invalid()); + } + Ok(Self { local, guest }) + } +} + pub struct GuestCommand<'a> { pub program: &'a str, pub arguments: &'a [&'a str], @@ -187,19 +210,48 @@ impl SshTransport { Ok(command) } - fn interactive_command(&self) -> Command { + fn interactive_command(&self, forwards: &[LoopbackForward]) -> Result { let mut command = Command::new("ssh"); command.arg("-tt"); self.options(&mut command, "-p"); + let mut ports = std::collections::BTreeSet::new(); + for forward in forwards { + if !ports.insert(forward.local) { + return Err(Error::Invalid("duplicate local forwarding port".into())); + } + command.arg("-L").arg(format!( + "127.0.0.1:{}:127.0.0.1:{}", + forward.local, forward.guest + )); + } + if !forwards.is_empty() { + command.args([ + "-o", + "ExitOnForwardFailure=yes", + "-o", + "ControlMaster=no", + "-o", + "ControlPath=none", + "-o", + "ControlPersist=no", + ]); + } command.arg(self.remote_target()); - command + Ok(command) } /// Run an interactive guest shell with the caller's terminal attached. /// Unlike captured guest commands this is intentionally unbounded until /// the user exits, and inherits stdin/stdout/stderr directly. pub fn interactive(&self) -> Result { - self.interactive_command().status().map_err(Error::Io) + self.interactive_with_forwards(&[]) + } + + /// Keep forwards scoped to the interactive SSH process and caller's run lock. + pub fn interactive_with_forwards(&self, forwards: &[LoopbackForward]) -> Result { + self.interactive_command(forwards)? + .status() + .map_err(Error::Io) } } diff --git a/ublue/skillet/crates/vm/src/transport/tests.rs b/ublue/skillet/crates/vm/src/transport/tests.rs index 12f2e978..45ba0ab7 100644 --- a/ublue/skillet/crates/vm/src/transport/tests.rs +++ b/ublue/skillet/crates/vm/src/transport/tests.rs @@ -164,7 +164,7 @@ fn enroll_policy_does_not_replace_a_recorded_host_key_and_verify_requires_it() { fn interactive_session_inherits_a_terminal_and_uses_recorded_target() { let dir = tempfile::tempdir().unwrap(); let transport = SshTransport::new(target(dir.path()), HostKeyPolicy::Enroll).unwrap(); - let command = transport.interactive_command(); + let command = transport.interactive_command(&[]).unwrap(); let arguments: Vec<_> = command .get_args() .map(|arg| arg.to_str().unwrap()) @@ -240,3 +240,48 @@ fn generic_transport_supports_dns_and_ipv6_targets_without_option_injection() { "fixture@[::1]:/var/tmp/skillet" ); } + +#[test] +fn loopback_forward_rejects_addresses_zero_and_invalid_ports() { + for value in [ + "0:8443", + "8443:0", + "65536:8443", + "8443", + "8443:host:8443", + "0.0.0.0:8443:8443", + ] { + assert!(value.parse::().is_err(), "{value}"); + } + assert_eq!( + "18443:8443".parse::().unwrap(), + LoopbackForward { + local: 18443, + guest: 8443 + } + ); +} + +#[test] +fn interactive_forwards_are_loopback_only_and_fail_on_bind_error() { + let dir = tempfile::tempdir().unwrap(); + let transport = SshTransport::new(target(dir.path()), HostKeyPolicy::Verify).unwrap(); + let forward = "18443:8443".parse::().unwrap(); + let command = transport.interactive_command(&[forward]).unwrap(); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(args + .windows(2) + .any(|pair| pair == ["-L", "127.0.0.1:18443:127.0.0.1:8443"])); + for option in [ + "ExitOnForwardFailure=yes", + "ControlMaster=no", + "ControlPath=none", + "ControlPersist=no", + ] { + assert!(args.contains(&option)); + } + assert!(transport.interactive_command(&[forward, forward]).is_err()); +} From a1da068b0ece48c478ff3b256c2627569ea6bb36 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 00:38:43 +0200 Subject: [PATCH 219/252] test(unifi): prepare fenced backup restore acceptance --- ublue/butane/ACCEPTANCE.md | 48 ++++++++ ublue/butane/README.md | 22 ++++ .../tests/fixtures/unifi-restore/fence.nft | 26 +++++ .../fixtures/unifi-restore/fence.service | 9 ++ .../fixtures/unifi-restore/unifi-dropin.conf | 3 + ublue/design/unifi.md | 7 +- ublue/plan/CLAMPS-UNIFI.md | 107 +++++++++++++++++- ublue/skillet/.gitignore | 3 + ublue/skillet/AGENTS.md | 5 + .../crates/unifi/examples/acceptance_apply.rs | 8 ++ 10 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 ublue/butane/tests/fixtures/unifi-restore/fence.nft create mode 100644 ublue/butane/tests/fixtures/unifi-restore/fence.service create mode 100644 ublue/butane/tests/fixtures/unifi-restore/unifi-dropin.conf create mode 100644 ublue/skillet/crates/unifi/examples/acceptance_apply.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 2c3dbb28..0f61dae3 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,51 @@ +## UniFi restore preparation — 2026-10-09 + +**Preparation passed; backup restore remains unverified.** + +- User supplied an October 1 `.unf` Network backup, 212,880 bytes. Its original + automatic-backup filename identifies UniFi 10.0.162. The private original is + retained and ignored by Git; a mode-0600 working copy is outside the checkout + in a mode-0700 directory. Production SSH rejected this workstation's key; + current production runtime/version and source inventory counts are unverified. +- A dedicated native `clamps-test-unifi-restore` VM on SSH port 2202 passed + fresh signed-image, base and user-environment readiness. The retained smoke + VM's configuration was untouched. No provider credentials were provisioned. +- The real UniFi recipe was applied alone using the `acceptance_apply` test + driver. Observed package `10.0.162-32076-1`, MongoDB 3.6.8 x86_64, rootful + host networking and numeric data-root ownership 999:999, mode 0750. + Local HTTPS returned 200. +- First isolation attempt blocked SSH before any controller or backup import; + that empty VM was disposed successfully and recreated. The revised nftables + policy uses standard filter priority, preserves loopback/SSH/DHCP/IPv6 + neighbour discovery, and drops other input/output/forwarding for both IP + families. Its empty-guest rollback timer was stopped before backup delivery. +- The revised policy passed fresh management access. Direct HTTPS to a public + IPv4 address timed out, with the output drop counter showing blocked packets. + An empty-controller reboot preserved SSH and both active fence/controller + services; the fence remained installed and counted 67 dropped output packets + when inspected. The controller's unit Requires/After includes the fence. + This is not an individual live IPv6/LAN/tailnet reachability probe. +- The provided backup was copied privately only after those checks. Installed + command help lists `restore `; attempting it while live reports + `Already running`. A stopped-controller restore using the same image/data + paths in a one-off `network=none` container exits unsuccessfully with a + missing Spring bean (`com.ubnt.service.system.status.o0OO`). No successful + import or backup validity is established. +- Normal service restarted behind the fence and HTTPS returned 200. Database + aggregate observations were two sites, one network, one WLAN, zero devices + and zero admins. Awaiting setup/UI restore via owned localhost forwarding; + post-restore configuration, repeat apply, reboot/data survival and final + VM cleanup remain pending. Site Manager/adoption remain cutover checks. +- Formatting, pedantic Clippy and all 307 workspace tests passed, including + compilation of the UniFi-only driver. Cargo reported the deployed driver + artifact at the selected musl release path. Fedora base/repeat validation + and Butane bootstrap regression checks passed. No workstation packages + were installed. + +Private diagnostics are under `/tmp/skillet-unifi-restore-isolation/`; readiness, +image-pull and fence preparation logs use `/tmp/skillet-unifi-*` paths. Do not +publish their contents or treat a failed restore command as acceptance. + ## Owned localhost UI forwarding — 2026-10-08 - `test vm ssh HOST INSTANCE --forward LOCAL:GUEST` retains the existing run diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 946fd097..c4531dc4 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -197,3 +197,25 @@ the owned libvirt resources follow the recorded recovery journal. Run the local bootstrap state regression checks with `bash tests/bootstrap.sh`. They exercise mocked deployment states and do not create or modify a VM. + +## Isolated UniFi restore acceptance + +Follow the [UniFi plan](../plan/CLAMPS-UNIFI.md). Use a dedicated disposable VM, +install the test fence from `tests/fixtures/unifi-restore/`, and verify fresh +SSH, blocked application egress and reboot persistence before importing state. +The fence files are test fixtures; do not merge them into production Butane. + +Build the service-only driver from the Skillet workspace with: + +```bash +cargo build --release -p skillet_unifi --example acceptance_apply +``` + +Deploy the reported executable through the owned VM transport and run it as +root inside the prepared guest. It applies the actual UniFi recipe; it neither +imports backups nor configures other services. Keep `.unf` files private and +outside the checkout; Skillet's ignore rules also protect accidental local +copies. UniFi 10.0.162's offline CLI restore failed in this acceptance attempt; +use the setup/UI flow through `test vm ssh --forward 18443:8443`. See the plan +for the retained instance and pending verification. Do not enable cloud access +or change device inform settings during this isolated test. diff --git a/ublue/butane/tests/fixtures/unifi-restore/fence.nft b/ublue/butane/tests/fixtures/unifi-restore/fence.nft new file mode 100644 index 00000000..717fe29c --- /dev/null +++ b/ublue/butane/tests/fixtures/unifi-restore/fence.nft @@ -0,0 +1,26 @@ +table inet skillet_restore {} +flush table inet skillet_restore +table inet skillet_restore { + chain input { + type filter hook input priority 0; policy drop; + iifname "lo" accept + tcp dport 22 accept + udp sport 67 udp dport 68 accept + udp sport 547 udp dport 546 accept + icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept + counter drop + } + chain output { + type filter hook output priority 0; policy drop; + oifname "lo" accept + tcp sport 22 accept + udp sport 68 udp dport 67 accept + udp sport 546 udp dport 547 accept + icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept + counter drop + } + chain forward { + type filter hook forward priority 0; policy drop; + counter drop + } +} diff --git a/ublue/butane/tests/fixtures/unifi-restore/fence.service b/ublue/butane/tests/fixtures/unifi-restore/fence.service new file mode 100644 index 00000000..a842a24f --- /dev/null +++ b/ublue/butane/tests/fixtures/unifi-restore/fence.service @@ -0,0 +1,9 @@ +[Unit] +Description=Isolate disposable UniFi restore from production and cloud +Before=unifi.service +[Service] +Type=oneshot +ExecStart=/usr/sbin/nft -f /etc/skillet-unifi-restore.nft +RemainAfterExit=yes +[Install] +WantedBy=multi-user.target diff --git a/ublue/butane/tests/fixtures/unifi-restore/unifi-dropin.conf b/ublue/butane/tests/fixtures/unifi-restore/unifi-dropin.conf new file mode 100644 index 00000000..31edd1c3 --- /dev/null +++ b/ublue/butane/tests/fixtures/unifi-restore/unifi-dropin.conf @@ -0,0 +1,3 @@ +[Unit] +Requires=skillet-unifi-restore-fence.service +After=skillet-unifi-restore-fence.service diff --git a/ublue/design/unifi.md b/ublue/design/unifi.md index 6e5c0dee..ef3968c8 100644 --- a/ublue/design/unifi.md +++ b/ublue/design/unifi.md @@ -17,7 +17,10 @@ controller's local HTTPS management endpoint available on the host network. Rupik is ARM64 and runs UniFi 10.0.162; clamps is x86_64. Validate a supported application backup restore across architectures in an isolated VM before -production migration. Retain the old controller and an independent backup until -clamps manages the devices successfully. +production migration. The restored test copy must be fenced from production devices and cloud +services, including across reboot; NAT is not sufficient isolation. Validate +configuration locally. Site Manager and device connectivity belong to cutover +acceptance, when the original controller is stopped. Retain the old controller +and an independent backup until clamps manages the devices successfully. See the [implementation plan](../plan/CLAMPS-UNIFI.md). diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md index dacf3714..030571d6 100644 --- a/ublue/plan/CLAMPS-UNIFI.md +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -51,10 +51,10 @@ for the controller's UI account. has `Network=host` for discovery/adoption, and survives repeated apply. See `butane/ACCEPTANCE.md`. Reboot survival, expected host listeners, and confirmation that no Caddy/Cloudflare setup is needed remain to verify. -2. Test restoring one private rupik `.unf` backup into an isolated x86_64 VM - using a compatible application version. Keep it disconnected from production - devices. Confirm the restored site/configuration and remote Site Manager - access without publishing backup contents or credentials. +2. Restore one private rupik `.unf` Network backup in a dedicated x86_64 VM + using a compatible application version. Complete the isolated acceptance + below. Site Manager access is a separate cutover check: the restored copy + must not contact devices or the cloud while rupik remains the live controller. 3. Before cutover, restore the selected backup on clamps, verify remote access and device connectivity, and retain rupik plus an independent backup for rollback. Do not run two controllers against the same devices concurrently. @@ -73,3 +73,102 @@ Read `skillet/AGENTS.md`, `design/storage.md`, `design/secrets.md`, and References: [UniFi cloud architecture](https://help.ui.com/hc/en-us/articles/30319146145175-Understanding-UniFi-Cloud-Architecture), [remote access](https://help.ui.com/hc/en-us/articles/360012192813-Introduction-to-UniFi), [local management](https://help.ui.com/hc/en-us/articles/28457353760919-UniFi-Local-Management). + +## Isolated backup-restore acceptance + +This work is in progress. Isolated VM preparation passed on 2026-10-09; +backup import and post-restore checks remain pending. On 2026-10-08 the workstation reached rupik but SSH +failed with `Permission denied (publickey)`. The user supplied a private October +1 Network backup (212,880 bytes); its original automatic-backup filename +identifies application version 10.0.162. Current source runtime access remains +unverified. Private `.unf` files are ignored by the Skillet checkout. + +1. Obtain the Network application backup through its Backups UI or read-only + source access. Keep it outside the checkout in a mode-0700 directory, with + mode-0600 files. Record its date, byte count and source application version + privately; never commit the backup, restored configuration, identities or + credentials. Retain an independent copy for eventual rollback. +2. Create a dedicated named disposable VM using the existing Rust lifecycle. + Keep the existing smoke VM untouched. Record the target application version + and image digest; the target must support the selected backup. Pull the + image before fencing network access. Do not copy the ARM MongoDB data tree. +3. Stop UniFi before importing anything. Install a **VM-only, persistent** + isolation policy ordered before UniFi starts: permit guest loopback, + the owned SSH management connection, DHCP and IPv6 neighbour discovery + needed to retain the management link after reboot; reject other IPv4/IPv6 + input and output. Disable automatic image updates during this test. NAT alone is + insufficient: it still permits outbound access to production networks and + cloud services. Do not enroll this restored controller for cloud access. + Before first activation on the empty guest, arm a short automatic rollback + timer. Prove fresh SSH connectivity, cancel the timer, then recheck the fence + before starting the controller or uploading the backup. Never arm a timer + that can open networking once production state is present. + Validate allowed SSH/local HTTPS and denied LAN, tailnet and public egress + before import and after reboot. An unavailable probe target does not prove + isolation; inspect the rules and their deny counters as well. +4. Inspect the installed application's supported restore interface. Prefer + its documented command if available; otherwise use its `.unf` restore UI. If authenticated UI + interaction is required, use a local browser through an ownership-checked + tunnel; add that capability to the shared VM transport if needed, never + bypass the lifecycle with a separately assembled SSH command. Do not guess + an undocumented restore API or transmit account passwords in CLI arguments. + The isolated driver is `cargo build --release -p skillet_unifi --example + acceptance_apply`: deploy its reported executable through the owned VM + transport and run it as root only on a prepared disposable guest. It invokes + the production UniFi recipe alone, without importing state or needing + credentials for unrelated services. Record module-only acceptance separately + from full-host acceptance. +5. Verify locally that restoration completes and the expected sites, networks, + WLANs and device inventory are present. Record only aggregate counts and + pass/fail evidence publicly. Devices staying offline is expected in this + isolated test. Verify rootful host networking, UID/GID 999 data ownership + and local management HTTPS. No Caddy/Cloudflare provisioning is required. +6. Repeat the production UniFi module apply with the isolated driver and + confirm the restored state remains intact. Record full-host acceptance + separately; the isolated driver does not configure the other services. + Reboot using the owned lifecycle, prove the fence remains effective before + UniFi starts, and confirm HTTPS and restored state survive. Ordinary apply + must never perform the import. Capture private failure diagnostics. +7. Stop the restored controller and destroy only the owned disposable VM. + Verify cleanup and retain the independent private backup. Record evidence + in `butane/ACCEPTANCE.md`; mark restore as passed only after these checks. + +Production adoption, inform-address changes, device forgetting/resetting and +Site Manager registration are excluded from the isolated test. Verify them +only during an explicitly scheduled cutover with the old controller stopped +and rollback available. + +Restore reference: [Ubiquiti Network backups and migration](https://help.ui.com/hc/en-us/articles/360008976393-Backups-and-Migration-in-UniFi). + +### Current live handoff — 2026-10-09 + +- Retained VM: host `clamps`, instance `unifi-restore`, native `qemu:///session`, + SSH loopback port 2202. Fresh signed boot/base/user readiness passed. +- The cached x86_64 image runs UniFi `10.0.162-32076-1`. The actual recipe uses + host networking and data-root ownership 999:999, mode 0750. Local HTTPS + returns 200. No Tailscale, Cloudflare or Datadog credentials were delivered. +- The tested fence assets are in `butane/tests/fixtures/unifi-restore/`. + Fresh SSH and the fence survived an empty-controller reboot. A direct + public HTTPS probe timed out and the output drop counter increased. + `unifi.service` requires and starts after the persistent fence. Its initial + rollback timer was stopped before the backup was copied. +- First fence attempt failed management access and its empty VM was disposed; + the revised policy uses filter priority and an explicit SSH reply allowance. + Never copy the rejected initial policy from private diagnostic artifacts. +- The application's help lists `restore `. It rejects a live controller + with `Already running`. The stopped-controller attempt in a one-off, + network-disabled container fails with `NoSuchBeanDefinitionException` for + `com.ubnt.service.system.status.o0OO`. No successful backup import is proven; + use the supported setup/UI restore next. Do not interpret the CLI failure + as evidence that the backup is corrupt or incompatible. +- The normal controller has been restarted behind the fence. Current aggregate + observations: two sites, one network, one WLAN, zero devices, zero admins. + These observations are not backup-restore acceptance. +- User action: from the Skillet workspace, run `cargo run --release -p skillet -- + test vm ssh clamps unifi-restore --forward 18443:8443`, open + `https://127.0.0.1:18443` and upload the private backup through the setup UI. + Wait for completion, then exit SSH so owned verification can acquire the + run lock. Passwords and backup contents must not be shared in chat. +- After UI restore: verify site/configuration and inventory, repeat recipe + apply, test reboot/data survival and isolation, then clean up the owned VM. + Do not destroy this retained VM while the UI handoff is pending. diff --git a/ublue/skillet/.gitignore b/ublue/skillet/.gitignore index 2f7896d1..110386d5 100644 --- a/ublue/skillet/.gitignore +++ b/ublue/skillet/.gitignore @@ -1 +1,4 @@ target/ + +# Private UniFi application backups +*.unf diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index b874f2c9..5f54b5e5 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -157,6 +157,11 @@ tracks existing violations and their migration, not completed implementation. environments directly, observe results, and bound waits with diagnostics. Preserve supported workstation backends and static guest builds without installing tools as part of a refactor. +- Validate isolation policies before importing sensitive application state. + Prove management connectivity across activation and reboot, respect kernel + hook ordering, and prepare recovery while the guest is still empty. Cancel + any automatic policy rollback before restoring production state; a recovery + mechanism must not silently reconnect a restored test copy to live services. - CI must cover executable helpers regardless of filename extension and must run relevant checks when their shared inputs change. Keep routine CI free of private credentials and external API access; record live acceptance separately. diff --git a/ublue/skillet/crates/unifi/examples/acceptance_apply.rs b/ublue/skillet/crates/unifi/examples/acceptance_apply.rs new file mode 100644 index 00000000..dd69cd35 --- /dev/null +++ b/ublue/skillet/crates/unifi/examples/acceptance_apply.rs @@ -0,0 +1,8 @@ +//! Apply the production `UniFi` module alone on an already prepared disposable guest. +//! This test driver does not import backups or configure other host services. +use skillet_core::{files::LocalFileResource, system::LinuxSystemResource}; + +fn main() -> Result<(), Box> { + skillet_unifi::apply(&LinuxSystemResource::new(), &LocalFileResource::new())?; + Ok(()) +} From 81f11c495714284e0a9a25cbf30af2e91938778a Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 11:26:27 +0200 Subject: [PATCH 220/252] docs(unifi): record restored-data acceptance and email follow-up --- ublue/butane/ACCEPTANCE.md | 31 +++++++++++++++++++++ ublue/plan/CLAMPS-EMAIL.md | 28 +++++++++++++++++++ ublue/plan/CLAMPS-UNIFI.md | 57 ++++++++++++++++---------------------- 3 files changed, 83 insertions(+), 33 deletions(-) create mode 100644 ublue/plan/CLAMPS-EMAIL.md diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 0f61dae3..d7fe14ba 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,34 @@ +## UniFi restored-data persistence — 2026-10-09 + +**UI import and restored-data persistence verified; authenticated UI acceptance +and final disposable cleanup remain pending.** + +- Operator uploaded the supplied backup using the setup UI. Aggregate database + state now contains two sites, one network, four WLANs, three devices and one + admin, versus one WLAN/zero devices/zero admins before UI import. Source + inventory equivalence has not been independently verified. +- The actual UniFi module-only driver passed repeat apply before and after an + owned reboot. Network/WLAN collection hashes matched the post-import baseline + throughout. No import occurs during ordinary apply. +- After boot startup completed, UniFi was active/running with Result=success, + NRestarts=0 and local HTTPS HTTP 200. Rootful host networking and image user + `unifi` remain configured; data-root metadata was 999:999, mode 0750. +- The persistent fence was active after reboot; UniFi Requires/After still + includes it. Both-family drop policies remained installed, with 48 dropped + output packets observed. A subsequent public IPv4 HTTPS probe timed out. + Dedicated LAN/tailnet/IPv6 endpoint probes remain unverified. +- Operator cannot log in with the recorded password; password age is uncertain + and SMTP is not configured. No password reset, database credential edits or + cloud enrollment were attempted. Authenticated inspection is still pending. +- Retain the isolated `clamps-test-unifi-restore` VM for login investigation. + Do not remove its fence. Final controller stop/disposal is deferred until the + operator completes that inspection; independent private backups are retained. +- This is module-only acceptance, not full-host/device/Site Manager acceptance. + No production controller or device was modified. Only documentation changed + in this follow-up; existing code validation is recorded below. + +Email delivery follow-up: [server email plan](../plan/CLAMPS-EMAIL.md). + ## UniFi restore preparation — 2026-10-09 **Preparation passed; backup restore remains unverified.** diff --git a/ublue/plan/CLAMPS-EMAIL.md b/ublue/plan/CLAMPS-EMAIL.md new file mode 100644 index 00000000..e27275c7 --- /dev/null +++ b/ublue/plan/CLAMPS-EMAIL.md @@ -0,0 +1,28 @@ +# Server email delivery follow-up + +Status: requested; provider selection and implementation pending. Finish UniFi +restore acceptance first. + +## Scope + +- Allow server applications, including UniFi password recovery, to send email + accepted by Gmail through an authenticated external delivery provider. +- Inspect existing Chef configuration and the operator’s existing provider key. + The clear recipe `site-cookbooks/calculon/recipes/wanderer.rb` passes SMTP + username/password/host/port/sender settings from encrypted configuration; + the provider is not identified in the clear source. Do not publish the key. +- Evaluate a shared local SMTP relay backed by that provider versus direct + application SMTP configuration. Keep the relay private and authenticated or + otherwise restricted to declared local applications; prevent open relaying. +- Configure a verified sender domain and the provider’s required SPF/DKIM/DMARC + records. Determine provider limits and suitability before choosing a service. +- Store credentials in KeePass, use generic configuration templates and host + capability declarations, and update the root secrets checklist when actual + requirements are introduced. No new vault keys are required yet. + +## Acceptance + +Send a test message from a configured application to an operator-controlled +Gmail inbox; inspect authentication results and delivery. Verify UniFi recovery +email works and unauthorized relay attempts are rejected. Record evidence +without recipient addresses, real domains or credentials. diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md index 030571d6..7a8b065e 100644 --- a/ublue/plan/CLAMPS-UNIFI.md +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -5,7 +5,8 @@ before proceeding with the remaining feature milestones in this plan. Existing acceptance evidence and deferred tasks remain applicable. Status: implementation and empty-controller acceptance passed on the -disposable x86_64 VM on 2026-10-03. Backup restore and production migration +disposable x86_64 VM on 2026-10-03. UI backup import and restored-data persistence passed on 2026-10-09. +Authenticated UI verification, final disposable cleanup and production migration remain pending. ## Current behavior @@ -76,8 +77,9 @@ References: [UniFi cloud architecture](https://help.ui.com/hc/en-us/articles/303 ## Isolated backup-restore acceptance -This work is in progress. Isolated VM preparation passed on 2026-10-09; -backup import and post-restore checks remain pending. On 2026-10-08 the workstation reached rupik but SSH +This work is in progress. UI import, repeat module apply, restored configuration +hashes and reboot persistence passed on 2026-10-09. Authenticated UI inspection +and final disposal remain pending. On 2026-10-08 the workstation reached rupik but SSH failed with `Permission denied (publickey)`. The user supplied a private October 1 Network backup (212,880 bytes); its original automatic-backup filename identifies application version 10.0.162. Current source runtime access remains @@ -142,33 +144,22 @@ Restore reference: [Ubiquiti Network backups and migration](https://help.ui.com/ ### Current live handoff — 2026-10-09 -- Retained VM: host `clamps`, instance `unifi-restore`, native `qemu:///session`, - SSH loopback port 2202. Fresh signed boot/base/user readiness passed. -- The cached x86_64 image runs UniFi `10.0.162-32076-1`. The actual recipe uses - host networking and data-root ownership 999:999, mode 0750. Local HTTPS - returns 200. No Tailscale, Cloudflare or Datadog credentials were delivered. -- The tested fence assets are in `butane/tests/fixtures/unifi-restore/`. - Fresh SSH and the fence survived an empty-controller reboot. A direct - public HTTPS probe timed out and the output drop counter increased. - `unifi.service` requires and starts after the persistent fence. Its initial - rollback timer was stopped before the backup was copied. -- First fence attempt failed management access and its empty VM was disposed; - the revised policy uses filter priority and an explicit SSH reply allowance. - Never copy the rejected initial policy from private diagnostic artifacts. -- The application's help lists `restore `. It rejects a live controller - with `Already running`. The stopped-controller attempt in a one-off, - network-disabled container fails with `NoSuchBeanDefinitionException` for - `com.ubnt.service.system.status.o0OO`. No successful backup import is proven; - use the supported setup/UI restore next. Do not interpret the CLI failure - as evidence that the backup is corrupt or incompatible. -- The normal controller has been restarted behind the fence. Current aggregate - observations: two sites, one network, one WLAN, zero devices, zero admins. - These observations are not backup-restore acceptance. -- User action: from the Skillet workspace, run `cargo run --release -p skillet -- - test vm ssh clamps unifi-restore --forward 18443:8443`, open - `https://127.0.0.1:18443` and upload the private backup through the setup UI. - Wait for completion, then exit SSH so owned verification can acquire the - run lock. Passwords and backup contents must not be shared in chat. -- After UI restore: verify site/configuration and inventory, repeat recipe - apply, test reboot/data survival and isolation, then clean up the owned VM. - Do not destroy this retained VM while the UI handoff is pending. +- Retained VM: `clamps` / `unifi-restore`, native `qemu:///session`, SSH 2202. + UniFi version 10.0.162-32076-1, rootful host networking, data-root 999:999/0750. +- Operator uploaded the backup through the setup UI. Database aggregates: + two sites, one network, four WLANs, three devices, one admin. Source inventory + equivalence remains unverified. CLI restore attempts failed previously; + those failures did not establish backup corruption. +- Repeat production module-only apply before and after owned reboot passed; + network/WLAN configuration hashes stayed identical. HTTPS returned 200 after + boot startup, service active with zero restarts. This is not full-host acceptance. +- Persistent isolation survived reboot and is required before UniFi activation. + Public IPv4 HTTPS remains blocked; separate LAN/tailnet/IPv6 probes remain + unverified. No provider credentials or cloud/device access were enabled. +- Login with the operator’s recorded password failed; an older password is + possible. SMTP recovery is unavailable. Retain the isolated VM for credential + investigation; do not alter database credentials or remove the fence. +- Remaining: operator authenticated UI/configuration inspection, then stop and + dispose through the owned lifecycle. Preserve independent private backups. + Production adoption and Site Manager remain scheduled cutover checks. +- Email delivery is tracked in [the follow-up plan](CLAMPS-EMAIL.md). From 62fb21651806cea9f4364e62707640dc298a24e2 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 11:26:37 +0200 Subject: [PATCH 221/252] docs(unifi): reconcile migration acceptance summary --- ublue/plan/CLAMPS-UNIFI.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md index 7a8b065e..5bf28b4e 100644 --- a/ublue/plan/CLAMPS-UNIFI.md +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -63,8 +63,8 @@ for the controller's UI account. Routine tests already cover host-network Quadlet rendering, numeric data-root ownership, mount refusal, repeated apply, and preservation of existing files; the canonical profile selects UniFi for Clamps. Empty-controller runtime -acceptance passed on 2026-10-03. Backup restore and production/device acceptance -remain pending. Record new commands and results in `butane/ACCEPTANCE.md`. +acceptance passed on 2026-10-03. UI import and restored-data persistence passed on 2026-10-09; authenticated +inspection, disposal and production/device acceptance remain pending. Record new commands and results in `butane/ACCEPTANCE.md`. Device migration stays unverified until a backup restore or deliberate re-adoption succeeds. From b1945ac61ba763074c480d824b5c97ffd294fd0b Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 11:39:15 +0200 Subject: [PATCH 222/252] docs(unifi): close isolated restore acceptance --- ublue/butane/ACCEPTANCE.md | 18 +++++++++++ ublue/plan/CLAMPS-UNIFI.md | 63 ++++++++++++++++++-------------------- 2 files changed, 48 insertions(+), 33 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index d7fe14ba..d53d0e83 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,21 @@ +## UniFi isolated restore completed — 2026-10-09 + +**Passed: cross-architecture backup restore, operator UI inspection, repeat +module apply, reboot/data persistence and owned disposable cleanup.** + +- Operator successfully logged in and confirmed the restored devices and + configuration look correct. This completes the authenticated inspection + left pending in the entry below; no password/database reset was needed. +- Restored state and configuration persistence checks are recorded below. + This remains module-only acceptance; production adoption and Site Manager + connectivity were intentionally excluded by the persistent isolation fence. +- Stopped `unifi.service` through the ownership-checked transport, then ran + `skillet test vm destroy clamps unifi-restore`; disposal succeeded. + Owned `test vm list clamps` shows only the existing smoke instance, and + the restore run directory is absent. The original and protected independent + backup copies remain present. Existing smoke VM was not modified. +- No production controller/device changes or outgoing recovery emails occurred. + ## UniFi restored-data persistence — 2026-10-09 **UI import and restored-data persistence verified; authenticated UI acceptance diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md index 5bf28b4e..7ea3a36e 100644 --- a/ublue/plan/CLAMPS-UNIFI.md +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -5,9 +5,10 @@ before proceeding with the remaining feature milestones in this plan. Existing acceptance evidence and deferred tasks remain applicable. Status: implementation and empty-controller acceptance passed on the -disposable x86_64 VM on 2026-10-03. UI backup import and restored-data persistence passed on 2026-10-09. -Authenticated UI verification, final disposable cleanup and production migration -remain pending. +disposable x86_64 VM on 2026-10-03. UI backup import and restored-data +persistence passed on 2026-10-09. +Operator authenticated UI inspection and owned disposable cleanup passed. +Production migration remains pending. ## Current behavior @@ -50,9 +51,11 @@ for the controller's UI account. 1. **Passed:** Test the Quadlet/module on a named disposable x86_64 VM with an empty controller: service starts, remains active, uses intended ownership, has `Network=host` for discovery/adoption, and survives repeated apply. - See `butane/ACCEPTANCE.md`. Reboot survival, expected host listeners, and - confirmation that no Caddy/Cloudflare setup is needed remain to verify. -2. Restore one private rupik `.unf` Network backup in a dedicated x86_64 VM + See `butane/ACCEPTANCE.md`. Reboot survival and local HTTPS passed; complete + expected host listener coverage remains a cutover check. Isolated restore + acceptance needed no Caddy/Cloudflare setup. +2. **Passed on 2026-10-09:** Restore one private rupik `.unf` Network backup + in a dedicated x86_64 VM using a compatible application version. Complete the isolated acceptance below. Site Manager access is a separate cutover check: the restored copy must not contact devices or the cloud while rupik remains the live controller. @@ -63,10 +66,10 @@ for the controller's UI account. Routine tests already cover host-network Quadlet rendering, numeric data-root ownership, mount refusal, repeated apply, and preservation of existing files; the canonical profile selects UniFi for Clamps. Empty-controller runtime -acceptance passed on 2026-10-03. UI import and restored-data persistence passed on 2026-10-09; authenticated -inspection, disposal and production/device acceptance remain pending. Record new commands and results in `butane/ACCEPTANCE.md`. -Device migration stays unverified until a backup restore or deliberate -re-adoption succeeds. +acceptance passed on 2026-10-03. UI import, restored-data persistence, operator +inspection and disposal passed on 2026-10-09. Production/device acceptance +remains pending. Record new commands and results in `butane/ACCEPTANCE.md`. +Actual device migration remains unverified until scheduled cutover succeeds. Read `skillet/AGENTS.md`, `design/storage.md`, `design/secrets.md`, and `design/smoke-vms.md` before implementation. @@ -77,9 +80,10 @@ References: [UniFi cloud architecture](https://help.ui.com/hc/en-us/articles/303 ## Isolated backup-restore acceptance -This work is in progress. UI import, repeat module apply, restored configuration -hashes and reboot persistence passed on 2026-10-09. Authenticated UI inspection -and final disposal remain pending. On 2026-10-08 the workstation reached rupik but SSH +Completed on 2026-10-09: UI import, repeat module apply, restored configuration +hashes, reboot persistence, operator authenticated inspection and final disposal +passed. Production/device/cloud acceptance remains separate. On 2026-10-08 +the workstation reached rupik but SSH failed with `Permission denied (publickey)`. The user supplied a private October 1 Network backup (212,880 bytes); its original automatic-backup filename identifies application version 10.0.162. Current source runtime access remains @@ -142,24 +146,17 @@ and rollback available. Restore reference: [Ubiquiti Network backups and migration](https://help.ui.com/hc/en-us/articles/360008976393-Backups-and-Migration-in-UniFi). -### Current live handoff — 2026-10-09 - -- Retained VM: `clamps` / `unifi-restore`, native `qemu:///session`, SSH 2202. - UniFi version 10.0.162-32076-1, rootful host networking, data-root 999:999/0750. -- Operator uploaded the backup through the setup UI. Database aggregates: - two sites, one network, four WLANs, three devices, one admin. Source inventory - equivalence remains unverified. CLI restore attempts failed previously; - those failures did not establish backup corruption. -- Repeat production module-only apply before and after owned reboot passed; - network/WLAN configuration hashes stayed identical. HTTPS returned 200 after - boot startup, service active with zero restarts. This is not full-host acceptance. -- Persistent isolation survived reboot and is required before UniFi activation. - Public IPv4 HTTPS remains blocked; separate LAN/tailnet/IPv6 probes remain - unverified. No provider credentials or cloud/device access were enabled. -- Login with the operator’s recorded password failed; an older password is - possible. SMTP recovery is unavailable. Retain the isolated VM for credential - investigation; do not alter database credentials or remove the fence. -- Remaining: operator authenticated UI/configuration inspection, then stop and - dispose through the owned lifecycle. Preserve independent private backups. - Production adoption and Site Manager remain scheduled cutover checks. +### Completed isolated acceptance — 2026-10-09 + +- Dedicated `clamps` / `unifi-restore` VM restored the supplied ARM controller + backup using x86_64 UniFi 10.0.162-32076-1. Aggregate state: two sites, one + network, four WLANs, three devices, one admin. +- Operator successfully logged in and confirmed devices/configuration look + correct. Repeat production module apply and reboot preserved network/WLAN + collection hashes; local HTTPS and persistent isolation passed. +- Stopped the controller and disposed the owned VM. Run artifacts are absent, + independent backup copies retained, and the existing smoke VM untouched. +- See `butane/ACCEPTANCE.md` for evidence and limitations. Complete individual + listener checks, device connectivity and Site Manager at scheduled production + cutover, with rupik stopped and rollback available. - Email delivery is tracked in [the follow-up plan](CLAMPS-EMAIL.md). From 0a3d8b7be082525bab2c91334fe77dd93e74bd0f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 11:39:23 +0200 Subject: [PATCH 223/252] docs(smtp): plan shared Postfix relay provisioning --- ublue/design/email-delivery.md | 49 ++++++++++++++++++ ublue/plan/CLAMPS-EMAIL.md | 90 +++++++++++++++++++++++----------- 2 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 ublue/design/email-delivery.md diff --git a/ublue/design/email-delivery.md b/ublue/design/email-delivery.md new file mode 100644 index 00000000..2c333f7f --- /dev/null +++ b/ublue/design/email-delivery.md @@ -0,0 +1,49 @@ +# Fleet email delivery + +Status: proposed after repository/provider research; not implemented. + +## Recommended boundary + +Use native Postfix as a submission relay with a persistent retry queue. Install +Postfix and its required SASL/TLS support in `ucore-common`; keep the shipped +service inactive until configuration and credentials are delivered. A shared +`skillet_smtp` service module owns runtime convergence, selected from the +canonical fleet host declarations. Do not put SMTP policy in `skillet_core` or +make the credential-free base/bootstrap phase require provider access. + +This gives host programs the standard sendmail interface and host-network +applications a loopback SMTP endpoint. The packages are static OS dependencies; +provider endpoints, private sender identities and credentials are runtime inputs. +A container relay is possible, but would additionally need host submission +integration. A lightweight client alone does not supply the required durable +queue without extra machinery. + +## Delivery and access + +Relay only through an authenticated external provider, using verified TLS; +never fall back to direct destination-MX delivery. Keep the queue in persistent +`/var/spool/postfix`, independent of the service-data mount. Shared templates +hold policy; private configuration and credentials use the existing vault and +systemd encrypted-delivery workflow. Validate credential-map support, service +permissions and worker/chroot behavior before selecting its concrete backend. +Never bake credentials or sender domains into an image. + +Default listener: loopback only. UniFi uses host networking and can reach it. +Bridge containers need an explicitly declared internal endpoint with restricted +access, preferably authenticated submission; do not expose port 25 on the LAN +or tailnet, or trust an entire bridge containing unrelated containers by default. +Application modules own their SMTP client integration and monitoring declarations. + +Production fleet hosts include SMTP as shared service policy. Test machines use +an isolated capture sink by default, with no external provider delivery or copied +production credentials. Real delivery is an explicit operator-controlled test. +Do not send queued lab mail later when changing a test instance to another policy. + +Use a provider-verified sender and SPF/DKIM/DMARC. The provider handles internet +SMTP delivery infrastructure and IP reputation; authentication does not guarantee +inbox placement. No incoming mailbox or public MX service is required. + +References: [Postfix relay configuration](https://www.postfix.org/SOHO_README.html), +[Gmail sender requirements](https://support.google.com/mail/answer/81126?hl=en). + +See [the implementation plan](../plan/CLAMPS-EMAIL.md). diff --git a/ublue/plan/CLAMPS-EMAIL.md b/ublue/plan/CLAMPS-EMAIL.md index e27275c7..6f94074f 100644 --- a/ublue/plan/CLAMPS-EMAIL.md +++ b/ublue/plan/CLAMPS-EMAIL.md @@ -1,28 +1,62 @@ -# Server email delivery follow-up - -Status: requested; provider selection and implementation pending. Finish UniFi -restore acceptance first. - -## Scope - -- Allow server applications, including UniFi password recovery, to send email - accepted by Gmail through an authenticated external delivery provider. -- Inspect existing Chef configuration and the operator’s existing provider key. - The clear recipe `site-cookbooks/calculon/recipes/wanderer.rb` passes SMTP - username/password/host/port/sender settings from encrypted configuration; - the provider is not identified in the clear source. Do not publish the key. -- Evaluate a shared local SMTP relay backed by that provider versus direct - application SMTP configuration. Keep the relay private and authenticated or - otherwise restricted to declared local applications; prevent open relaying. -- Configure a verified sender domain and the provider’s required SPF/DKIM/DMARC - records. Determine provider limits and suitability before choosing a service. -- Store credentials in KeePass, use generic configuration templates and host - capability declarations, and update the root secrets checklist when actual - requirements are introduced. No new vault keys are required yet. - -## Acceptance - -Send a test message from a configured application to an operator-controlled -Gmail inbox; inspect authentication results and delivery. Verify UniFi recovery -email works and unauthorized relay attempts are rejected. Record evidence -without recipient addresses, real domains or credentials. +# Fleet SMTP implementation + +Status: investigation completed; implementation pending. UniFi isolated restore +acceptance is complete. Native Postfix + shared Skillet module is the recommended +approach; confirm the existing provider before selecting credentials and endpoints. +See [the design](../design/email-delivery.md). + +## Findings + +- Chef configures SMTP directly for Wanderer and AdventureLog using application + secrets. No fleet-wide Postfix/relay recipe was found. `secrets/` contains only + its README locally, so the old provider cannot be identified from this checkout. +- `ucore-common` owns packages/static files; Skillet already owns credentials, + canonical service composition, convergence and monitoring declarations. +- No credentials were read or new vault requirements introduced by this research. + Do not assume the unlocked vault contains the old provider configuration. + +## Ordered slices + +1. Confirm the operator’s existing provider and verified sender. Check SMTP + authentication, required TLS/port, sending limits and domain verification + against that provider’s official documentation. Prepare brief exact vault + requirements through shared metadata/root checklist; keep provider password + and private sender/endpoint leaves out of the public repo. Use a shared + credential where its actual account scope allows it, with explicit test policy. +2. Add native Postfix and required authentication/TLS packages to the common + image, initially inactive. Check the base image for existing packages and + units first. Keep all runtime values and authentication material out of it. +3. Add a cohesive `skillet_smtp` service crate and typed configuration template. + Declare fleet inclusion centrally, separate from credential-free base apply. + Deliver the entire prerequisite set before activation using existing encrypted + credential workflow. Validate map backend, file permissions, SELinux and + service/worker access using the actual runtime. Preserve queued mail across + repeat apply, reboot and image rebase. Require authenticated verified-TLS + upstream; no direct-MX fallback. Queue lives in `/var/spool/postfix`. +4. Support host sendmail and loopback submission first. Add explicit bridge + client access only for declared applications; do not publish LAN/tailnet SMTP + or broadly allow arbitrary bridge clients. Applications own their SMTP client + integration. Configure UniFi recovery email via a supported interface, with + any necessary operator UI handoff recorded. +5. Add module-owned monitoring for service health, deferred queue age and send + failures without credential/message-body disclosure. Test hosts use a local + capture sink and environment tags; provider delivery remains explicit opt-in. + +## Exit criteria and acceptance + +- Routine CI needs no vault/provider: typed rendering, missing/invalid inputs, + prerequisite activation, repeat apply, metadata preservation and recipient/access + restrictions tested through injected boundaries. +- Named disposable VM: host sendmail and intended SMTP clients reach the sink; + unauthorized clients cannot relay; TLS certificate/authentication failures do + not trigger plaintext delivery. Restart/reboot/rebase preserve queued messages + and retry after a simulated upstream outage without regenerating credentials. +- Explicit live acceptance after provider setup: send a message to an + operator-controlled Gmail inbox. Inspect SPF/DKIM/DMARC results and actual + delivery; no guaranteed inbox placement claim. Verify UniFi recovery email. + Do not send during routine smoke runs. +- Record exact passed/pending checks in `butane/ACCEPTANCE.md`, maintain designs, + README usage and the root secrets checklist; focused validated commits, no push. + +References: [Postfix relay configuration](https://www.postfix.org/SOHO_README.html), +[Gmail requirements](https://support.google.com/mail/answer/81126?hl=en). From 556fb22a8ae021bd4a3229ef976aa68929bbc0d1 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 12:00:28 +0200 Subject: [PATCH 224/252] feat(secrets): validate structured SMTP vault fields --- SECRETS.md | 29 ++-- ublue/AGENTS.md | 7 +- ublue/butane/ACCEPTANCE.md | 18 +++ ublue/design/email-delivery.md | 11 ++ ublue/design/secrets.md | 12 +- ublue/plan/CLAMPS-EMAIL.md | 16 +- ublue/skillet/README.md | 8 +- .../skillet/crates/cli/src/secret_delivery.rs | 9 +- ublue/skillet/crates/cli/src/secret_output.rs | 4 +- .../workstation/src/secret-requirements.json | 37 +++++ .../skillet/crates/workstation/src/secrets.rs | 109 ++++++++++++-- .../crates/workstation/src/secrets/tests.rs | 141 +++++++++++++++++- ublue/skillet/crates/workstation/src/vault.rs | 46 ++++-- .../crates/workstation/src/vault/tests.rs | 42 ++++++ 14 files changed, 430 insertions(+), 59 deletions(-) diff --git a/SECRETS.md b/SECRETS.md index 78e83ebd..5b6e16a9 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -2,13 +2,12 @@ Vault: `$XDG_DATA_HOME/skillet/secrets.kdbx` (default: `~/.local/share/skillet/secrets.kdbx`). Paths are **group path + entry title**. -Put every value in **Password**, including IDs and configuration; leave -**Username** empty. `` is `prod` or `test`; `` is the +Unless an entry documents other fields, put its value in **Password** and +leave **Username** empty. `` is `prod` or `test`; `` is the canonical host name. Only prepare entries for enabled modules. Audit with `skillet secrets check` (all declared hosts, production), or -`skillet secrets check --host clamps --environment test`. It checks presence -and nonempty Password fields, without provider calls or generating secrets. +`skillet secrets check --host clamps --environment test`. It checks required fields and their formats, without provider calls or generating secrets. Missing optional/generated/planned entries do not fail the check. @@ -21,7 +20,7 @@ No vault entries for the current local snapshot configuration. Prepare the shared Cloudflare entries. [UI setup](ublue/skillet/README.md#caddy-ui-credentials). -| Entry | Status | Password value / how to obtain it | +| Entry | Status | Fields / how to obtain them | | --- | --- | --- | | `skillet/environments//hosts//cloudflare/acme-token` | Generated | Skillet creates the persistent scoped token; do not create disposable VM tokens manually. | | `skillet/hosts//cloudflare/acme-token` | Generated | Legacy production token read during migration. Do not create new entries at this path. | @@ -30,7 +29,7 @@ Prepare the shared Cloudflare entries. [UI setup](ublue/skillet/README.md#caddy- Shared inputs for Caddy and DDNS. -| Entry | Status | Password value / how to obtain it | +| Entry | Status | Fields / how to obtain them | | --- | --- | --- | | `skillet/cloudflare/token-creator` | Required | Create an account-owned Cloudflare API token with Account API Tokens Write and Zone Read, restricted to the selected zones. [Permissions guide](ublue/skillet/README.md#secret-setup-and-delivery). | | `skillet/environments//dns/cloudflare-zone-id` | Required | Copy the Zone ID from the selected zone's Cloudflare Overview page. | @@ -40,7 +39,7 @@ Shared inputs for Caddy and DDNS. One shared key serves production and test. No application key required for ingestion. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog). -| Entry | Status | Password value / how to obtain it | +| Entry | Status | Fields / how to obtain them | | --- | --- | --- | | `skillet/datadog/api-key` | Required | Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | | `skillet/datadog/monitor-setup-key` | Optional | For workstation monitor administration only: create a scoped Application Key in Datadog Organization Settings → Application Keys with monitors_read and monitors_write. Never deliver this key to hosts. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | @@ -49,7 +48,7 @@ One shared key serves production and test. No application key required for inges Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skillet/README.md#optional-cloudflare-ddns). -| Entry | Status | Password value / how to obtain it | +| Entry | Status | Fields / how to obtain them | | --- | --- | --- | | `skillet/environments//hosts//cloudflare/ddns-dns-name` | Required | Choose the relative DNS record name, not a full domain. | | `skillet/environments/prod/hosts//cloudflare/ddns-token` | Generated | Skillet creates the persistent scoped token; disposable tokens are lifecycle-owned. | @@ -58,15 +57,23 @@ Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skil [Delivery](ublue/skillet/README.md#secret-setup-and-delivery). -| Entry | Status | Password value / how to obtain it | +| Entry | Status | Fields / how to obtain them | | --- | --- | --- | | `skillet/hosts//pihole/web-password` | Generated | Skillet generates and saves the web admin password for a fresh host; optionally create it to choose a password. For an existing host, restore its entry rather than inventing a replacement. | +## SMTP + +Shared fleet relay credential preparation; SMTP runtime delivery is planned. [SMTP plan](ublue/plan/CLAMPS-EMAIL.md). + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/smtp` | Required | Create one entry using your Mailjet SMTP credentials: Username = SMTP username; Password = SMTP password. Custom fields: host = in-v3.mailjet.com; port = 587; tls = starttls (required TLS with certificate verification). [Mailjet credentials](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters). | + ## Storage (planned) No entry required today. Keep recovery material off the encrypted host. [Encryption plan](ublue/plan/TPM-ENCRYPTED-ROOT.md). -| Entry | Status | Password value / how to obtain it | +| Entry | Status | Fields / how to obtain them | | --- | --- | --- | | `skillet/hosts//storage/root-recovery-key` | Planned | LUKS recovery key; prepare through the future TPM/LUKS workflow. | @@ -78,7 +85,7 @@ No vault entries currently required; Skillet manages the private UI configuratio Create an OAuth client in Tailscale Trust credentials with Auth Keys Write and Devices Core Read/Write, restricted to the provisioner tag. [Tag and scope guide](ublue/skillet/README.md#tailscale-setup-for-skillet). Skillet mints enrollment keys; do not create per-host auth-key entries. -| Entry | Status | Password value / how to obtain it | +| Entry | Status | Fields / how to obtain them | | --- | --- | --- | | `skillet/tailscale/provisioner-client-id` | Required | Copy the OAuth Client ID from the creation page; use Password, not Username. | | `skillet/tailscale/provisioner-client-secret` | Required | Copy the OAuth Client secret from the creation page. | diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index d0e0edbd..854f2f7e 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -23,8 +23,11 @@ and regenerate `SECRETS.md` with `cargo run -p skillet_workstation --example secrets_documentation` from the Skillet workspace (redirect output to the repository-root file). Keep template-reference coverage and the documentation - equality test passing. Required checks follow selected host capabilities; - unused-entry auditing must consider all declared hosts and both environments, + equality test passing. Required checks follow selected host capabilities. + Structured vault entries must declare each required standard/custom field and + its validation in the shared audit metadata; keep single-value entry behavior + compatible, and never include field values in audit output or errors. + Unused-entry auditing must consider all declared hosts and both environments, including optional/generated entries and retained migration readers. - Read the relevant documents in [design/](design/) before changing architecture diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index d53d0e83..5b243b3e 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,21 @@ +## Structured SMTP vault audit — 2026-10-09 + +- `skillet secrets check --host clamps --environment test` opened the unlocked + vault and passed: seven required entries, zero missing/invalid, zero unused. + The shared `skillet/smtp` entry includes required Username/Password and custom + host/port/tls fields. The audit checks format and required STARTTLS policy; + it makes no provider calls and prints no field values. +- Shared metadata drives field validation, missing-field instructions and the + root checklist. SMTP is a shared fleet preparation requirement in prod/test; + credential-free synthetic agent profiles are excluded. Runtime SMTP delivery + is still planned. Existing single-value readers retain Password semantics. +- Formatting, pedantic workspace Clippy and all 311 workspace tests passed. + Added coverage for missing/invalid structured fields, protected custom reads, + exact-path ambiguity, deduplication and both-environment unused auditing. + Fedora base/repeat check passed with no service start/restart on the second apply. +- No container behavior changed; no additional VM or Mailjet delivery acceptance + is claimed. Postfix provisioning and verified-sender setup remain pending. + ## UniFi isolated restore completed — 2026-10-09 **Passed: cross-architecture backup restore, operator UI inspection, repeat diff --git a/ublue/design/email-delivery.md b/ublue/design/email-delivery.md index 2c333f7f..f406ae29 100644 --- a/ublue/design/email-delivery.md +++ b/ublue/design/email-delivery.md @@ -18,6 +18,17 @@ A container relay is possible, but would additionally need host submission integration. A lightweight client alone does not supply the required durable queue without extra machinery. +## Provider entry + +Mailjet is the existing provider. Read a single `skillet/smtp` entry: +Username and Password contain SMTP credentials; `host`, `port` and `tls` custom +fields hold the endpoint and security mode. Field-aware reading/auditing is +implemented; service delivery is pending. The audit requires these fields once +for shared fleet preparation in both environments and currently accepts only +`starttls` for TLS. Missing/invalid field names are public diagnostics; values +are never printed. SMTP credentials are the provider’s API key/secret, rather +than the account login ([provider guide](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters)). + ## Delivery and access Relay only through an authenticated external provider, using verified TLS; diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index b9e31023..fa472025 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -50,7 +50,9 @@ without the old one's keyring. `skillet secret unlock` verifies the database and establishes the same session cache without coupling unlock to provisioning. The Cloudflare token creator is stored at `skillet/cloudflare/token-creator`. -Vault paths are group paths plus an entry title; values use the Password field. +Vault paths are group paths plus an entry title. Single-value entries use the +Password field; structured entries declare their standard/custom fields in the +shared requirements metadata. The prefix is consistently singular, `skillet`: KeePassXC environment groups are named `prod` and `test`; the CLI's `production` policy maps to the `prod` group. @@ -89,8 +91,12 @@ come from the delivery resolver, including host overrides. Generated, optional, and planned entries are distinguished. A read-only inventory of entry paths finds unused Skillet entries across all declared hosts and both environments, retaining known migration paths. It never emits values, mutates the vault, or -uses provider APIs; it checks presence rather than provider permissions or host -recovery state. `secrets` is the canonical CLI group; `secret` remains an alias. +uses provider APIs; it checks declared field presence/formats rather than provider +permissions or host recovery state. Existing entries default to Password-only +validation. Structured entries declare standard/custom fields and validators in +the shared metadata. `skillet/smtp` uses Username/Password and host/port/tls +custom fields; its shared fleet audit is implemented, while runtime SMTP +delivery remains planned. `secrets` is the canonical CLI group; `secret` remains an alias. Use the generic [configuration template design](configuration-templates.md) when service configuration combines clear policy with KeePassXC values. The diff --git a/ublue/plan/CLAMPS-EMAIL.md b/ublue/plan/CLAMPS-EMAIL.md index 6f94074f..7b57b914 100644 --- a/ublue/plan/CLAMPS-EMAIL.md +++ b/ublue/plan/CLAMPS-EMAIL.md @@ -2,7 +2,8 @@ Status: investigation completed; implementation pending. UniFi isolated restore acceptance is complete. Native Postfix + shared Skillet module is the recommended -approach; confirm the existing provider before selecting credentials and endpoints. +approach. Mailjet credentials and endpoint are confirmed by the operator; +verified sender/domain configuration remains to prepare. See [the design](../design/email-delivery.md). ## Findings @@ -12,14 +13,17 @@ See [the design](../design/email-delivery.md). its README locally, so the old provider cannot be identified from this checkout. - `ucore-common` owns packages/static files; Skillet already owns credentials, canonical service composition, convergence and monitoring declarations. -- No credentials were read or new vault requirements introduced by this research. - Do not assume the unlocked vault contains the old provider configuration. +- Operator identified Mailjet (`in-v3.mailjet.com:587`) and created one + `skillet/smtp` entry with Username/Password and host/port/tls custom fields. + Shared field-aware vault reading and required-field auditing are implemented; + no provider authentication or outgoing email is performed by the audit. ## Ordered slices -1. Confirm the operator’s existing provider and verified sender. Check SMTP - authentication, required TLS/port, sending limits and domain verification - against that provider’s official documentation. Prepare brief exact vault +1. **Partially complete:** Mailjet identified and the one-entry vault audit + passed. Confirm the verified sender and domain, account limits and provider + authentication during explicit live acceptance. Required STARTTLS on port + 587 is the selected policy. Prepare brief exact vault requirements through shared metadata/root checklist; keep provider password and private sender/endpoint leaves out of the public repo. Use a shared credential where its actual account scope allows it, with explicit test policy. diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 3f4e5479..bf429b2d 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -198,9 +198,15 @@ cargo run --release -p skillet -- secrets check --host clamps --environment test ``` `check` defaults to all declared host profiles and production requirements. -It reads the vault, reports missing or invalid required Password fields with +It reads the vault, reports missing or invalid required entry fields with the checklist's setup instructions, and exits unsuccessfully if any are missing or invalid. Optional and automatically generated entries need not exist. +SMTP preparation uses one entry, `skillet/smtp`: set **Username** and +**Password**, plus custom fields `host` (Mailjet: `in-v3.mailjet.com`), `port` +(`587`), and `tls` (`starttls`). The audit requires it for all declared fleet +hosts in prod and test, checks field presence/formats, and never prints values. +SMTP service delivery is still planned; the check does not authenticate to Mailjet. + Unused `skillet/` entries are reported against **all hosts and both prod/test**, regardless of the check filter; unrelated personal entries are excluded. Unused entries are advisory and are never deleted. No provider calls or credential diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 60a97fd0..8a6a1822 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -217,10 +217,11 @@ pub(super) fn check_vault(args: &SecretCheckArgs) -> Result<()> { .clone() .map_or_else(default_database_path, Ok)?; let vault = Vault::open(&path, args.key_file.as_deref())?; - let report = - skillet_workstation::secrets::check(&profiles, args.environment.policy(), &|path| { - vault.get(path) - })?; + let report = skillet_workstation::secrets::check( + &profiles, + args.environment.policy(), + &|path, field| vault.get_field(path, field), + )?; let unused = skillet_workstation::secrets::unused_paths(&all_profiles, &vault.entry_paths())?; let hyperlinks = std::io::IsTerminal::is_terminal(&std::io::stdout()) && supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout); diff --git a/ublue/skillet/crates/cli/src/secret_output.rs b/ublue/skillet/crates/cli/src/secret_output.rs index 560713ec..b555b10c 100644 --- a/ublue/skillet/crates/cli/src/secret_output.rs +++ b/ublue/skillet/crates/cli/src/secret_output.rs @@ -73,12 +73,12 @@ pub(super) fn render(report: &CheckReport, unused: &[String], hyperlinks: bool) previous_module = &entry.module; } let state = if entry.invalid { - "INVALID (empty or ambiguous)" + "INVALID (fields or ambiguous entry)" } else { "MISSING" }; let _ = writeln!(output, " {state}: {}", entry.path); - output.push_str(" Field: Password (leave Username empty)\n Setup:\n"); + output.push_str(" Setup:\n"); write_guide(&mut output, &entry.guide, hyperlinks); output.push('\n'); } diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json index a7d55128..1dbe00be 100644 --- a/ublue/skillet/crates/workstation/src/secret-requirements.json +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -20,6 +20,43 @@ {"module":"Pi-hole","required_by":["pihole"],"description":"[Delivery](ublue/skillet/README.md#secret-setup-and-delivery).","entries":[ {"path":"skillet/hosts//pihole/web-password","status":"generated","help":"Skillet generates and saves the web admin password for a fresh host; optionally create it to choose a password. For an existing host, restore its entry rather than inventing a replacement."} ]}, + { + "module": "SMTP", + "required_by": [ + "fleet" + ], + "description": "Shared fleet relay credential preparation; SMTP runtime delivery is planned. [SMTP plan](ublue/plan/CLAMPS-EMAIL.md).", + "entries": [ + { + "path": "skillet/smtp", + "status": "required", + "help": "Create one entry using your Mailjet SMTP credentials: Username = SMTP username; Password = SMTP password. Custom fields: host = in-v3.mailjet.com; port = 587; tls = starttls (required TLS with certificate verification). [Mailjet credentials](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters).", + "fields": [ + { + "name": "Password" + }, + { + "name": "UserName" + }, + { + "name": "host", + "rule": "hostname" + }, + { + "name": "port", + "rule": "port" + }, + { + "name": "tls", + "rule": "choice", + "values": [ + "starttls" + ] + } + ] + } + ] + }, {"module":"Storage (planned)","required_by":[],"description":"No entry required today. Keep recovery material off the encrypted host. [Encryption plan](ublue/plan/TPM-ENCRYPTED-ROOT.md).","entries":[ {"path":"skillet/hosts//storage/root-recovery-key","status":"planned","help":"LUKS recovery key; prepare through the future TPM/LUKS workflow."} ]}, diff --git a/ublue/skillet/crates/workstation/src/secrets.rs b/ublue/skillet/crates/workstation/src/secrets.rs index b0678f67..75478c39 100644 --- a/ublue/skillet/crates/workstation/src/secrets.rs +++ b/ublue/skillet/crates/workstation/src/secrets.rs @@ -9,7 +9,7 @@ use std::{ use thiserror::Error; const CATALOG: &str = include_str!("secret-requirements.json"); -const INTRO: &str = "# Skillet secrets checklist\n\nVault: `$XDG_DATA_HOME/skillet/secrets.kdbx` (default:\n`~/.local/share/skillet/secrets.kdbx`). Paths are **group path + entry title**.\nPut every value in **Password**, including IDs and configuration; leave\n**Username** empty. `` is `prod` or `test`; `` is the\ncanonical host name. Only prepare entries for enabled modules.\n\nAudit with `skillet secrets check` (all declared hosts, production), or\n`skillet secrets check --host clamps --environment test`. It checks presence\nand nonempty Password fields, without provider calls or generating secrets.\nMissing optional/generated/planned entries do not fail the check.\n\n\n"; +const INTRO: &str = "# Skillet secrets checklist\n\nVault: `$XDG_DATA_HOME/skillet/secrets.kdbx` (default:\n`~/.local/share/skillet/secrets.kdbx`). Paths are **group path + entry title**.\nUnless an entry documents other fields, put its value in **Password** and\nleave **Username** empty. `` is `prod` or `test`; `` is the\ncanonical host name. Only prepare entries for enabled modules.\n\nAudit with `skillet secrets check` (all declared hosts, production), or\n`skillet secrets check --host clamps --environment test`. It checks required fields and their formats, without provider calls or generating secrets.\nMissing optional/generated/planned entries do not fail the check.\n\n\n"; #[derive(Debug, Error)] pub enum CheckError { @@ -59,6 +59,60 @@ struct Entry { path: String, status: Status, help: String, + #[serde(default)] + fields: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct FieldRequirement { + name: String, + #[serde(default)] + rule: FieldRule, + #[serde(default)] + values: Vec, +} + +#[derive(Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "lowercase")] +enum FieldRule { + #[default] + Nonempty, + Hostname, + Port, + Choice, +} + +impl FieldRequirement { + fn valid(&self, value: &str) -> bool { + if value.trim().is_empty() { + return false; + } + match self.rule { + FieldRule::Nonempty => true, + FieldRule::Port => value.parse::().is_ok_and(|port| port != 0), + FieldRule::Choice => self.values.iter().any(|allowed| allowed == value), + FieldRule::Hostname => valid_hostname(value), + } + } +} + +fn valid_hostname(value: &str) -> bool { + if value.parse::().is_ok() { + return true; + } + let value = value.strip_suffix('.').unwrap_or(value); + !value.is_empty() + && value.len() <= 253 + && value.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) } fn catalog() -> Result, CheckError> { @@ -139,7 +193,9 @@ pub fn unused_paths( fn selected(module: &RequirementGroup, profile: &HostProfile) -> bool { module.required_by.iter().any(|service| { - if service == "ui" { + if service == "fleet" { + profile.signed_image.is_some() + } else if service == "ui" { !profile.ui_services().is_empty() } else { profile.supports_service(service) @@ -157,7 +213,7 @@ fn expand(path: &str, host: &str, environment: &str) -> String { pub fn check( profiles: &[HostProfile], policy: ProvisioningPolicy, - lookup: &impl Fn(&str) -> Result, VaultError>, + lookup: &impl Fn(&str, &str) -> Result, VaultError>, ) -> Result { let mut required = BTreeMap::new(); for module in catalog()? { @@ -189,6 +245,7 @@ pub fn check( ( module.module.clone(), format!("{} {}", module.description, entry.help), + entry.fields.clone(), ) }); } @@ -198,18 +255,46 @@ pub fn check( checked: required.len(), missing: Vec::new(), }; - for (path, (module, guide)) in required { - let invalid = match lookup(&path) { - Ok(Some(value)) if !value.is_empty() => continue, - Ok(None) => false, - Ok(Some(_)) | Err(VaultError::Invalid(_)) => true, - Err(error) => return Err(error.into()), - }; + for (path, (module, mut guide, mut fields)) in required { + if fields.is_empty() { + fields.push(FieldRequirement { + name: "Password".into(), + rule: FieldRule::Nonempty, + values: Vec::new(), + }); + } + let mut invalid_fields = Vec::new(); + let mut present = false; + let mut invalid = false; + for field in fields { + match lookup(&path, &field.name) { + Ok(Some(value)) => { + present = true; + if !field.valid(&value) { + invalid_fields.push(field.name); + } + } + Ok(None) => invalid_fields.push(field.name), + Err(VaultError::Invalid(_)) => { + invalid = true; + invalid_fields.push(field.name); + } + Err(error) => return Err(error.into()), + } + } + if invalid_fields.is_empty() { + continue; + } + let _ = write!( + guide, + " Missing or invalid fields: {}.", + invalid_fields.join(", ") + ); report.missing.push(MissingEntry { module, path, guide, - invalid, + invalid: invalid || present, }); } report.missing.sort_by(|a, b| { @@ -229,7 +314,7 @@ pub fn documentation() -> Result { let _ = write!(output, "\n## {}\n\n{}\n", module.module, module.description); if !module.entries.is_empty() { output.push_str( - "\n| Entry | Status | Password value / how to obtain it |\n| --- | --- | --- |\n", + "\n| Entry | Status | Fields / how to obtain them |\n| --- | --- | --- |\n", ); for entry in module.entries { let _ = writeln!( diff --git a/ublue/skillet/crates/workstation/src/secrets/tests.rs b/ublue/skillet/crates/workstation/src/secrets/tests.rs index 3196c337..3d035d0b 100644 --- a/ublue/skillet/crates/workstation/src/secrets/tests.rs +++ b/ublue/skillet/crates/workstation/src/secrets/tests.rs @@ -27,11 +27,11 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { let report = check( &[profile], ProvisioningPolicy::new(Environment::Test), - &|_| Ok(None), + &|_, _| Ok(None), ) .unwrap(); - assert_eq!(report.checked, 6); - assert_eq!(report.missing.len(), 6); + assert_eq!(report.checked, 7); + assert_eq!(report.missing.len(), 7); assert!(report.missing.iter().any( |entry| entry.path == "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name" )); @@ -45,10 +45,19 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { let report = check( &[profile], ProvisioningPolicy::new(Environment::Production), - &|_| Ok(Some("not-displayed".into())), + &|_, field| { + Ok(Some( + match field { + "port" => "587", + "tls" => "starttls", + _ => "not-displayed", + } + .into(), + )) + }, ) .unwrap(); - assert_eq!(report.checked, 2); + assert_eq!(report.checked, 3); assert!(report.missing.is_empty()); } @@ -57,7 +66,7 @@ fn invalid_and_empty_entries_are_reported_without_values_or_lookup_error_text() let profiles = skillet_hosts::profile::declared_profiles(); let policy = ProvisioningPolicy::new(Environment::Production); for lookup in [false, true] { - let report = check(&profiles, policy, &|_| { + let report = check(&profiles, policy, &|_, _| { if lookup { Err(VaultError::Invalid("private-value-do-not-print".into())) } else { @@ -110,3 +119,123 @@ fn template_reference_discovery_is_vault_free() { assert_eq!(paths, ["skillet/datadog/api-key"]); } } + +#[test] +fn smtp_fields_are_required_validated_and_never_rendered() { + let profiles = [skillet_hosts::profile_for_name("clamps").unwrap()]; + for environment in [Environment::Test, Environment::Production] { + for bad_field in ["UserName", "Password", "host", "port", "tls"] { + for missing in [false, true] { + let report = check( + &profiles, + ProvisioningPolicy::new(environment), + &|path, field| { + if path != "skillet/smtp" { + return Ok(Some("fixture-only".into())); + } + if field == bad_field { + return Ok(if missing { + None + } else { + Some( + if matches!(field, "UserName" | "Password") { + " " + } else { + "PRIVATE-INVALID-VALUE!" + } + .into(), + ) + }); + } + Ok(Some( + match field { + "host" => "smtp.example.com", + "port" => "587", + "tls" => "starttls", + _ => "PRIVATE-CREDENTIAL", + } + .into(), + )) + }, + ) + .unwrap(); + assert_eq!(report.missing.len(), 1); + let failure = &report.missing[0]; + assert_eq!(failure.path, "skillet/smtp"); + assert!(failure.invalid); + assert!(failure.guide.contains(bad_field)); + assert!(!failure.guide.contains("PRIVATE-")); + } + } + } +} + +#[test] +fn field_rules_reject_unsafe_hosts_ports_and_tls_modes() { + for value in [ + "", + " ", + "smtp://example.com", + "user@example.com", + "-bad.example", + "bad..example", + "bad\n.example", + ] { + assert!(!valid_hostname(value), "{value:?}"); + } + for value in ["smtp.example.com", "127.0.0.1", "::1"] { + assert!(valid_hostname(value)); + } + let port = FieldRequirement { + name: "port".into(), + rule: FieldRule::Port, + values: vec![], + }; + for value in ["0", "65536", "-1", "587\n", "587:25"] { + assert!(!port.valid(value)); + } + assert!(port.valid("587")); + let tls = FieldRequirement { + name: "tls".into(), + rule: FieldRule::Choice, + values: vec!["starttls".into()], + }; + for value in ["", "none", "optional", "STARTTLS"] { + assert!(!tls.valid(value)); + } + assert!(tls.valid("starttls")); +} + +#[test] +fn shared_smtp_is_counted_once_and_is_not_unused() { + let profiles = skillet_hosts::declared_profiles(); + let report = check( + &profiles, + ProvisioningPolicy::new(Environment::Test), + &|_, _| Ok(None), + ) + .unwrap(); + assert_eq!( + report + .missing + .iter() + .filter(|entry| entry.path == "skillet/smtp") + .count(), + 1 + ); + assert_eq!( + unused_paths(&profiles, &["skillet/smtp".into()]).unwrap(), + Vec::::new() + ); + let agent = skillet_hosts::profile_for_name("agent").unwrap(); + assert_eq!( + check( + &[agent], + ProvisioningPolicy::new(Environment::Test), + &|_, _| Ok(None) + ) + .unwrap() + .checked, + 0 + ); +} diff --git a/ublue/skillet/crates/workstation/src/vault.rs b/ublue/skillet/crates/workstation/src/vault.rs index 86a351a1..d254b64f 100644 --- a/ublue/skillet/crates/workstation/src/vault.rs +++ b/ublue/skillet/crates/workstation/src/vault.rs @@ -122,6 +122,11 @@ impl Vault { lookup(&self.database, path) } + /// Read a standard or custom field from an exact entry without logging values. + pub fn get_field(&self, path: &str, field: &str) -> Result, VaultError> { + lookup_field(&self.database, path, field) + } + pub fn insert(&mut self, path: &str, secret: &str) -> Result<(), VaultError> { create_entry(&mut self.database, path, secret) } @@ -321,6 +326,34 @@ fn open_with_password( } fn lookup(database: &Database, path: &str) -> Result, VaultError> { + let Some(secret) = lookup_field(database, path, "Password")? else { + if find_entry(database, path)?.is_some() { + return Err(VaultError::Invalid(format!( + "KeePassXC entry {path} has no Password field" + ))); + } + return Ok(None); + }; + if secret.is_empty() { + return Err(VaultError::Invalid(format!( + "KeePassXC entry {path} has an empty Password field" + ))); + } + Ok(Some(secret)) +} + +fn lookup_field( + database: &Database, + path: &str, + field: &str, +) -> Result, VaultError> { + Ok(find_entry(database, path)?.and_then(|entry| entry.get(field).map(str::to_owned))) +} + +fn find_entry<'a>( + database: &'a Database, + path: &str, +) -> Result>, VaultError> { let parts: Vec<_> = path.split('/').collect(); if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { return Err(VaultError::Invalid("invalid KeePassXC entry path".into())); @@ -346,18 +379,7 @@ fn lookup(database: &Database, path: &str) -> Result, VaultError> "KeePassXC entry {path} is ambiguous" ))); } - let Some(entry) = matches.pop() else { - return Ok(None); - }; - let secret = entry.get_password().ok_or_else(|| { - VaultError::Invalid(format!("KeePassXC entry {path} has no Password field")) - })?; - if secret.is_empty() { - return Err(VaultError::Invalid(format!( - "KeePassXC entry {path} has an empty Password field" - ))); - } - Ok(Some(secret.to_owned())) + Ok(matches.pop()) } fn create_entry(database: &mut Database, path: &str, password: &str) -> Result<(), VaultError> { diff --git a/ublue/skillet/crates/workstation/src/vault/tests.rs b/ublue/skillet/crates/workstation/src/vault/tests.rs index b4bdd97e..48ad99dd 100644 --- a/ublue/skillet/crates/workstation/src/vault/tests.rs +++ b/ublue/skillet/crates/workstation/src/vault/tests.rs @@ -201,3 +201,45 @@ fn missing_vault_fails_before_password_prompt() { assert!(error.to_string().contains("missing or unreadable")); assert!(error.to_string().contains("secrets.kdbx")); } + +#[test] +fn exact_field_lookup_supports_standard_protected_and_custom_fields() { + let mut db = Database::new(); + let mut root = db.root_mut(); + let mut group = root.add_group(); + group.name = "skillet".into(); + let mut entry = group.add_entry(); + entry.set_unprotected("Title", "smtp"); + entry.set_protected("UserName", "fixture-user"); + entry.set_protected("Password", "fixture-password"); + entry.set_unprotected("host", "smtp.example.com"); + entry.set_protected("tls", "starttls"); + for (field, expected) in [ + ("UserName", "fixture-user"), + ("Password", "fixture-password"), + ("host", "smtp.example.com"), + ("tls", "starttls"), + ] { + assert_eq!( + super::lookup_field(&db, "skillet/smtp", field) + .unwrap() + .as_deref(), + Some(expected) + ); + } + assert!(super::lookup_field(&db, "skillet/smtp", "port") + .unwrap() + .is_none()); + assert!(super::lookup_field(&db, "skillet/missing", "Password") + .unwrap() + .is_none()); + let mut root = db.root_mut(); + let mut group = root.add_group(); + group.name = "skillet".into(); + let mut entry = group.add_entry(); + entry.set_unprotected("Title", "smtp"); + assert!(matches!( + super::lookup_field(&db, "skillet/smtp", "host"), + Err(VaultError::Invalid(_)) + )); +} From b145f49190d77e610fd8876da89cfb148fc24a17 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 12:34:29 +0200 Subject: [PATCH 225/252] feat(smtp): provision shared native fleet relay with isolated tests --- SECRETS.md | 4 +- ublue/AGENTS.md | 3 +- ublue/butane/ACCEPTANCE.md | 34 +++ ublue/butane/includes/skillet.bu | 16 + ublue/design/email-delivery.md | 30 +- ublue/design/secrets.md | 5 +- ublue/plan/CLAMPS-EMAIL.md | 32 +- ublue/skillet/AGENTS.md | 6 + ublue/skillet/Cargo.lock | 16 + ublue/skillet/Cargo.toml | 2 + ublue/skillet/README.md | 58 +++- ublue/skillet/crates/cli-common/Cargo.toml | 1 + ublue/skillet/crates/cli-common/src/hosts.rs | 2 + ublue/skillet/crates/cli-common/src/lib.rs | 23 ++ ublue/skillet/crates/cli/src/main.rs | 14 +- .../skillet/crates/cli/src/secret_delivery.rs | 61 +++- .../crates/cli/src/secret_delivery/tests.rs | 31 ++ ublue/skillet/crates/host-profiles/Cargo.toml | 1 + .../crates/host-profiles/src/hosts_tests.rs | 33 ++- ublue/skillet/crates/host-profiles/src/lib.rs | 31 +- .../crates/host-profiles/src/profile.rs | 65 +++- ublue/skillet/crates/smtp/Cargo.toml | 20 ++ ublue/skillet/crates/smtp/src/lib.rs | 278 ++++++++++++++++++ ublue/skillet/crates/smtp/src/postfix.conf | 7 + ublue/skillet/crates/smtp/src/prepare.service | 10 + ublue/skillet/crates/smtp/src/tests.rs | 121 ++++++++ ublue/skillet/crates/smtp/templates/main.cf | 32 ++ .../crates/smtp/tests/native_acceptance.py | 196 ++++++++++++ ublue/skillet/crates/workstation/Cargo.toml | 1 + .../src/credential_delivery/tests.rs | 23 ++ ublue/skillet/crates/workstation/src/lib.rs | 1 + .../workstation/src/secret-requirements.json | 11 +- .../skillet/crates/workstation/src/secrets.rs | 16 +- .../crates/workstation/src/secrets/tests.rs | 30 +- .../workstation/src/smtp_provisioning.rs | 67 +++++ .../src/smtp_provisioning/tests.rs | 35 +++ 36 files changed, 1252 insertions(+), 64 deletions(-) create mode 100644 ublue/skillet/crates/smtp/Cargo.toml create mode 100644 ublue/skillet/crates/smtp/src/lib.rs create mode 100644 ublue/skillet/crates/smtp/src/postfix.conf create mode 100644 ublue/skillet/crates/smtp/src/prepare.service create mode 100644 ublue/skillet/crates/smtp/src/tests.rs create mode 100644 ublue/skillet/crates/smtp/templates/main.cf create mode 100644 ublue/skillet/crates/smtp/tests/native_acceptance.py create mode 100644 ublue/skillet/crates/workstation/src/smtp_provisioning.rs create mode 100644 ublue/skillet/crates/workstation/src/smtp_provisioning/tests.rs diff --git a/SECRETS.md b/SECRETS.md index 5b6e16a9..05876a0e 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -63,11 +63,11 @@ Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skil ## SMTP -Shared fleet relay credential preparation; SMTP runtime delivery is planned. [SMTP plan](ublue/plan/CLAMPS-EMAIL.md). +Production-only native Postfix relay credentials; smoke machines use a local sink without these values. [SMTP plan](ublue/plan/CLAMPS-EMAIL.md). | Entry | Status | Fields / how to obtain them | | --- | --- | --- | -| `skillet/smtp` | Required | Create one entry using your Mailjet SMTP credentials: Username = SMTP username; Password = SMTP password. Custom fields: host = in-v3.mailjet.com; port = 587; tls = starttls (required TLS with certificate verification). [Mailjet credentials](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters). | +| `skillet/smtp` | Required | Create one entry using your Mailjet SMTP credentials: Username = SMTP username; Password = SMTP password. Custom fields: host = in-v3.mailjet.com; port = 587; tls = starttls (required TLS with certificate verification); sender = a Mailjet-verified sender email address. [Mailjet credentials](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters). | ## Storage (planned) diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 854f2f7e..81aafd85 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -23,7 +23,8 @@ and regenerate `SECRETS.md` with `cargo run -p skillet_workstation --example secrets_documentation` from the Skillet workspace (redirect output to the repository-root file). Keep template-reference coverage and the documentation - equality test passing. Required checks follow selected host capabilities. + equality test passing. Required checks follow selected host capabilities and the actual environment + delivery policy; isolated test modes must not require unused provider credentials. Structured vault entries must declare each required standard/custom field and its validation in the shared audit metadata; keep single-value entry behavior compatible, and never include field values in audit output or errors. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 5b243b3e..ae7833b5 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2023,3 +2023,37 @@ host tool; it still uses normal command approvals. - No production credentials, production DNS, or Cloudflare APIs were used for this recovery check. Production delivery/renewal and outside-tailnet denial remain deferred from the refactoring gate. + + +### Native fleet SMTP provisioning, 2026-10-09 + +- ucore-images commit `3223d48` adds Postfix and `cyrus-sasl-plain` to the common + image, initially disabled. The previously published common image was inspected + and lacked both packages. Package availability was verified in a disposable + Fedora image; the signed uCore image rebuild remains an operator step. +- Passed `cargo fmt --all -- --check`, pedantic workspace Clippy, all 323 workspace + tests, Fedora beezelbot base integration (repeat apply issued no service + start/restart), Butane bootstrap regression tests, and Ruff check/format for + the native acceptance fixture. These checks require no provider credentials. +- Production `secrets check --host clamps --environment production` passed: + 7 required checked, 0 missing/invalid, 0 unused, including SMTP sender/custom + fields. This validates presence/formats, not Mailjet permissions or sender DNS. +- Ran `crates/smtp/tests/native_acceptance.py` against the Cargo-reported musl CLI + in fresh disposable Fedora systemd containers with `--network none` and + synthetic credentials/addresses. Capture and production-policy runs passed; + both containers were removed. No provider APIs or external email were used. +- Actual Postfix accepted host sendmail submission, preserved the recipient and + queued message across stop/apply-start, and delivered to the loopback capture + sink after an upstream outage. Repeated apply preserved the Postfix PID. + Credential preparation regenerated a deleted volatile map. The production + map was root-owned mode `0640`, under root/Postfix-group mode `0750`; a query + under the Postfix worker identity verified backend access. +- With production policy and a synthetic upstream that did not offer STARTTLS, + Postfix retained the queued message and sent no AUTH, MAIL or message content. + Sender canonical rewriting preserved the recipient. This is not acceptance + of provider authentication or certificate validation against a TLS server. +- Pending: rebuilt uCore VM with enforcing SELinux, boot/reboot/rebase map and + queue persistence, non-loopback access rejection, certificate/authentication + failures, explicit Mailjet/Gmail delivery with SPF/DKIM/DMARC inspection, and + UniFi recovery-email configuration. Bridge clients and queue-age/send-failure + metrics are separate remaining implementation slices; unit health is declared. diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu index 51e182b3..a8622be3 100644 --- a/ublue/butane/includes/skillet.bu +++ b/ublue/butane/includes/skillet.bu @@ -74,5 +74,21 @@ systemd: LoadCredentialEncrypted=datadog_config:/etc/credstore.encrypted/skillet/datadog_config.cred ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase datadog + [Install] + WantedBy=multi-user.target + - name: skillet-smtp-apply.service + enabled: true + contents: | + [Unit] + Description=Apply shared fleet SMTP configuration + Requires=skillet-apply.service + After=skillet-apply.service + ConditionPathExists=/etc/credstore.encrypted/skillet/smtp_config.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=smtp_config:/etc/credstore.encrypted/skillet/smtp_config.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase smtp + [Install] WantedBy=multi-user.target diff --git a/ublue/design/email-delivery.md b/ublue/design/email-delivery.md index f406ae29..8eaed066 100644 --- a/ublue/design/email-delivery.md +++ b/ublue/design/email-delivery.md @@ -1,8 +1,10 @@ # Fleet email delivery -Status: proposed after repository/provider research; not implemented. +Status: image packages and shared SMTP provisioning implemented. Native Postfix +container acceptance passed; rebuilt uCore VM and live provider acceptance remain +pending. -## Recommended boundary +## Implementation boundary Use native Postfix as a submission relay with a persistent retry queue. Install Postfix and its required SASL/TLS support in `ucore-common`; keep the shipped @@ -22,11 +24,12 @@ queue without extra machinery. Mailjet is the existing provider. Read a single `skillet/smtp` entry: Username and Password contain SMTP credentials; `host`, `port` and `tls` custom -fields hold the endpoint and security mode. Field-aware reading/auditing is -implemented; service delivery is pending. The audit requires these fields once -for shared fleet preparation in both environments and currently accepts only -`starttls` for TLS. Missing/invalid field names are public diagnostics; values -are never printed. SMTP credentials are the provider’s API key/secret, rather +fields hold the endpoint and security mode; `sender` holds the verified sender +address. Field-aware reading, auditing and encrypted delivery are implemented. +The audit requires these fields once for production fleet preparation. Test +provisioning neither reads nor delivers provider credentials. Only `starttls` is +accepted; production requires certificate and hostname verification. +Missing/invalid field names are public diagnostics; values are never printed. SMTP credentials are the provider’s API key/secret, rather than the account login ([provider guide](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters)). ## Delivery and access @@ -35,8 +38,11 @@ Relay only through an authenticated external provider, using verified TLS; never fall back to direct destination-MX delivery. Keep the queue in persistent `/var/spool/postfix`, independent of the service-data mount. Shared templates hold policy; private configuration and credentials use the existing vault and -systemd encrypted-delivery workflow. Validate credential-map support, service -permissions and worker/chroot behavior before selecting its concrete backend. +systemd encrypted-delivery workflow. A root-owned, Postfix-group-readable +`texthash` map (`0640`, parent `0750`) under `/run/postfix/skillet` is regenerated +from `LoadCredentialEncrypted=` before Postfix starts. Native Fedora acceptance +verified the backend and permissions; enforcing SELinux remains a VM gate. +Sender canonical rewriting preserves recipients. Never bake credentials or sender domains into an image. Default listener: loopback only. UniFi uses host networking and can reach it. @@ -44,11 +50,15 @@ Bridge containers need an explicitly declared internal endpoint with restricted access, preferably authenticated submission; do not expose port 25 on the LAN or tailnet, or trust an entire bridge containing unrelated containers by default. Application modules own their SMTP client integration and monitoring declarations. +Postfix and credential-preparation unit health are declared by the SMTP module; +queue-age and delivery-failure metrics remain pending. Production fleet hosts include SMTP as shared service policy. Test machines use an isolated capture sink by default, with no external provider delivery or copied production credentials. Real delivery is an explicit operator-controlled test. -Do not send queued lab mail later when changing a test instance to another policy. +Retained queues cannot switch between capture and production policy; apply and +credential preparation both enforce the persisted mode. The test relay targets +loopback port 1025; messages queue until an acceptance sink is running. Use a provider-verified sender and SPF/DKIM/DMARC. The provider handles internet SMTP delivery infrastructure and IP reputation; authentication does not guarantee diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index fa472025..9256a23d 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -95,8 +95,9 @@ uses provider APIs; it checks declared field presence/formats rather than provid permissions or host recovery state. Existing entries default to Password-only validation. Structured entries declare standard/custom fields and validators in the shared metadata. `skillet/smtp` uses Username/Password and host/port/tls -custom fields; its shared fleet audit is implemented, while runtime SMTP -delivery remains planned. `secrets` is the canonical CLI group; `secret` remains an alias. +and sender custom fields. Its shared fleet audit requires the entry for production +only. Encrypted delivery activates a dedicated native SMTP phase; disposable +machines receive a credential-free capture payload without consulting the entry. `secrets` is the canonical CLI group; `secret` remains an alias. Use the generic [configuration template design](configuration-templates.md) when service configuration combines clear policy with KeePassXC values. The diff --git a/ublue/plan/CLAMPS-EMAIL.md b/ublue/plan/CLAMPS-EMAIL.md index 7b57b914..b0206c9a 100644 --- a/ublue/plan/CLAMPS-EMAIL.md +++ b/ublue/plan/CLAMPS-EMAIL.md @@ -1,9 +1,9 @@ # Fleet SMTP implementation -Status: investigation completed; implementation pending. UniFi isolated restore -acceptance is complete. Native Postfix + shared Skillet module is the recommended -approach. Mailjet credentials and endpoint are confirmed by the operator; -verified sender/domain configuration remains to prepare. +Status: native Postfix image packages and shared provisioning implemented. +Isolated native-consumer checks passed; rebuilt uCore VM acceptance is pending. +Mailjet entry fields, including sender, pass the production vault audit. Provider +authentication and verified sender/domain DNS remain live acceptance work. See [the design](../design/email-delivery.md). ## Findings @@ -27,22 +27,25 @@ See [the design](../design/email-delivery.md). requirements through shared metadata/root checklist; keep provider password and private sender/endpoint leaves out of the public repo. Use a shared credential where its actual account scope allows it, with explicit test policy. -2. Add native Postfix and required authentication/TLS packages to the common - image, initially inactive. Check the base image for existing packages and +2. **Implemented:** add native Postfix and required authentication/TLS packages + to the common image, initially inactive. Check the base image for existing packages and units first. Keep all runtime values and authentication material out of it. -3. Add a cohesive `skillet_smtp` service crate and typed configuration template. +3. **Implemented; VM gates pending:** add a cohesive `skillet_smtp` service + crate and typed configuration template. Declare fleet inclusion centrally, separate from credential-free base apply. Deliver the entire prerequisite set before activation using existing encrypted credential workflow. Validate map backend, file permissions, SELinux and service/worker access using the actual runtime. Preserve queued mail across repeat apply, reboot and image rebase. Require authenticated verified-TLS upstream; no direct-MX fallback. Queue lives in `/var/spool/postfix`. -4. Support host sendmail and loopback submission first. Add explicit bridge +4. **Host/loopback implemented; application setup pending:** support host + sendmail and loopback submission first. Add explicit bridge client access only for declared applications; do not publish LAN/tailnet SMTP or broadly allow arbitrary bridge clients. Applications own their SMTP client integration. Configure UniFi recovery email via a supported interface, with any necessary operator UI handoff recorded. -5. Add module-owned monitoring for service health, deferred queue age and send +5. **Unit health implemented; queue/failure metrics pending:** add module-owned + monitoring for service health, deferred queue age and send failures without credential/message-body disclosure. Test hosts use a local capture sink and environment tags; provider delivery remains explicit opt-in. @@ -64,3 +67,14 @@ See [the design](../design/email-delivery.md). References: [Postfix relay configuration](https://www.postfix.org/SOHO_README.html), [Gmail requirements](https://support.google.com/mail/answer/81126?hl=en). + +## Next acceptance slice + +After the operator pushes ucore-images commit `3223d48` and signed images rebuild, +create a named disposable VM and run `test vm provision +--smtp-only`. Verify enforcing SELinux access, boot map regeneration, loopback +submission, queue persistence through reboot/rebase, and non-loopback denial. +The capture sink is test-only; no production SMTP credential is delivered. +Then perform explicit Mailjet/Gmail acceptance and configure UniFi SMTP through +its UI. Bridge client access and queue-age/send-failure monitoring remain separate +work. See the native-consumer evidence in `butane/ACCEPTANCE.md`. diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 5f54b5e5..17d31861 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -101,6 +101,12 @@ tracks existing violations and their migration, not completed implementation. seccomp, mount and LSM policy before declaring the host kernel unsupported. Match syscall allow rules to explicitly selected capabilities; test the smallest policy change and retain restrictions unrelated to the feature. +- Restrict credential-file access to the identities that actually consume them. + Test access under the worker identity, rather than inferring it from a + successful privileged preparation command. +- Regenerate volatile credential files before their consumers start. Validate + persisted environment policy during both apply and boot preparation; retained + queues or application data must not silently cross deployment policies. - Discovery acceptance must cover concurrent boot startup as well as steady state. Derive startup ordering from existing caller declarations and keep ordering separate from dependencies that activate optional services; an diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 9b97ce51..192d2dab 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2382,6 +2382,7 @@ dependencies = [ "serde_yml", "skillet_core", "skillet_hosts", + "skillet_smtp", "tempfile", "thiserror 1.0.69", "tracing", @@ -2452,6 +2453,7 @@ dependencies = [ "skillet_hardening", "skillet_pihole", "skillet_podman", + "skillet_smtp", "skillet_syncthing", "skillet_unifi", "thiserror 1.0.69", @@ -2483,6 +2485,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "skillet_smtp" +version = "0.1.0" +dependencies = [ + "askama", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "skillet_core", + "thiserror 1.0.69", +] + [[package]] name = "skillet_syncthing" version = "0.1.0" @@ -2540,6 +2555,7 @@ dependencies = [ "skillet_datadog", "skillet_ddns", "skillet_hosts", + "skillet_smtp", "skillet_vm", "tempfile", "thiserror 1.0.69", diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 54ccfa89..dd0e8009 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/caddy", "crates/ddns", "crates/datadog", + "crates/smtp", "crates/podman", "crates/cli", "crates/hosts/beezelbot", @@ -34,6 +35,7 @@ skillet_btrbk = { path = "crates/btrbk" } skillet_caddy = { path = "crates/caddy" } skillet_ddns = { path = "crates/ddns" } skillet_datadog = { path = "crates/datadog" } +skillet_smtp = { path = "crates/smtp" } skillet_podman = { path = "crates/podman" } skillet_cli_common = { path = "crates/cli-common" } skillet_vm = { path = "crates/vm" } diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index bf429b2d..0791915d 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -203,9 +203,11 @@ the checklist's setup instructions, and exits unsuccessfully if any are missing or invalid. Optional and automatically generated entries need not exist. SMTP preparation uses one entry, `skillet/smtp`: set **Username** and **Password**, plus custom fields `host` (Mailjet: `in-v3.mailjet.com`), `port` -(`587`), and `tls` (`starttls`). The audit requires it for all declared fleet -hosts in prod and test, checks field presence/formats, and never prints values. -SMTP service delivery is still planned; the check does not authenticate to Mailjet. +(`587`), `tls` (`starttls`), and `sender` (a Mailjet-verified email address). +The audit requires it for all declared fleet hosts in production, checks field +presence/formats, and never prints values. +Test-policy checks omit Mailjet credentials because capture delivery never uses them. +The check does not authenticate to Mailjet. Native SMTP delivery is described below. Unused `skillet/` entries are reported against **all hosts and both prod/test**, regardless of the check filter; unrelated personal entries are excluded. Unused @@ -470,3 +472,53 @@ record/token ownership is cleaned up by `test vm destroy`; do not use direct - **Idempotency**: All modules must ensure system state idempotently. - **System Interactions**: Prioritize Rust crates (e.g., `zbus`, `users`) over shelling out to system commands. - **Linting**: Run `cargo clippy --workspace --all-targets -- -D warnings`; the workspace enables pedantic lints with documented exceptions. + +## Native SMTP relay + +The shared fleet declaration includes native Postfix. The common image supplies +`postfix` and `cyrus-sasl-plain`, initially disabled. Delivery starts the separate +SMTP apply phase; credential-free base and normal application apply still work +without relay credentials. See [the design](../design/email-delivery.md). + +Create `skillet/smtp` as described above, including a verified `sender`. For a +production host, use the existing verified SSH target/identity/known-hosts options: + +```bash +cargo run --release -p skillet -- secrets deliver clamps smtp --target USER@ADDRESS --identity KEY --known-hosts KNOWN_HOSTS --environment production +``` + +This transmits a validated payload through SSH stdin, stores `smtp_config` +encrypted on the guest, and starts `skillet-smtp-apply.service`. The relay accepts +only loopback SMTP on port 25 and host `sendmail` submissions. UniFi's host-network +container can use `127.0.0.1:25` without SMTP authentication or TLS on that local +hop; Postfix authenticates to Mailjet over required, verified STARTTLS. Configure +UniFi's SMTP sender/test-email settings in its UI. Bridge application access is +not enabled yet. + +Postfix's provider map is root-owned, readable only by root and the Postfix +group (`0640`, parent `0750`) under `/run/postfix/skillet/`, and regenerated +from the encrypted credential before service startup. Queued mail persists in +`/var/spool/postfix`. Local senders are rewritten to the verified sender; recipient +addresses are preserved. Mailjet domain verification/SPF/DKIM/DMARC still need +provider setup and live acceptance. + +For an owned disposable VM, configure only the isolated relay without unlocking +KeePass or joining Tailscale: + +```bash +cargo run --release -p skillet -- test vm provision clamps smtp --smtp-only +``` + +The VM must already exist and be ready with the rebuilt image/current Skillet. +Ordinary application provisioning also configures this capture policy. Test mail +queues until a local sink is listening on `127.0.0.1:1025`; no provider credentials +are read or delivered. Switching an existing relay between production and capture +is refused to protect queued mail. Use a new disposable instance for another policy. + +`crates/smtp/tests/native_acceptance.py BINARY HOST [--production-policy]` is a +native-consumer fixture for an isolated **Podman systemd container with +`--network none`** and Postfix/Python installed. It uses synthetic credentials, +checks repeat apply, queue restart/retry and map regeneration; production-policy +mode also checks that an upstream without STARTTLS receives no credentials/mail. +It refuses to run outside that test container. Compile the CLI via Cargo and use +its reported artifact; the fixture is separate from VM lifecycle management. diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index dcc3a95d..d9e3eed6 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_smtp.workspace = true skillet_core.workspace = true skillet_hosts.workspace = true clap.workspace = true diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs index 06ebeb35..12dc8b3a 100644 --- a/ublue/skillet/crates/cli-common/src/hosts.rs +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -17,6 +17,7 @@ pub enum ApplyPhase { Caddy, Ddns, Datadog, + Smtp, } pub fn apply_host_phase( @@ -32,6 +33,7 @@ pub fn apply_host_phase( ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, ApplyPhase::Ddns => skillet_hosts::HostApplyPhase::Ddns, ApplyPhase::Datadog => skillet_hosts::HostApplyPhase::Datadog, + ApplyPhase::Smtp => skillet_hosts::HostApplyPhase::Smtp, }; skillet_hosts::apply_host_phase(hostname, phase, system, files, credentials) } diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 6d00d496..c75bfd46 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -54,6 +54,11 @@ pub struct HostArgs { #[derive(clap::Subcommand, Debug)] pub enum HostCommands { + /// Prepare native SMTP runtime files from an encrypted systemd credential + Smtp { + #[command(subcommand)] + command: SmtpCommands, + }, /// Apply configuration Apply { #[arg(long, value_enum, default_value_t = ApplyPhase::Full)] @@ -105,6 +110,9 @@ where tracing::subscriber::set_global_default(subscriber)?; match args.command { + HostCommands::Smtp { + command: SmtpCommands::Prepare, + } => prepare_smtp(), HostCommands::Apply { record, phase } => { handle_host_apply(hostname, phase, record, |system, files, credentials| { apply_fn(phase, system, files, credentials) @@ -157,6 +165,7 @@ where ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, ApplyPhase::Ddns => skillet_hosts::HostApplyPhase::Ddns, ApplyPhase::Datadog => skillet_hosts::HostApplyPhase::Datadog, + ApplyPhase::Smtp => skillet_hosts::HostApplyPhase::Smtp, }; let required = skillet_hosts::credentials_for_phase(hostname, phase) .map_err(|error| CliCommonError::Config(error.to_string()))?; @@ -261,3 +270,17 @@ fn persist_recording( #[cfg(test)] #[path = "recording_tests.rs"] mod recording_tests; + +#[derive(clap::Subcommand, Debug)] +pub enum SmtpCommands { + Prepare, +} + +pub fn prepare_smtp() -> Result<(), CliCommonError> { + let manager = CredentialManager::new()?; + let payload = manager.read_secret(skillet_smtp::CREDENTIAL)?; + let input = + skillet_smtp::Input::parse(&payload).map_err(|e| CliCommonError::Config(e.to_string()))?; + skillet_smtp::prepare(&LocalFileResource::new(), &input) + .map_err(|e| CliCommonError::Config(e.to_string())) +} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index c8e6aaf9..8c14f24c 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -20,6 +20,11 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { + /// Prepare guest SMTP runtime credentials + Smtp { + #[command(subcommand)] + command: skillet_cli_common::SmtpCommands, + }, /// Manage host secrets backed by `KeePassXC` #[command(name = "secrets", visible_alias = "secret")] Secret { @@ -87,7 +92,7 @@ struct SecretLockArgs { #[derive(clap::Args, Debug)] struct SecretDeliverArgs { hostname: String, - #[arg(value_parser = ["pihole", "tailscale", "caddy", "ddns", "datadog"])] + #[arg(value_parser = ["pihole", "tailscale", "caddy", "ddns", "datadog", "smtp"])] service: String, #[arg(long)] database: Option, @@ -207,6 +212,9 @@ struct VmDestroyArgs { // These CLI switches select independent optional provisioning operations. #[allow(clippy::struct_excessive_bools)] struct VmProvisionArgs { + /// Configure only isolated SMTP capture; no vault, Tailscale or external delivery + #[arg(long)] + smtp_only: bool, hostname: String, instance: String, /// Also provision private UI DNS, Caddy, and disposable Cloudflare credentials @@ -281,6 +289,9 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { + Commands::Smtp { + command: skillet_cli_common::SmtpCommands::Prepare, + } => skillet_cli_common::prepare_smtp()?, Commands::Secret { command: SecretCommands::Check(args), } => secret_delivery::check_vault(&args)?, @@ -1244,6 +1255,7 @@ fn run_twice_and_check(name: &str, phase: ApplyPhase, inspect: bool) -> Result<( ApplyPhase::Caddy => "caddy", ApplyPhase::Ddns => "ddns", ApplyPhase::Datadog => "datadog", + ApplyPhase::Smtp => "smtp", }; for round in ["first", "second"] { let status = Command::new("podman") diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 8a6a1822..ecc9ec55 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -17,12 +17,14 @@ use std::path::Path; pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { validate_delivery_service(&args.hostname, &args.service)?; - if matches!(args.service.as_str(), "ddns" | "datadog") + if matches!(args.service.as_str(), "ddns" | "datadog" | "smtp") && args.environment.policy().environment() != skillet_workstation::provisioning_policy::Environment::Production { let service = if args.service == "ddns" { "DDNS" + } else if args.service == "smtp" { + "SMTP" } else { "Datadog" }; @@ -68,6 +70,18 @@ pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { } "caddy" => deliver_caddy_from_vault(args, &mut vault), "ddns" => deliver_ddns_from_vault(args, &mut vault), + "smtp" => { + let input = skillet_workstation::smtp_provisioning::input( + args.environment.policy(), + &|path, field| vault.get_field(path, field), + )?; + skillet_workstation::smtp_provisioning::deliver( + &args.hostname, + &input, + &credential_transport(args)?, + )?; + Ok(()) + } "datadog" => { let transport = credential_transport(args)?; deliver_datadog_from_vault( @@ -85,13 +99,17 @@ fn validate_delivery_service(hostname: &str, service: &str) -> Result Err( - anyhow!("host {hostname} does not declare {service} credential delivery"), - ), + "pihole" | "tailscale" | "ddns" | "datadog" | "smtp" + if !profile.supports_service(service) => + { + Err(anyhow!( + "host {hostname} does not declare {service} credential delivery" + )) + } "caddy" if profile.ui_services().is_empty() => { Err(anyhow!("host {hostname} declares no UI services")) } - "pihole" | "tailscale" | "caddy" | "ddns" | "datadog" => Ok(profile), + "pihole" | "tailscale" | "caddy" | "ddns" | "datadog" | "smtp" => Ok(profile), unsupported => Err(anyhow!("unsupported secret service {unsupported}")), } } @@ -293,7 +311,9 @@ fn default_database_path() -> Result { pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let profile = skillet_hosts::profile_for_name(&args.hostname) .ok_or_else(|| anyhow!("unknown host profile: {}", args.hostname))?; - if !profile.supports_service("pihole") || !profile.supports_service("tailscale") { + if !args.smtp_only + && (!profile.supports_service("pihole") || !profile.supports_service("tailscale")) + { return Err(anyhow!( "host {} must declare both Pi-hole and Tailscale to use VM application provisioning", args.hostname @@ -305,6 +325,11 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { if args.with_datadog && !profile.supports_service("datadog") { return Err(anyhow!("host {} does not declare Datadog", args.hostname)); } + if args.smtp_only && (args.with_ui || args.with_ddns || args.with_datadog || args.rotate) { + return Err(anyhow!( + "--smtp-only cannot be combined with application provisioning switches" + )); + } let butane = butane_root()?; let run_identity = RunIdentity::new(&args.hostname, &args.instance)?; let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; @@ -319,6 +344,22 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let verify_ownership = || verify_running_vm(&backend, &run); verify_ownership()?; let transport = OwnershipCheckedTransport::new(&base_transport, &verify_ownership); + if args.smtp_only { + skillet_workstation::smtp_provisioning::deliver( + &args.hostname, + &skillet_workstation::smtp_provisioning::input( + UiEnvironmentName::Test.policy(), + &|_, _| { + Err(skillet_workstation::vault::VaultError::Invalid( + "test policy must not read credentials".into(), + )) + }, + )?, + &transport, + )?; + println!("Configured isolated SMTP capture relay; start a loopback sink on port 1025"); + return Ok(()); + } validate_vm_tailscale_delivery(&transport)?; let vault_path = match &args.database { Some(path) => path.clone(), @@ -357,6 +398,14 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { if args.with_datadog { deliver_datadog_from_vault(&args.hostname, policy, &vault, &transport)?; } + if profile.supports_service("smtp") { + let input = skillet_workstation::smtp_provisioning::input(policy, &|_, _| { + Err(skillet_workstation::vault::VaultError::Invalid( + "test policy must not read credentials".into(), + )) + })?; + skillet_workstation::smtp_provisioning::deliver(&args.hostname, &input, &transport)?; + } provisioning_state::remove_tailscale_pending(&run_dir)?; println!("Tailscale connected VM {expected_hostname}"); Ok(()) diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index d96f18da..42872145 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -137,3 +137,34 @@ fn datadog_commands_parse_and_test_delivery_refuses_before_vault_access() { .to_string() .contains("test Datadog must be provisioned through an owned disposable VM")); } + +#[test] +fn smtp_delivery_and_capture_only_cli_are_supported() { + use clap::Parser; + assert!(super::super::Args::try_parse_from([ + "skillet", + "secrets", + "deliver", + "clamps", + "smtp", + "--target", + "root@example.invalid", + "--identity", + "/tmp/key", + "--known-hosts", + "/tmp/hosts" + ]) + .is_ok()); + assert!(super::super::Args::try_parse_from([ + "skillet", + "test", + "vm", + "provision", + "beezelbot", + "smtp", + "--smtp-only" + ]) + .is_ok()); + assert!(super::validate_delivery_service("agent", "smtp").is_err()); + assert!(super::validate_delivery_service("beezelbot", "smtp").is_ok()); +} diff --git a/ublue/skillet/crates/host-profiles/Cargo.toml b/ublue/skillet/crates/host-profiles/Cargo.toml index 7bc532a3..eaa2a85e 100644 --- a/ublue/skillet/crates/host-profiles/Cargo.toml +++ b/ublue/skillet/crates/host-profiles/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_smtp.workspace = true skillet_datadog.workspace = true skillet_core.workspace = true skillet_hardening.workspace = true diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 37ba78d8..1d06edc6 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -110,7 +110,8 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s "unifi", "ddns", "datadog", - "btrbk" + "btrbk", + "smtp" ] ); let credentials = clamps.credential_consumers(); @@ -141,7 +142,7 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s .contains(&"Subnet=fd59:4e23:2950:11f5::/64".to_string())); let beezelbot = super::profile_for_name("beezelbot").unwrap(); - assert_eq!(beezelbot.services.len(), 1); + assert_eq!(beezelbot.services.len(), 2); assert!(beezelbot.supports_service("syncthing")); assert!(!beezelbot.supports_service("pihole")); assert!(beezelbot.btrbk_config().is_none()); @@ -155,6 +156,10 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s assert_eq!( beezelbot.credential_consumers(), [ + super::CredentialConsumer { + credential: skillet_smtp::CREDENTIAL, + unit: "postfix.service" + }, super::CredentialConsumer { credential: super::CADDY_SITES_CREDENTIAL, unit: "caddy.service", @@ -454,3 +459,27 @@ fn a_credential_service_remains_eligible_when_its_ui_is_not_exposed() { }] ); } + +#[test] +fn smtp_is_shared_and_has_an_independent_credential_phase() { + for host in ["clamps", "beezelbot"] { + let profile = super::profile_for_name(host).unwrap(); + assert_eq!( + profile + .services + .iter() + .filter(|service| service.name() == "smtp") + .count(), + 1 + ); + assert_eq!( + super::credentials_for_phase(host, super::HostApplyPhase::Smtp).unwrap(), + [skillet_smtp::CREDENTIAL] + ); + assert!( + !super::credentials_for_phase(host, super::HostApplyPhase::Full) + .unwrap() + .contains(&skillet_smtp::CREDENTIAL) + ); + } +} diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index eb9456f2..3e1406a5 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -23,6 +23,7 @@ pub enum HostApplyPhase { Caddy, Ddns, Datadog, + Smtp, } pub fn credentials_for_phase( @@ -40,11 +41,15 @@ pub fn credentials_for_phase( .filter(|consumer| match phase { HostApplyPhase::Full => !matches!( consumer.unit, - "caddy.service" | "cloudflare-ddns.service" | "datadog-agent.service" + "caddy.service" + | "cloudflare-ddns.service" + | "datadog-agent.service" + | "postfix.service" ), HostApplyPhase::Caddy => consumer.unit == "caddy.service", HostApplyPhase::Ddns => consumer.unit == "cloudflare-ddns.service", HostApplyPhase::Datadog => consumer.unit == "datadog-agent.service", + HostApplyPhase::Smtp => consumer.unit == "postfix.service", HostApplyPhase::Base => false, }) .map(|consumer| consumer.credential) @@ -91,6 +96,8 @@ pub enum ApplyError { Ddns(#[from] skillet_ddns::DdnsError), #[error("Datadog apply error: {0}")] Datadog(#[from] skillet_datadog::DatadogError), + #[error("SMTP apply error: {0}")] + Smtp(#[from] skillet_smtp::SmtpError), #[error("Podman error: {0}")] Podman(#[from] skillet_podman::PodmanError), #[error("Fixture input error: {0}")] @@ -253,7 +260,7 @@ fn apply_profile( for service in &profile.services { match &service.config { // Independently credential-gated optional services have their own phase. - ServiceConfig::Ddns | ServiceConfig::Datadog { .. } => {} + ServiceConfig::Ddns | ServiceConfig::Datadog { .. } | ServiceConfig::Smtp => {} ServiceConfig::Pihole { custom_dns } => { let password = credentials .require(PIHOLE_WEB_PASSWORD_CREDENTIAL)? @@ -350,6 +357,17 @@ pub fn apply_host_phase( HostApplyPhase::Caddy => apply_caddy_host(hostname, system, files, credentials), HostApplyPhase::Ddns => apply_ddns_host(hostname, system, files, credentials), HostApplyPhase::Datadog => apply_datadog_host(hostname, system, files, credentials), + HostApplyPhase::Smtp => { + profile_for_name(hostname) + .filter(|profile| profile.supports_service("smtp")) + .ok_or_else(|| ApplyError::FixtureInput("host does not declare SMTP".into()))?; + skillet_smtp::apply( + system, + files, + credentials.require(skillet_smtp::CREDENTIAL)?, + )?; + Ok(()) + } } } @@ -399,12 +417,19 @@ fn apply_datadog_host( .ok_or_else(|| ApplyError::FixtureInput("host does not declare Datadog".into()))?; let payload = credentials.require(skillet_datadog::CREDENTIAL)?; let hostname = runtime_hostname(files, profile.id.as_str())?; - let monitored_units = profile + let mut monitored_units = profile .acceptance_plan_with_ddns(true) .services .into_iter() .map(|service| service.unit) .collect::>(); + if profile.supports_service("smtp") { + monitored_units.extend( + skillet_smtp::MONITORED_UNITS + .iter() + .map(|unit| (*unit).to_string()), + ); + } skillet_datadog::apply( system, files, diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index 27086044..f52cf88f 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -52,6 +52,7 @@ pub enum ServiceConfig { container_gid: u32, }, Unifi, + Smtp, Ddns, Datadog { production_region: &'static str, @@ -79,6 +80,7 @@ impl HostService { ServiceConfig::Unifi => "unifi", ServiceConfig::Ddns => "ddns", ServiceConfig::Datadog { .. } => "datadog", + ServiceConfig::Smtp => "smtp", ServiceConfig::Tailscale { .. } => "tailscale", ServiceConfig::Btrbk { .. } => "btrbk", } @@ -86,6 +88,10 @@ impl HostService { pub fn credential_consumers(&self) -> Vec { match self.config { + ServiceConfig::Smtp => vec![CredentialConsumer { + credential: skillet_smtp::CREDENTIAL, + unit: "postfix.service", + }], ServiceConfig::Datadog { .. } => vec![CredentialConsumer { credential: skillet_datadog::CREDENTIAL, unit: "datadog-agent.service", @@ -181,8 +187,10 @@ impl HostProfile { .services .iter() .filter(|service| { - !matches!(service.config, ServiceConfig::Datadog { .. }) - && (with_ddns || !matches!(service.config, ServiceConfig::Ddns)) + !matches!( + service.config, + ServiceConfig::Datadog { .. } | ServiceConfig::Smtp + ) && (with_ddns || !matches!(service.config, ServiceConfig::Ddns)) }) .map(|service| acceptance_service(self.id.as_str(), service)) .collect::>(); @@ -299,15 +307,8 @@ impl HostProfile { fn acceptance_service(profile: &str, service: &HostService) -> AcceptanceService { match service.config { - ServiceConfig::Datadog { .. } => AcceptanceService { - unit: "datadog-agent.service".into(), - container: Some("datadog-agent".into()), - network_mode: Some("host".into()), - bind_paths: vec!["/etc/skillet/datadog/conf.d".into()], - owner: Some(AcceptanceOwner::Numeric { uid: 0, gid: 0 }), - listeners: Vec::new(), - health_probe: None, - }, + ServiceConfig::Smtp => smtp_acceptance(), + ServiceConfig::Datadog { .. } => datadog_acceptance(), ServiceConfig::Ddns => AcceptanceService { unit: "cloudflare-ddns.service".into(), container: Some("cloudflare-ddns".into()), @@ -537,8 +538,8 @@ fn agent_baseline() -> HostProfile { /// Resolve a validated host identity to its declared configuration. pub fn profile_for_host(host: &HostId) -> Option { match host.as_str() { - "clamps" => Some(clamps()), - "beezelbot" => Some(beezelbot()), + "clamps" => Some(with_fleet_baseline(clamps())), + "beezelbot" => Some(with_fleet_baseline(beezelbot())), "agent" => Some(agent_baseline()), _ => None, } @@ -549,5 +550,41 @@ pub fn profile_for_name(host: &str) -> Option { } pub fn declared_profiles() -> Vec { - vec![clamps(), beezelbot(), agent_baseline()] + vec![ + with_fleet_baseline(clamps()), + with_fleet_baseline(beezelbot()), + agent_baseline(), + ] +} + +fn with_fleet_baseline(mut profile: HostProfile) -> HostProfile { + profile.services.push(HostService { + config: ServiceConfig::Smtp, + ui: None, + }); + profile +} + +fn smtp_acceptance() -> AcceptanceService { + AcceptanceService { + unit: "postfix.service".into(), + container: None, + network_mode: None, + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + } +} + +fn datadog_acceptance() -> AcceptanceService { + AcceptanceService { + unit: "datadog-agent.service".into(), + container: Some("datadog-agent".into()), + network_mode: Some("host".into()), + bind_paths: vec!["/etc/skillet/datadog/conf.d".into()], + owner: Some(AcceptanceOwner::Numeric { uid: 0, gid: 0 }), + listeners: Vec::new(), + health_probe: None, + } } diff --git a/ublue/skillet/crates/smtp/Cargo.toml b/ublue/skillet/crates/smtp/Cargo.toml new file mode 100644 index 00000000..ef9d7253 --- /dev/null +++ b/ublue/skillet/crates/smtp/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "skillet_smtp" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +sha2.workspace = true +hex.workspace = true +askama.workspace = true + +[dev-dependencies] +serde_json.workspace = true +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/smtp/src/lib.rs b/ublue/skillet/crates/smtp/src/lib.rs new file mode 100644 index 00000000..8c32a16c --- /dev/null +++ b/ublue/skillet/crates/smtp/src/lib.rs @@ -0,0 +1,278 @@ +//! Native fleet SMTP relay configuration and volatile credential preparation. +use askama::Template; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use skillet_core::{ + activation::{self, ActivationRequest, ConsumerKind}, + files::{FileError, FileMutationResource, FileReadResource, Ownership}, + system::{ServiceResource, SystemError}, +}; +use std::path::Path; +use thiserror::Error; + +pub const MONITORED_UNITS: &[&str] = &["postfix.service", "skillet-smtp-prepare.service"]; +pub const CREDENTIAL: &str = "smtp_config"; +pub const MAP_PATH: &str = "/run/postfix/skillet/sasl_passwd"; +const STATE_DIR: &str = "/var/lib/skillet/smtp"; + +#[derive(Debug, Error)] +pub enum SmtpError { + #[error("invalid SMTP configuration; check required fields and security policy")] + Invalid, + #[error("refusing to switch SMTP environment on a retained queue")] + EnvironmentChange, + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Activation(#[from] activation::ActivationError), + #[error("SMTP configuration rendering failed")] + Render, +} + +// Intentionally no Debug: production input contains credentials. +#[derive(Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)] +pub enum Input { + Production { + host: String, + port: u16, + tls: String, + username: String, + password: String, + sender: String, + }, + Capture {}, +} +impl Input { + pub fn parse(payload: &str) -> Result { + let input: Self = serde_json::from_str(payload).map_err(|_| SmtpError::Invalid)?; + input.validate()?; + Ok(input) + } + pub fn validate(&self) -> Result<(), SmtpError> { + if let Self::Production { + host, + port, + tls, + username, + password, + sender, + } = self + { + if !valid_host(host) + || *port == 0 + || tls != "starttls" + || !valid_login(username) + || username.contains(':') + || !valid_login(password) + || !valid_sender(sender) + { + return Err(SmtpError::Invalid); + } + } + Ok(()) + } + pub fn payload(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| SmtpError::Invalid) + } + fn mode(&self) -> &'static str { + match self { + Self::Production { .. } => "production", + Self::Capture {} => "capture", + } + } + pub fn main_config(&self) -> Result { + self.validate()?; + let (hostname, relay, auth, tls, password_map, sender_map) = match self { + Self::Production { + host, port, sender, .. + } => ( + sender + .split_once('@') + .ok_or(SmtpError::Invalid)? + .1 + .to_string(), + format!("[{host}]:{port}"), + "yes", + "secure", + format!("texthash:{MAP_PATH}"), + format!("static:{sender}"), + ), + Self::Capture {} => ( + "smtp-capture.invalid".into(), + "[127.0.0.1]:1025".into(), + "no", + "none", + String::new(), + String::new(), + ), + }; + MainConfig { + hostname, + relay, + auth, + tls, + password_map, + sender_map, + } + .render() + .map_err(|_| SmtpError::Render) + } +} +#[derive(Template)] +#[template(path = "main.cf", escape = "none")] +struct MainConfig<'a> { + hostname: String, + relay: String, + auth: &'a str, + tls: &'a str, + password_map: String, + sender_map: String, +} +fn valid_login(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|b| b.is_ascii_graphic()) +} +fn valid_host(value: &str) -> bool { + !value.is_empty() + && value.len() <= 253 + && value.split('.').all(|s| { + !s.is_empty() + && s.len() <= 63 + && !s.starts_with('-') + && !s.ends_with('-') + && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) +} +pub fn valid_sender(value: &str) -> bool { + value.split_once('@').is_some_and(|(local, host)| { + !local.is_empty() + && local + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._+-".contains(&b)) + && valid_host(host) + }) +} + +/// Prepare only tmpfs files; never start a service from its own prerequisite. +pub fn prepare( + files: &F, + input: &Input, +) -> Result<(), SmtpError> { + input.validate()?; + if files + .read_file(&Path::new(STATE_DIR).join("mode"))? + .is_some_and(|mode| mode != input.mode().as_bytes()) + { + return Err(SmtpError::EnvironmentChange); + } + files.ensure_directory(Path::new("/run/postfix"), None, &Ownership::default())?; + files.ensure_directory( + Path::new("/run/postfix/skillet"), + Some(0o750), + &Ownership::named(Some("root"), Some("postfix")), + )?; + let map = match input { + Input::Production { + host, + port, + username, + password, + .. + } => format!("[{host}]:{port} {username}:{password}\n"), + Input::Capture {} => String::new(), + }; + files.ensure_file( + Path::new(MAP_PATH), + map.as_bytes(), + Some(0o640), + &Ownership::named(Some("root"), Some("postfix")), + )?; + Ok(()) +} + +pub fn apply(system: &S, files: &F, payload: &str) -> Result<(), SmtpError> +where + S: ServiceResource + ?Sized, + F: FileReadResource + FileMutationResource + ?Sized, +{ + let input = Input::parse(payload)?; + let mode_path = Path::new(STATE_DIR).join("mode"); + if files + .read_file(&mode_path)? + .is_some_and(|old| old != input.mode().as_bytes()) + { + return Err(SmtpError::EnvironmentChange); + } + files.ensure_directory( + Path::new(STATE_DIR), + Some(0o700), + &Ownership::named(Some("root"), Some("root")), + )?; + // Persist the environment guard before touching configuration or activating a queue. + files.ensure_file( + &mode_path, + input.mode().as_bytes(), + Some(0o600), + &Ownership::named(Some("root"), Some("root")), + )?; + files.ensure_directory(Path::new("/etc/postfix"), None, &Ownership::default())?; + files.ensure_directory( + Path::new("/etc/systemd/system/postfix.service.d"), + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + let config = input.main_config()?; + let mut changed = files.ensure_file( + Path::new("/etc/postfix/main.cf"), + config.as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + changed |= files.ensure_file( + Path::new("/etc/systemd/system/postfix.service.d/skillet.conf"), + include_bytes!("postfix.conf"), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + changed |= files.ensure_file( + Path::new("/etc/systemd/system/skillet-smtp-prepare.service"), + include_bytes!("prepare.service"), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + if changed { + system.daemon_reload()?; + } + // A fresh decrypt/map preparation is required on every credential redelivery. + let revision = hex::encode(Sha256::digest(payload.as_bytes())).into_bytes(); + if changed + || files + .read_file(&Path::new(STATE_DIR).join("applied"))? + .as_deref() + != Some(revision.as_slice()) + || !system.service_is_active("postfix.service")? + { + system.service_restart("skillet-smtp-prepare.service")?; + } + system.service_enable("postfix.service")?; + activation::activate( + system, + files, + &ActivationRequest { + service: "postfix.service".into(), + state_path: Path::new(STATE_DIR).join("applied"), + revision, + definition_changed: changed, + reload_daemon: false, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/smtp/src/postfix.conf b/ublue/skillet/crates/smtp/src/postfix.conf new file mode 100644 index 00000000..be567293 --- /dev/null +++ b/ublue/skillet/crates/smtp/src/postfix.conf @@ -0,0 +1,7 @@ +[Unit] +Requires=skillet-smtp-prepare.service +After=skillet-smtp-prepare.service network-online.target +Wants=network-online.target + +[Service] +ExecStartPre=/usr/sbin/postfix check diff --git a/ublue/skillet/crates/smtp/src/prepare.service b/ublue/skillet/crates/smtp/src/prepare.service new file mode 100644 index 00000000..237209c9 --- /dev/null +++ b/ublue/skillet/crates/smtp/src/prepare.service @@ -0,0 +1,10 @@ +[Unit] +Description=Prepare volatile Postfix relay credentials +Before=postfix.service + +[Service] +Type=oneshot +RemainAfterExit=yes +LoadCredentialEncrypted=smtp_config:/etc/credstore.encrypted/skillet/smtp_config.cred +ExecStart=/var/usrlocal/bin/skillet smtp prepare +ExecStartPost=/usr/sbin/restorecon -RF /run/postfix/skillet diff --git a/ublue/skillet/crates/smtp/src/tests.rs b/ublue/skillet/crates/smtp/src/tests.rs new file mode 100644 index 00000000..ffb48bcb --- /dev/null +++ b/ublue/skillet/crates/smtp/src/tests.rs @@ -0,0 +1,121 @@ +use super::*; +use skillet_core::{ + files::FileReadResource, + test_utils::{MockFiles, MockSystem}, +}; +use std::sync::atomic::Ordering; + +fn production() -> Input { + Input::Production { + host: "smtp.example.com".into(), + port: 587, + tls: "starttls".into(), + username: "fixture-key".into(), + password: "fixture-secret".into(), + sender: "server@example.com".into(), + } +} +#[test] +fn production_config_requires_verified_tls_and_loopback_submission() { + let config = production().main_config().unwrap(); + for directive in [ + "relayhost = [smtp.example.com]:587", + "smtp_tls_security_level = secure", + "inet_interfaces = loopback-only", + "smtp_sasl_auth_enable = yes", + "sender_canonical_maps = static:server@example.com", + "smtp_fallback_relay =\n", + ] { + assert!(config.contains(directive)); + } + assert!(!config.contains("fixture-secret")); + assert!(!config.contains("fixture-key")); + assert!(!config.contains("smtp_generic_maps")); +} +#[test] +fn capture_has_no_external_route_authentication_or_sender_rewriting() { + let config = Input::Capture {}.main_config().unwrap(); + assert!(config.contains("relayhost = [127.0.0.1]:1025")); + assert!(config.contains("smtp_sasl_auth_enable = no")); + assert!(config.contains("sender_canonical_maps = ")); +} +#[test] +fn invalid_input_is_rejected_without_echoing_payload_or_mutating_files() { + let files = MockFiles::new(); + let system = MockSystem::new(); + for input in [ + "PRIVATE-INVALID", + r#"{"mode":"PRIVATE-VALUE"}"#, + r#"{"mode":"capture","password":"PRIVATE-VALUE"}"#, + ] { + assert_eq!( + apply(&system, &files, input).unwrap_err().to_string(), + SmtpError::Invalid.to_string() + ); + } + assert_eq!(files.files.lock().unwrap().len(), 0); + for sender in [ + "bad\n@example.com", + "name@bad domain", + "name@example.com:25", + "a@b@c", + "", + ] { + assert!(!valid_sender(sender)); + } +} +#[test] +fn volatile_map_has_restricted_metadata_and_is_not_recorded() { + let files = MockFiles::new(); + let contents = files.files.clone(); + let metadata = files.metadata.clone(); + let recorder = skillet_core::recorder::Recorder::new(files); + prepare(&recorder, &production()).unwrap(); + assert_eq!( + contents.lock().unwrap()[MAP_PATH], + b"[smtp.example.com]:587 fixture-key:fixture-secret\n" + ); + assert_eq!(metadata.lock().unwrap()[MAP_PATH].0, Some(0o640)); + let recording = serde_json::to_string(&recorder.get_ops()).unwrap(); + assert!(!recording.contains("fixture-secret")); +} +#[test] +fn repeat_apply_is_noop_and_failed_activation_is_retryable() { + let files = MockFiles::new(); + let system = MockSystem::new(); + let payload = Input::Capture {}.payload().unwrap(); + apply(&system, &files, &payload).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + apply(&system, &files, &payload).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + assert!(apply(&system, &files, &production().payload().unwrap()).is_err()); + let files = MockFiles::new(); + let system = MockSystem::new(); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(apply(&system, &files, &payload).is_err()); + assert!(files + .read_file(Path::new("/var/lib/skillet/smtp/applied")) + .unwrap() + .is_none()); + apply(&system, &files, &payload).unwrap(); + assert!(files + .read_file(Path::new("/var/lib/skillet/smtp/applied")) + .unwrap() + .is_some()); +} + +#[test] +fn mismatched_credentials_cannot_prepare_a_retained_environment() { + let files = MockFiles::new(); + apply( + &MockSystem::new(), + &files, + &Input::Capture {}.payload().unwrap(), + ) + .unwrap(); + assert!(matches!( + prepare(&files, &production()), + Err(SmtpError::EnvironmentChange) + )); + assert!(files.read_file(Path::new(MAP_PATH)).unwrap().is_none()); +} diff --git a/ublue/skillet/crates/smtp/templates/main.cf b/ublue/skillet/crates/smtp/templates/main.cf new file mode 100644 index 00000000..742607c9 --- /dev/null +++ b/ublue/skillet/crates/smtp/templates/main.cf @@ -0,0 +1,32 @@ +# Managed by Skillet. Local submission only; no destination-MX fallback. +compatibility_level = 3.6 +queue_directory = /var/spool/postfix +inet_interfaces = loopback-only +inet_protocols = all +myhostname = {{ hostname }} +myorigin = $myhostname +mydestination = +mynetworks = 127.0.0.0/8 [::1]/128 +relay_domains = +smtpd_client_restrictions = permit_mynetworks, reject +smtpd_relay_restrictions = permit_mynetworks, reject +smtpd_recipient_restrictions = permit_mynetworks, reject +smtpd_helo_required = yes +disable_vrfy_command = yes +relayhost = {{ relay }} +smtp_fallback_relay = +default_transport = smtp +relay_transport = smtp +local_transport = error:5.1.1 Local delivery disabled +alias_maps = +alias_database = +smtp_sasl_auth_enable = {{ auth }} +smtp_sasl_password_maps = {{ password_map }} +smtp_sasl_security_options = noanonymous +smtp_sasl_tls_security_options = noanonymous +smtp_tls_security_level = {{ tls }} +smtp_tls_CAfile = /etc/pki/tls/certs/ca-bundle.crt +smtp_tls_secure_cert_match = nexthop +smtp_tls_wrappermode = no +sender_canonical_classes = envelope_sender, header_sender +sender_canonical_maps = {{ sender_map }} diff --git a/ublue/skillet/crates/smtp/tests/native_acceptance.py b/ublue/skillet/crates/smtp/tests/native_acceptance.py new file mode 100644 index 00000000..380cb37d --- /dev/null +++ b/ublue/skillet/crates/smtp/tests/native_acceptance.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Run inside a network-isolated, disposable Fedora systemd test container. + +Only synthetic credentials/addresses are used. VM transport/lifecycle remains +owned by Skillet. Supply the deployed CLI path and a declared host profile. +""" + +import argparse +import json +import pathlib +import socket +import socketserver +import subprocess +import threading +import time + + +def run(*args, payload=None): + return subprocess.run( + args, input=payload, capture_output=True, check=True, timeout=45 + ).stdout + + +def wait_for(predicate): + end = time.monotonic() + 30 + while time.monotonic() < end: + try: + if predicate(): + return + except subprocess.CalledProcessError: + pass + time.sleep(0.2) + raise AssertionError("bounded SMTP observation timed out") + + +def queue(): + return [json.loads(line) for line in run("postqueue", "-j").splitlines()] + + +class Sink(socketserver.StreamRequestHandler): + def handle(self): + self.wfile.write(b"220 isolated test sink\r\n") + message = None + while line := self.rfile.readline(): + self.server.commands.append(line.split(b" ", 1)[0].strip().upper()) + if message is not None: + if line == b".\r\n": + self.server.messages.append(b"".join(message)) + message = None + self.wfile.write(b"250 captured\r\n") + else: + message.append(line) + continue + verb = line.split(b" ", 1)[0].strip().upper() + if verb == b"DATA": + message = [] + self.wfile.write(b"354 send data\r\n") + elif verb == b"QUIT": + self.wfile.write(b"221 bye\r\n") + break + else: + # No STARTTLS or AUTH advertised: production must defer. + self.wfile.write(b"250 isolated-test\r\n") + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary") + parser.add_argument("host") + parser.add_argument("--production-policy", action="store_true") + args = parser.parse_args() + if pathlib.Path("/run/systemd/container").read_text().strip() not in { + "podman", + "oci", + } or {name for _, name in socket.if_nameindex()} != {"lo"}: + raise RuntimeError( + "native fixture requires a disposable Podman container with network=none" + ) + wait_for( + lambda: ( + run( + "systemctl", "show", "multi-user.target", "-p", "ActiveState", "--value" + ).strip() + == b"active" + ) + ) + config = {"mode": "capture"} + if args.production_policy: + config = { + "mode": "production", + "host": "localhost", + "port": 1025, + "tls": "starttls", + "username": "fixture-key", + "password": "fixture-secret", + "sender": "server@example.invalid", + } + credential = pathlib.Path("/etc/credstore.encrypted/skillet/smtp_config.cred") + credential.parent.mkdir(parents=True, exist_ok=True) + run( + "systemd-creds", + "encrypt", + "--with-key=host", + "--name=smtp_config", + "-", + str(credential), + payload=json.dumps(config).encode(), + ) + + def apply(): + return run( + "systemd-run", + "--wait", + "--pipe", + "--collect", + "-p", + f"LoadCredentialEncrypted=smtp_config:{credential}", + args.binary, + "apply", + "--host", + args.host, + "--phase", + "smtp", + ) + + apply() + pid = run("systemctl", "show", "postfix.service", "-p", "MainPID", "--value") + apply() + assert pid == run( + "systemctl", "show", "postfix.service", "-p", "MainPID", "--value" + ) + assert ( + pathlib.Path("/run/postfix/skillet/sasl_passwd").stat().st_mode & 0o777 == 0o640 + ) + if args.production_policy: + assert ( + run( + "runuser", + "-u", + "postfix", + "--", + "postmap", + "-q", + "[localhost]:1025", + "texthash:/run/postfix/skillet/sasl_passwd", + ).strip() + == b"fixture-key:fixture-secret" + ) + # No upstream exists yet: submission must queue durably. + run( + "sendmail", + "-i", + "-f", + "smoke@example.invalid", + "receiver@example.invalid", + payload=b"From: smoke@example.invalid\nTo: receiver@example.invalid\nSubject: isolated acceptance\n\nfixture-marker\n", + ) + wait_for(lambda: len(queue()) == 1) + assert queue()[0]["recipients"][0]["address"] == "receiver@example.invalid" + run("systemctl", "stop", "postfix.service") + apply() + assert len(queue()) == 1 + with Server(("127.0.0.1", 1025), Sink) as sink: + sink.messages = [] + sink.commands = [] + threading.Thread(target=sink.serve_forever, daemon=True).start() + run("postqueue", "-f") + if args.production_policy: + wait_for(lambda: b"EHLO" in sink.commands) + time.sleep(2) + assert len(queue()) == 1 + assert b"AUTH" not in sink.commands + assert b"MAIL" not in sink.commands + assert sink.messages == [] + assert queue()[0]["sender"] == "server@example.invalid" + else: + wait_for(lambda: bool(sink.messages)) + assert b"fixture-marker" in sink.messages[0] + wait_for(lambda: queue() == []) + sink.shutdown() + # The prerequisite regenerates tmpfs state without workstation access. + pathlib.Path("/run/postfix/skillet/sasl_passwd").unlink() + run("systemctl", "restart", "skillet-smtp-prepare.service") + assert pathlib.Path("/run/postfix/skillet/sasl_passwd").is_file() + print( + "PASS: native Postfix credential preparation, repeat apply, queue restart/retry, and TLS policy" + ) + + +if __name__ == "__main__": + main() diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml index 674cc658..cf4c8795 100644 --- a/ublue/skillet/crates/workstation/Cargo.toml +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +skillet_smtp.workspace = true skillet_datadog.workspace = true cloudflare.workspace = true hex.workspace = true diff --git a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs index 347bc898..c58fdcee 100644 --- a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs +++ b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs @@ -284,3 +284,26 @@ fn datadog_production_delivery_uses_caller_region() { serde_json::json!(["env:prod", "region:ftwo"]) ); } + +#[test] +fn smtp_delivery_is_stdin_only_and_capture_cannot_contain_provider_fields() { + let guest = FakeGuest::default(); + let input = skillet_smtp::Input::Production { + host: "smtp.example.com".into(), + port: 587, + tls: "starttls".into(), + username: "fixture-key".into(), + password: "fixture-password".into(), + sender: "server@example.com".into(), + }; + assert!(crate::smtp_provisioning::deliver("agent", &input, &guest).is_err()); + assert_eq!(guest.calls.lock().unwrap().len(), 0); + crate::smtp_provisioning::deliver("beezelbot", &input, &guest).unwrap(); + let calls = guest.calls.lock().unwrap(); + assert!(calls.iter().all(|(args, _)| args + .iter() + .all(|arg| !arg.contains("fixture-password") && !arg.contains("fixture-key")))); + assert!(calls.iter().any(|(_, bytes)| bytes + .as_ref() + .is_some_and(|value| String::from_utf8_lossy(value).contains("fixture-password")))); +} diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index a70054c4..e6dd79d8 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -7,6 +7,7 @@ pub mod ddns_provisioning; pub mod provisioning_policy; pub mod provisioning_state; pub mod secrets; +pub mod smtp_provisioning; pub mod tailscale; pub mod tailscale_enrollment; pub mod ui_provisioning; diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json index 1dbe00be..981d76d1 100644 --- a/ublue/skillet/crates/workstation/src/secret-requirements.json +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -22,16 +22,21 @@ ]}, { "module": "SMTP", + "environments": ["prod"], "required_by": [ - "fleet" + "smtp" ], - "description": "Shared fleet relay credential preparation; SMTP runtime delivery is planned. [SMTP plan](ublue/plan/CLAMPS-EMAIL.md).", + "description": "Production-only native Postfix relay credentials; smoke machines use a local sink without these values. [SMTP plan](ublue/plan/CLAMPS-EMAIL.md).", "entries": [ { "path": "skillet/smtp", "status": "required", - "help": "Create one entry using your Mailjet SMTP credentials: Username = SMTP username; Password = SMTP password. Custom fields: host = in-v3.mailjet.com; port = 587; tls = starttls (required TLS with certificate verification). [Mailjet credentials](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters).", + "help": "Create one entry using your Mailjet SMTP credentials: Username = SMTP username; Password = SMTP password. Custom fields: host = in-v3.mailjet.com; port = 587; tls = starttls (required TLS with certificate verification); sender = a Mailjet-verified sender email address. [Mailjet credentials](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters).", "fields": [ + { + "name": "sender", + "rule": "sender" + }, { "name": "Password" }, diff --git a/ublue/skillet/crates/workstation/src/secrets.rs b/ublue/skillet/crates/workstation/src/secrets.rs index 75478c39..9fd84140 100644 --- a/ublue/skillet/crates/workstation/src/secrets.rs +++ b/ublue/skillet/crates/workstation/src/secrets.rs @@ -48,6 +48,8 @@ impl Status { struct RequirementGroup { module: String, required_by: Vec, + #[serde(default)] + environments: Vec, description: String, template: Option, entries: Vec, @@ -81,6 +83,7 @@ enum FieldRule { Hostname, Port, Choice, + Sender, } impl FieldRequirement { @@ -93,6 +96,7 @@ impl FieldRequirement { FieldRule::Port => value.parse::().is_ok_and(|port| port != 0), FieldRule::Choice => self.values.iter().any(|allowed| allowed == value), FieldRule::Hostname => valid_hostname(value), + FieldRule::Sender => skillet_smtp::valid_sender(value), } } } @@ -193,9 +197,7 @@ pub fn unused_paths( fn selected(module: &RequirementGroup, profile: &HostProfile) -> bool { module.required_by.iter().any(|service| { - if service == "fleet" { - profile.signed_image.is_some() - } else if service == "ui" { + if service == "ui" { !profile.ui_services().is_empty() } else { profile.supports_service(service) @@ -217,6 +219,14 @@ pub fn check( ) -> Result { let mut required = BTreeMap::new(); for module in catalog()? { + if !module.environments.is_empty() + && !module + .environments + .iter() + .any(|env| env == policy.vault_name()) + { + continue; + } for profile in profiles.iter().filter(|profile| selected(&module, profile)) { let host = profile.id.as_str(); let paths = module diff --git a/ublue/skillet/crates/workstation/src/secrets/tests.rs b/ublue/skillet/crates/workstation/src/secrets/tests.rs index 3d035d0b..a5f6fb3c 100644 --- a/ublue/skillet/crates/workstation/src/secrets/tests.rs +++ b/ublue/skillet/crates/workstation/src/secrets/tests.rs @@ -30,8 +30,8 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { &|_, _| Ok(None), ) .unwrap(); - assert_eq!(report.checked, 7); - assert_eq!(report.missing.len(), 7); + assert_eq!(report.checked, 6); + assert_eq!(report.missing.len(), 6); assert!(report.missing.iter().any( |entry| entry.path == "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name" )); @@ -50,6 +50,7 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { match field { "port" => "587", "tls" => "starttls", + "sender" => "server@example.com", _ => "not-displayed", } .into(), @@ -123,8 +124,8 @@ fn template_reference_discovery_is_vault_free() { #[test] fn smtp_fields_are_required_validated_and_never_rendered() { let profiles = [skillet_hosts::profile_for_name("clamps").unwrap()]; - for environment in [Environment::Test, Environment::Production] { - for bad_field in ["UserName", "Password", "host", "port", "tls"] { + for environment in [Environment::Production] { + for bad_field in ["UserName", "Password", "host", "port", "tls", "sender"] { for missing in [false, true] { let report = check( &profiles, @@ -152,6 +153,7 @@ fn smtp_fields_are_required_validated_and_never_rendered() { "host" => "smtp.example.com", "port" => "587", "tls" => "starttls", + "sender" => "server@example.com", _ => "PRIVATE-CREDENTIAL", } .into(), @@ -211,7 +213,7 @@ fn shared_smtp_is_counted_once_and_is_not_unused() { let profiles = skillet_hosts::declared_profiles(); let report = check( &profiles, - ProvisioningPolicy::new(Environment::Test), + ProvisioningPolicy::new(Environment::Production), &|_, _| Ok(None), ) .unwrap(); @@ -239,3 +241,21 @@ fn shared_smtp_is_counted_once_and_is_not_unused() { 0 ); } + +#[test] +fn capture_environment_does_not_require_or_read_smtp_provider_fields() { + let profiles = skillet_hosts::declared_profiles(); + let report = check( + &profiles, + ProvisioningPolicy::new(Environment::Test), + &|path, _| { + assert_ne!(path, "skillet/smtp"); + Ok(None) + }, + ) + .unwrap(); + assert!(report + .missing + .iter() + .all(|entry| entry.path != "skillet/smtp")); +} diff --git a/ublue/skillet/crates/workstation/src/smtp_provisioning.rs b/ublue/skillet/crates/workstation/src/smtp_provisioning.rs new file mode 100644 index 00000000..0d0b755c --- /dev/null +++ b/ublue/skillet/crates/workstation/src/smtp_provisioning.rs @@ -0,0 +1,67 @@ +//! Shared SMTP payload construction; test policy never reads provider credentials. +use crate::{ + provisioning_policy::{Environment, ProvisioningPolicy}, + vault::VaultError, +}; +use skillet_smtp::{Input, SmtpError}; +use skillet_vm::{ + credential::{self, ActivationPolicy}, + transport::GuestTransport, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("missing SMTP vault field: {0}")] + Missing(&'static str), + #[error("host does not declare SMTP")] + Host, + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Config(#[from] SmtpError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), +} + +pub fn input( + policy: ProvisioningPolicy, + lookup: &impl Fn(&str, &str) -> Result, VaultError>, +) -> Result { + if policy.environment() == Environment::Test { + return Ok(Input::Capture {}); + } + let field = |name| lookup("skillet/smtp", name)?.ok_or(Error::Missing(name)); + let port = field("port")? + .parse::() + .map_err(|_| SmtpError::Invalid)?; + let input = Input::Production { + host: field("host")?, + port, + tls: field("tls")?, + username: field("UserName")?, + password: field("Password")?, + sender: field("sender")?, + }; + input.validate()?; + Ok(input) +} + +pub fn deliver(host: &str, input: &Input, guest: &impl GuestTransport) -> Result<(), Error> { + let profile = skillet_hosts::profile_for_name(host) + .filter(|p| p.supports_service("smtp")) + .ok_or(Error::Host)?; + credential::install( + guest, + profile.id.as_str(), + skillet_smtp::CREDENTIAL, + "skillet-smtp-apply.service", + ActivationPolicy::StartConsumer, + input.payload()?.as_bytes(), + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "smtp_provisioning/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/smtp_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/smtp_provisioning/tests.rs new file mode 100644 index 00000000..c2fe6fd7 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/smtp_provisioning/tests.rs @@ -0,0 +1,35 @@ +use super::*; +#[test] +fn test_payload_never_reads_provider_credentials() { + let input = input(ProvisioningPolicy::new(Environment::Test), &|_, _| { + panic!("test must not open the vault") + }) + .unwrap(); + assert_eq!(input.payload().unwrap(), r#"{"mode":"capture"}"#); +} +#[test] +fn production_requires_all_fields_without_echoing_values() { + let policy = ProvisioningPolicy::new(Environment::Production); + for missing in ["host", "port", "tls", "UserName", "Password", "sender"] { + let result = input(policy, &|_, field| { + Ok(if field == missing { + None + } else { + Some( + match field { + "host" => "smtp.example.com", + "port" => "587", + "tls" => "starttls", + "sender" => "server@example.com", + _ => "fixture-secret", + } + .into(), + ) + }) + }); + let Err(error) = result else { + panic!("missing field accepted") + }; + assert!(!error.to_string().contains("fixture-secret")); + } +} From 99252651ca5408aae535a826972d56bd56241d28 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 13:37:07 +0200 Subject: [PATCH 226/252] fix(smtp): initialize persistent Postfix state on ostree hosts --- ublue/AGENTS.md | 3 ++ ublue/butane/ACCEPTANCE.md | 46 +++++++++++++++++++ ublue/design/email-delivery.md | 13 ++++-- ublue/plan/CLAMPS-EMAIL.md | 23 ++++++---- ublue/skillet/AGENTS.md | 3 ++ ublue/skillet/README.md | 3 +- ublue/skillet/crates/smtp/src/lib.rs | 38 +++++++++++++++ ublue/skillet/crates/smtp/src/prepare.service | 2 +- ublue/skillet/crates/smtp/src/tests.rs | 36 +++++++++++++++ .../crates/smtp/tests/native_acceptance.py | 12 +++++ 10 files changed, 163 insertions(+), 16 deletions(-) diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 81aafd85..09ecbcb7 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -83,6 +83,9 @@ - Preserve downstream schema types when parsing and re-emitting configuration. Validate staged output with the actual consumer/compiler where possible, not only with a parser round trip. +- Do not assume package directories under mutable OS paths are initialized by + an image build or rebase. Converge required state directories, ownership and + security labels before confined service startup; test from absent package state. - Before activating a unit with multiple credential prerequisites, deliver the complete credential set first. Cover activation from a fresh host where the consumer service and its containers do not yet exist. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index ae7833b5..e74011d3 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2057,3 +2057,49 @@ host tool; it still uses normal command approvals. failures, explicit Mailjet/Gmail delivery with SPF/DKIM/DMARC inspection, and UniFi recovery-email configuration. Bridge clients and queue-age/send-failure metrics are separate remaining implementation slices; unit health is declared. + + +### Rebuilt uCore SMTP capture acceptance, 2026-10-09 + +- [Image workflow 37916306725](https://github.com/gbagnoli/ucore-images/actions/runs/37916306725) + succeeded for ucore-images `3223d48`. Created the owned disposable + `clamps-test-smtp` on port 2202; UUID + `59878d4b-8095-4ebb-81e9-dc9e4658914d`. The retained smoke VM was untouched. + Signed clamps boot/readiness passed; the guest contained + `postfix-3.10.14-1.fc44` and `cyrus-sasl-plain-2.1.28-35.fc44`. +- Fresh activation exposed missing package `/var` state on ostree. Skillet now + initializes the full packaged queue directory layout, PID directory and + `/var/lib/postfix` with package ownership/modes before confined startup, then + restores security labels. Initializing only the queue root was insufficient: + confined startup could not initialize all remaining directory metadata. +- Capture-only delivery used `test vm provision clamps smtp --smtp-only` without + vault/provider access. With SELinux enforcing, native Postfix and credential + preparation started, host sendmail queued a synthetic message, and connections + to the guest's non-loopback IPv4 address on port 25 were rejected. +- Removed the volatile map, rebooted through `test vm reboot`, and ran owned + readiness. The encrypted credential survived, boot regenerated the map, and + the synthetic message persisted and delivered to a temporary loopback sink. + The first observation captured a transient maildrop ID; subsequent checks + waited for the finalized deferred ID before comparing identities. +- Exercised signed common-image rebase and return to signed clamps. For the + direct common check, temporarily changed `/etc/ucore-bootstrap-image` to the + common image and restored it before returning. Disabling bootstrap alone is + insufficient because the base unit requires it and can start it again. + Confirmed actual signed common boot, unchanged finalized queue ID, regenerated + map, enforcing SELinux, and successful retry. Signed clamps readiness passed + after restoring its bootstrap target. All guest assertions used the owned + `test vm ssh` interface; reboot and readiness used the Rust lifecycle owner. +- Local checks passed: formatting, pedantic workspace Clippy, 324 workspace + tests, Fedora base repeat-apply integration, and Ruff check/format. Both native + fixture policies additionally passed after deleting package queue/data state + inside isolated disposable containers; both containers were removed. +- After returning to signed clamps, updated both guest binaries to the current + fix and repeated capture provisioning. Confirmed the restored image/target, + empty queue, retained encrypted credential, and unchanged Postfix PID on repeat + apply. Disposed `clamps-test-smtp`; final VM list retained only the running + `clamps-test-smoke` on port 2201. +- No real provider credential or external email was used. Production credential + map access and certificate/authentication failures under enforcing SELinux, + Mailjet/Gmail delivery, sender-domain verification and UniFi SMTP settings + remain pending. Bridge clients and queue-age/send-failure metrics remain + implementation follow-ups in `plan/CLAMPS-EMAIL.md`. diff --git a/ublue/design/email-delivery.md b/ublue/design/email-delivery.md index 8eaed066..795fe0c5 100644 --- a/ublue/design/email-delivery.md +++ b/ublue/design/email-delivery.md @@ -1,8 +1,8 @@ # Fleet email delivery Status: image packages and shared SMTP provisioning implemented. Native Postfix -container acceptance passed; rebuilt uCore VM and live provider acceptance remain -pending. +container and rebuilt uCore capture acceptance passed; live production provider +acceptance remains pending. ## Implementation boundary @@ -41,8 +41,13 @@ hold policy; private configuration and credentials use the existing vault and systemd encrypted-delivery workflow. A root-owned, Postfix-group-readable `texthash` map (`0640`, parent `0750`) under `/run/postfix/skillet` is regenerated from `LoadCredentialEncrypted=` before Postfix starts. Native Fedora acceptance -verified the backend and permissions; enforcing SELinux remains a VM gate. -Sender canonical rewriting preserves recipients. +verified backend/worker permissions. Capture delivery and boot/rebase recovery +passed on uCore with enforcing SELinux; production credential-map access and +TLS/authentication acceptance remain pending. +Skillet initializes the packaged queue layout and `/var/lib/postfix` before +startup, with package ownership/modes and restored SELinux labels. Image-layer +`/var` contents do not initialize a rebased host. Existing message contents are +preserved; managed directory metadata and security labels converge. Sender canonical rewriting preserves recipients. Never bake credentials or sender domains into an image. Default listener: loopback only. UniFi uses host networking and can reach it. diff --git a/ublue/plan/CLAMPS-EMAIL.md b/ublue/plan/CLAMPS-EMAIL.md index b0206c9a..dc6deb8c 100644 --- a/ublue/plan/CLAMPS-EMAIL.md +++ b/ublue/plan/CLAMPS-EMAIL.md @@ -1,7 +1,8 @@ # Fleet SMTP implementation Status: native Postfix image packages and shared provisioning implemented. -Isolated native-consumer checks passed; rebuilt uCore VM acceptance is pending. +Isolated native-consumer and rebuilt uCore capture acceptance passed. +Production provider/TLS authentication acceptance remains pending. Mailjet entry fields, including sender, pass the production vault audit. Provider authentication and verified sender/domain DNS remain live acceptance work. See [the design](../design/email-delivery.md). @@ -30,7 +31,7 @@ See [the design](../design/email-delivery.md). 2. **Implemented:** add native Postfix and required authentication/TLS packages to the common image, initially inactive. Check the base image for existing packages and units first. Keep all runtime values and authentication material out of it. -3. **Implemented; VM gates pending:** add a cohesive `skillet_smtp` service +3. **Implemented; capture VM gates passed:** add a cohesive `skillet_smtp` service crate and typed configuration template. Declare fleet inclusion centrally, separate from credential-free base apply. Deliver the entire prerequisite set before activation using existing encrypted @@ -70,11 +71,13 @@ References: [Postfix relay configuration](https://www.postfix.org/SOHO_README.ht ## Next acceptance slice -After the operator pushes ucore-images commit `3223d48` and signed images rebuild, -create a named disposable VM and run `test vm provision ---smtp-only`. Verify enforcing SELinux access, boot map regeneration, loopback -submission, queue persistence through reboot/rebase, and non-loopback denial. -The capture sink is test-only; no production SMTP credential is delivered. -Then perform explicit Mailjet/Gmail acceptance and configure UniFi SMTP through -its UI. Bridge client access and queue-age/send-failure monitoring remain separate -work. See the native-consumer evidence in `butane/ACCEPTANCE.md`. +The rebuilt image passed named disposable capture acceptance with enforcing +SELinux, loopback-only submission, boot map regeneration, and queue persistence +through reboot and signed image rebase. Record: `butane/ACCEPTANCE.md`. + +Next: production-policy testing of certificate/authentication failures and +credential-map access under enforcing SELinux, then explicit Mailjet/Gmail +acceptance with provider-verified sender and SPF/DKIM/DMARC inspection. Configure +UniFi SMTP through its UI. Bridge client access and queue-age/send-failure +monitoring remain separate implementation work. No provider mail is sent by +routine smoke runs. diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 17d31861..5c627307 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -29,6 +29,9 @@ This document defines the architectural mandates and project structure for `skil - **Verification**: Always run both: - **Unit Tests**: `cargo test --workspace --all-targets`. - **Runtime Smoke**: Run `cargo run --release -p skillet -- test smoke --instance ` against an explicitly named disposable VM with real systemd and Podman for affected container resources. Record the guest state snapshots and failure diagnostics. +- Acceptance checks distinguish transient staging from durable state, and + successful oneshot completion from persistent service activity. Compare + identities only after the consumer reaches its stable state. - Tests for host-dependent observations must inject or simulate those observations. Do not require a particular workstation port, account, service, or external resource to happen to be available for a unit test. diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 0791915d..1b2ed073 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -498,7 +498,8 @@ not enabled yet. Postfix's provider map is root-owned, readable only by root and the Postfix group (`0640`, parent `0750`) under `/run/postfix/skillet/`, and regenerated from the encrypted credential before service startup. Queued mail persists in -`/var/spool/postfix`. Local senders are rewritten to the verified sender; recipient +`/var/spool/postfix`; Skillet initializes the queue directories and +`/var/lib/postfix` before activation. Local senders are rewritten to the verified sender; recipient addresses are preserved. Mailjet domain verification/SPF/DKIM/DMARC still need provider setup and live acceptance. diff --git a/ublue/skillet/crates/smtp/src/lib.rs b/ublue/skillet/crates/smtp/src/lib.rs index 8c32a16c..cd9d7664 100644 --- a/ublue/skillet/crates/smtp/src/lib.rs +++ b/ublue/skillet/crates/smtp/src/lib.rs @@ -218,6 +218,7 @@ where Some(0o600), &Ownership::named(Some("root"), Some("root")), )?; + initialize_persistent_state(files)?; files.ensure_directory(Path::new("/etc/postfix"), None, &Ownership::default())?; files.ensure_directory( Path::new("/etc/systemd/system/postfix.service.d"), @@ -273,6 +274,43 @@ where Ok(()) } +// Image-layer /var paths need explicit initialization before confined startup. +fn initialize_persistent_state( + files: &F, +) -> Result<(), SmtpError> { + let queue = Path::new("/var/spool/postfix"); + for path in [queue.to_path_buf(), queue.join("pid")] { + files.ensure_directory( + &path, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + } + for name in [ + "active", "bounce", "corrupt", "defer", "deferred", "flush", "hold", "incoming", "private", + "saved", "trace", + ] { + files.ensure_directory( + &queue.join(name), + Some(0o700), + &Ownership::named(Some("postfix"), Some("root")), + )?; + } + for (name, mode) in [("maildrop", 0o730), ("public", 0o710)] { + files.ensure_directory( + &queue.join(name), + Some(mode), + &Ownership::named(Some("postfix"), Some("postdrop")), + )?; + } + files.ensure_directory( + Path::new("/var/lib/postfix"), + Some(0o700), + &Ownership::named(Some("postfix"), Some("root")), + )?; + Ok(()) +} + #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/ublue/skillet/crates/smtp/src/prepare.service b/ublue/skillet/crates/smtp/src/prepare.service index 237209c9..a8787134 100644 --- a/ublue/skillet/crates/smtp/src/prepare.service +++ b/ublue/skillet/crates/smtp/src/prepare.service @@ -7,4 +7,4 @@ Type=oneshot RemainAfterExit=yes LoadCredentialEncrypted=smtp_config:/etc/credstore.encrypted/skillet/smtp_config.cred ExecStart=/var/usrlocal/bin/skillet smtp prepare -ExecStartPost=/usr/sbin/restorecon -RF /run/postfix/skillet +ExecStartPost=/usr/sbin/restorecon -RF /run/postfix/skillet /var/spool/postfix /var/lib/postfix diff --git a/ublue/skillet/crates/smtp/src/tests.rs b/ublue/skillet/crates/smtp/src/tests.rs index ffb48bcb..a2f0fa69 100644 --- a/ublue/skillet/crates/smtp/src/tests.rs +++ b/ublue/skillet/crates/smtp/src/tests.rs @@ -119,3 +119,39 @@ fn mismatched_credentials_cannot_prepare_a_retained_environment() { )); assert!(files.read_file(Path::new(MAP_PATH)).unwrap().is_none()); } + +#[test] +fn fresh_apply_initializes_the_persistent_queue_root() { + let files = MockFiles::new(); + apply( + &MockSystem::new(), + &files, + &Input::Capture {}.payload().unwrap(), + ) + .unwrap(); + let metadata = files.directory_metadata.lock().unwrap(); + assert_eq!(metadata["/var/spool/postfix"].0, Some(0o755)); + assert_eq!(metadata["/var/spool/postfix/pid"].0, Some(0o755)); + for name in [ + "active", "bounce", "corrupt", "defer", "deferred", "flush", "hold", "incoming", "private", + "saved", "trace", + ] { + assert_eq!( + metadata[&format!("/var/spool/postfix/{name}")], + (Some(0o700), Ownership::named(Some("postfix"), Some("root"))) + ); + } + for (name, mode) in [("maildrop", 0o730), ("public", 0o710)] { + assert_eq!( + metadata[&format!("/var/spool/postfix/{name}")], + ( + Some(mode), + Ownership::named(Some("postfix"), Some("postdrop")) + ) + ); + } + assert_eq!( + metadata["/var/lib/postfix"], + (Some(0o700), Ownership::named(Some("postfix"), Some("root"))) + ); +} diff --git a/ublue/skillet/crates/smtp/tests/native_acceptance.py b/ublue/skillet/crates/smtp/tests/native_acceptance.py index 380cb37d..d127297f 100644 --- a/ublue/skillet/crates/smtp/tests/native_acceptance.py +++ b/ublue/skillet/crates/smtp/tests/native_acceptance.py @@ -8,6 +8,7 @@ import argparse import json import pathlib +import shutil import socket import socketserver import subprocess @@ -89,6 +90,17 @@ def main(): == b"active" ) ) + # Reproduce ostree: package image /var directories need runtime initialization. + assert ( + run( + "systemctl", "show", "postfix.service", "-p", "ActiveState", "--value" + ).strip() + != b"active" + ) + shutil.rmtree("/var/spool/postfix", ignore_errors=True) + assert not pathlib.Path("/var/spool/postfix").exists() + shutil.rmtree("/var/lib/postfix", ignore_errors=True) + assert not pathlib.Path("/var/lib/postfix").exists() config = {"mode": "capture"} if args.production_policy: config = { From a79285933a174da8834114ae6d6536d2c5184109 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 15:35:01 +0200 Subject: [PATCH 227/252] feat(smtp): opt in to live provider checks on owned VMs --- ublue/skillet/README.md | 12 +++++ ublue/skillet/crates/cli/src/main.rs | 5 ++- .../skillet/crates/cli/src/secret_delivery.rs | 45 +++++++++++++------ .../crates/cli/src/secret_delivery/tests.rs | 32 +++++++++++++ 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 1b2ed073..685f9c9f 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -516,6 +516,18 @@ queues until a local sink is listening on `127.0.0.1:1025`; no provider credenti are read or delivered. Switching an existing relay between production and capture is refused to protect queued mail. Use a new disposable instance for another policy. +For an explicitly authorized real-provider check, create a **fresh** instance +and opt in to vault credential delivery: + +```bash +cargo run --release -p skillet -- test vm create clamps mailjet --port 2202 +cargo run --release -p skillet -- test vm provision clamps mailjet --smtp-only --live-smtp +``` + +This configures the real relay but sends no message by itself. Submit the intended +message explicitly through the owned VM SSH command. Destroy the instance after +checking delivery; normal test provisioning continues to use capture mode. + `crates/smtp/tests/native_acceptance.py BINARY HOST [--production-policy]` is a native-consumer fixture for an isolated **Podman systemd container with `--network none`** and Postfix/Python installed. It uses synthetic credentials, diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 8c14f24c..11800566 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -212,9 +212,12 @@ struct VmDestroyArgs { // These CLI switches select independent optional provisioning operations. #[allow(clippy::struct_excessive_bools)] struct VmProvisionArgs { - /// Configure only isolated SMTP capture; no vault, Tailscale or external delivery + /// Configure only SMTP (isolated capture unless --live-smtp is supplied) #[arg(long)] smtp_only: bool, + /// Explicitly deliver real provider credentials; requires a fresh SMTP-only VM + #[arg(long, requires = "smtp_only")] + live_smtp: bool, hostname: String, instance: String, /// Also provision private UI DNS, Caddy, and disposable Cloudflare credentials diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index ecc9ec55..225509fc 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -311,6 +311,9 @@ fn default_database_path() -> Result { pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { let profile = skillet_hosts::profile_for_name(&args.hostname) .ok_or_else(|| anyhow!("unknown host profile: {}", args.hostname))?; + if args.smtp_only && !profile.supports_service("smtp") { + return Err(anyhow!("host {} does not declare SMTP", args.hostname)); + } if !args.smtp_only && (!profile.supports_service("pihole") || !profile.supports_service("tailscale")) { @@ -345,20 +348,7 @@ pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { verify_ownership()?; let transport = OwnershipCheckedTransport::new(&base_transport, &verify_ownership); if args.smtp_only { - skillet_workstation::smtp_provisioning::deliver( - &args.hostname, - &skillet_workstation::smtp_provisioning::input( - UiEnvironmentName::Test.policy(), - &|_, _| { - Err(skillet_workstation::vault::VaultError::Invalid( - "test policy must not read credentials".into(), - )) - }, - )?, - &transport, - )?; - println!("Configured isolated SMTP capture relay; start a loopback sink on port 1025"); - return Ok(()); + return provision_vm_smtp(args, &transport); } validate_vm_tailscale_delivery(&transport)?; let vault_path = match &args.database { @@ -661,6 +651,33 @@ fn validate_tailscale_unit_config(contents: &str) -> Result<()> { Ok(()) } +fn provision_vm_smtp(args: &VmProvisionArgs, transport: &impl GuestTransport) -> Result<()> { + let input = if args.live_smtp { + let vault_path = args + .database + .clone() + .map_or_else(default_database_path, Ok)?; + let vault = Vault::open(&vault_path, args.key_file.as_deref())?; + skillet_workstation::smtp_provisioning::input( + UiEnvironmentName::Production.policy(), + &|path, field| vault.get_field(path, field), + )? + } else { + skillet_workstation::smtp_provisioning::input(UiEnvironmentName::Test.policy(), &|_, _| { + Err(skillet_workstation::vault::VaultError::Invalid( + "test policy must not read credentials".into(), + )) + })? + }; + skillet_workstation::smtp_provisioning::deliver(&args.hostname, &input, transport)?; + if args.live_smtp { + println!("Configured live SMTP provider; submission remains explicit and loopback-only"); + } else { + println!("Configured isolated SMTP capture relay; start a loopback sink on port 1025"); + } + Ok(()) +} + #[cfg(test)] #[path = "secret_delivery/tests.rs"] mod tests; diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 42872145..a642c344 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -168,3 +168,35 @@ fn smtp_delivery_and_capture_only_cli_are_supported() { assert!(super::validate_delivery_service("agent", "smtp").is_err()); assert!(super::validate_delivery_service("beezelbot", "smtp").is_ok()); } + +#[test] +fn live_smtp_requires_explicit_smtp_only_selection() { + use clap::Parser; + let base = ["skillet", "test", "vm", "provision", "beezelbot", "mailjet"]; + assert!(super::super::Args::try_parse_from(base.into_iter().chain(["--live-smtp"])).is_err()); + let parsed = + super::super::Args::try_parse_from(base.into_iter().chain(["--smtp-only", "--live-smtp"])) + .unwrap(); + let crate::Commands::Test { + command: + crate::TestCommands::Vm { + command: crate::VmCommands::Provision(args), + }, + } = parsed.command + else { + panic!("wrong parsed command"); + }; + assert!(args.smtp_only && args.live_smtp); + let parsed = + super::super::Args::try_parse_from(base.into_iter().chain(["--smtp-only"])).unwrap(); + let crate::Commands::Test { + command: + crate::TestCommands::Vm { + command: crate::VmCommands::Provision(args), + }, + } = parsed.command + else { + panic!("wrong parsed command"); + }; + assert!(!args.live_smtp); +} From 1944f78e802066076b62e6d3bbd5c20c2345ab6f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 15:35:41 +0200 Subject: [PATCH 228/252] fix(smtp): prepare trusted TLS and confined credential access --- ublue/design/email-delivery.md | 20 ++++-- ublue/skillet/AGENTS.md | 2 + ublue/skillet/README.md | 8 ++- ublue/skillet/crates/cli-common/src/lib.rs | 16 ++++- .../crates/cli-common/src/smtp_runtime.rs | 32 ++++++++++ .../cli-common/src/smtp_runtime/tests.rs | 61 +++++++++++++++++++ ublue/skillet/crates/smtp/src/prepare.service | 2 +- ublue/skillet/crates/smtp/src/tests.rs | 13 ++++ ublue/skillet/crates/smtp/templates/main.cf | 3 +- .../crates/smtp/tests/native_acceptance.py | 3 + 10 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 ublue/skillet/crates/cli-common/src/smtp_runtime.rs create mode 100644 ublue/skillet/crates/cli-common/src/smtp_runtime/tests.rs diff --git a/ublue/design/email-delivery.md b/ublue/design/email-delivery.md index 795fe0c5..af604055 100644 --- a/ublue/design/email-delivery.md +++ b/ublue/design/email-delivery.md @@ -1,8 +1,9 @@ # Fleet email delivery Status: image packages and shared SMTP provisioning implemented. Native Postfix -container and rebuilt uCore capture acceptance passed; live production provider -acceptance remains pending. +container and rebuilt uCore capture acceptance passed. Live Mailjet submission +over verified STARTTLS and operator-confirmed Gmail receipt passed. Mail +authentication headers and UniFi recovery email remain unverified. ## Implementation boundary @@ -26,8 +27,11 @@ Mailjet is the existing provider. Read a single `skillet/smtp` entry: Username and Password contain SMTP credentials; `host`, `port` and `tls` custom fields hold the endpoint and security mode; `sender` holds the verified sender address. Field-aware reading, auditing and encrypted delivery are implemented. -The audit requires these fields once for production fleet preparation. Test -provisioning neither reads nor delivers provider credentials. Only `starttls` is +The audit requires these fields once for production fleet preparation. Default test +provisioning neither reads nor delivers provider credentials. The explicit +`--smtp-only --live-smtp` acceptance override delivers real credentials through +the same owned VM transport without changing its test identity or enabling +other production services. It sends no message itself. Only `starttls` is accepted; production requires certificate and hostname verification. Missing/invalid field names are public diagnostics; values are never printed. SMTP credentials are the provider’s API key/secret, rather than the account login ([provider guide](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters)). @@ -42,8 +46,12 @@ systemd encrypted-delivery workflow. A root-owned, Postfix-group-readable `texthash` map (`0640`, parent `0750`) under `/run/postfix/skillet` is regenerated from `LoadCredentialEncrypted=` before Postfix starts. Native Fedora acceptance verified backend/worker permissions. Capture delivery and boot/rebase recovery -passed on uCore with enforcing SELinux; production credential-map access and -TLS/authentication acceptance remain pending. +passed on uCore with enforcing SELinux, including production credential-map +access and live TLS/authentication. The guest preparation adapter copies the +Postfix configuration label onto RAM credential files when SELinux is enabled, +after restoring the reference file label. Disabled test containers skip labeling; +permissive hosts still receive correct labels. Trust uses Fedora’s extracted CA +bundle rather than assuming a compatibility symlink exists. Skillet initializes the packaged queue layout and `/var/lib/postfix` before startup, with package ownership/modes and restored SELinux labels. Image-layer `/var` contents do not initialize a rebased host. Existing message contents are diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 5c627307..73ea72c9 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -104,6 +104,8 @@ tracks existing violations and their migration, not completed implementation. seccomp, mount and LSM policy before declaring the host kernel unsupported. Match syscall allow rules to explicitly selected capabilities; test the smallest policy change and retain restrictions unrelated to the feature. +- Validate trust-store paths against the deployed OS. A rendered strict TLS + directive does not prove the consumer can initialize its TLS engine. - Restrict credential-file access to the identities that actually consume them. Test access under the worker identity, rather than inferring it from a successful privileged preparation command. diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 685f9c9f..36a0e396 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -497,11 +497,13 @@ not enabled yet. Postfix's provider map is root-owned, readable only by root and the Postfix group (`0640`, parent `0750`) under `/run/postfix/skillet/`, and regenerated -from the encrypted credential before service startup. Queued mail persists in +from the encrypted credential before service startup. On SELinux hosts, preparation +applies the Postfix configuration label to the RAM map and directory. Production +TLS uses `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`. Queued mail persists in `/var/spool/postfix`; Skillet initializes the queue directories and `/var/lib/postfix` before activation. Local senders are rewritten to the verified sender; recipient -addresses are preserved. Mailjet domain verification/SPF/DKIM/DMARC still need -provider setup and live acceptance. +addresses are preserved. Configure Mailjet sender/domain verification and +SPF/DKIM/DMARC in the provider UI; validate headers on a received message. For an owned disposable VM, configure only the isolated relay without unlocking KeePass or joining Tailscale: diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index c75bfd46..0af06437 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -11,6 +11,7 @@ use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; pub mod hosts; +mod smtp_runtime; use hosts::ApplyPhase; #[derive(Error, Debug)] @@ -281,6 +282,17 @@ pub fn prepare_smtp() -> Result<(), CliCommonError> { let payload = manager.read_secret(skillet_smtp::CREDENTIAL)?; let input = skillet_smtp::Input::parse(&payload).map_err(|e| CliCommonError::Config(e.to_string()))?; - skillet_smtp::prepare(&LocalFileResource::new(), &input) - .map_err(|e| CliCommonError::Config(e.to_string())) + let files = LocalFileResource::new(); + skillet_smtp::prepare(&files, &input).map_err(|e| CliCommonError::Config(e.to_string()))?; + smtp_runtime::label_credentials(&files, &|program, arguments| { + let output = std::process::Command::new(program) + .args(arguments) + .output()?; + if !output.status.success() { + return Err(CliCommonError::Config( + "SMTP credential labeling failed".into(), + )); + } + Ok(()) + }) } diff --git a/ublue/skillet/crates/cli-common/src/smtp_runtime.rs b/ublue/skillet/crates/cli-common/src/smtp_runtime.rs new file mode 100644 index 00000000..17a538c4 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/smtp_runtime.rs @@ -0,0 +1,32 @@ +//! Native `SELinux` adapter for volatile SMTP configuration files. +use crate::CliCommonError; +use skillet_core::files::FileReadResource; +use std::path::Path; + +pub(super) fn label_credentials( + files: &impl FileReadResource, + execute: &impl Fn(&str, &[&str]) -> Result<(), CliCommonError>, +) -> Result<(), CliCommonError> { + // SELinux-disabled containers have no mounted policy to label against. + // Permissive hosts still need correct labels before enforcement is enabled. + if files + .read_file(Path::new("/sys/fs/selinux/enforce")) + .map_err(|e| CliCommonError::Config(e.to_string()))? + .is_none() + { + return Ok(()); + } + execute("/usr/sbin/restorecon", &["-F", "/etc/postfix/main.cf"])?; + execute( + "/usr/bin/chcon", + &[ + "-R", + "--reference=/etc/postfix/main.cf", + "/run/postfix/skillet", + ], + ) +} + +#[cfg(test)] +#[path = "smtp_runtime/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/cli-common/src/smtp_runtime/tests.rs b/ublue/skillet/crates/cli-common/src/smtp_runtime/tests.rs new file mode 100644 index 00000000..32a30a98 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/smtp_runtime/tests.rs @@ -0,0 +1,61 @@ +use super::*; +use skillet_core::{ + files::{FileMutationResource, Ownership}, + test_utils::MockFiles, +}; +use std::cell::RefCell; + +#[test] +fn disabled_selinux_skips_label_commands() { + label_credentials(&MockFiles::new(), &|_, _| { + panic!("no SELinux commands expected") + }) + .unwrap(); +} + +#[test] +fn enabled_and_permissive_selinux_restore_reference_then_label_credentials() { + for enforcement in [b"0".as_slice(), b"1".as_slice()] { + let files = MockFiles::new(); + files + .ensure_file( + Path::new("/sys/fs/selinux/enforce"), + enforcement, + None, + &Ownership::default(), + ) + .unwrap(); + let calls = RefCell::new(Vec::new()); + label_credentials(&files, &|program, args| { + calls.borrow_mut().push(( + program.to_string(), + args.iter().map(ToString::to_string).collect::>(), + )); + Ok(()) + }) + .unwrap(); + assert_eq!( + *calls.borrow(), + vec![ + ( + "/usr/sbin/restorecon".into(), + vec!["-F".into(), "/etc/postfix/main.cf".into()] + ), + ( + "/usr/bin/chcon".into(), + vec![ + "-R".into(), + "--reference=/etc/postfix/main.cf".into(), + "/run/postfix/skillet".into() + ] + ), + ] + ); + assert!( + label_credentials(&files, &|_, _| Err(CliCommonError::Config( + "label failure".into() + ))) + .is_err() + ); + } +} diff --git a/ublue/skillet/crates/smtp/src/prepare.service b/ublue/skillet/crates/smtp/src/prepare.service index a8787134..e1c3d4f0 100644 --- a/ublue/skillet/crates/smtp/src/prepare.service +++ b/ublue/skillet/crates/smtp/src/prepare.service @@ -7,4 +7,4 @@ Type=oneshot RemainAfterExit=yes LoadCredentialEncrypted=smtp_config:/etc/credstore.encrypted/skillet/smtp_config.cred ExecStart=/var/usrlocal/bin/skillet smtp prepare -ExecStartPost=/usr/sbin/restorecon -RF /run/postfix/skillet /var/spool/postfix /var/lib/postfix +ExecStartPost=/usr/sbin/restorecon -RF /var/spool/postfix /var/lib/postfix diff --git a/ublue/skillet/crates/smtp/src/tests.rs b/ublue/skillet/crates/smtp/src/tests.rs index a2f0fa69..0ec2c8e5 100644 --- a/ublue/skillet/crates/smtp/src/tests.rs +++ b/ublue/skillet/crates/smtp/src/tests.rs @@ -21,6 +21,8 @@ fn production_config_requires_verified_tls_and_loopback_submission() { for directive in [ "relayhost = [smtp.example.com]:587", "smtp_tls_security_level = secure", + "smtp_tls_CAfile = /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "smtp_tls_loglevel = 1", "inet_interfaces = loopback-only", "smtp_sasl_auth_enable = yes", "sender_canonical_maps = static:server@example.com", @@ -155,3 +157,14 @@ fn fresh_apply_initializes_the_persistent_queue_root() { (Some(0o700), Ownership::named(Some("postfix"), Some("root"))) ); } + +#[test] +fn prepare_unit_preserves_runtime_credential_labels() { + let unit = include_str!("prepare.service"); + let restore = unit + .lines() + .find(|line| line.starts_with("ExecStartPost=")) + .unwrap(); + assert!(!restore.contains("/run/postfix/skillet")); + assert!(restore.contains("/var/spool/postfix /var/lib/postfix")); +} diff --git a/ublue/skillet/crates/smtp/templates/main.cf b/ublue/skillet/crates/smtp/templates/main.cf index 742607c9..7f39bee2 100644 --- a/ublue/skillet/crates/smtp/templates/main.cf +++ b/ublue/skillet/crates/smtp/templates/main.cf @@ -25,7 +25,8 @@ smtp_sasl_password_maps = {{ password_map }} smtp_sasl_security_options = noanonymous smtp_sasl_tls_security_options = noanonymous smtp_tls_security_level = {{ tls }} -smtp_tls_CAfile = /etc/pki/tls/certs/ca-bundle.crt +smtp_tls_CAfile = /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem +smtp_tls_loglevel = 1 smtp_tls_secure_cert_match = nexthop smtp_tls_wrappermode = no sender_canonical_classes = envelope_sender, header_sender diff --git a/ublue/skillet/crates/smtp/tests/native_acceptance.py b/ublue/skillet/crates/smtp/tests/native_acceptance.py index d127297f..eaab8eb6 100644 --- a/ublue/skillet/crates/smtp/tests/native_acceptance.py +++ b/ublue/skillet/crates/smtp/tests/native_acceptance.py @@ -141,6 +141,9 @@ def apply(): ) apply() + assert pathlib.Path( + run("postconf", "-h", "smtp_tls_CAfile").decode().strip() + ).is_file(), "configured CA trust bundle must exist" pid = run("systemctl", "show", "postfix.service", "-p", "MainPID", "--value") apply() assert pid == run( From c784001b044f4f4f193af663d057004b8f17d78d Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 15:35:41 +0200 Subject: [PATCH 229/252] docs(smtp): record Mailjet delivery and Gmail acceptance --- ublue/butane/ACCEPTANCE.md | 29 +++++++++++++++++++++++++++++ ublue/plan/CLAMPS-EMAIL.md | 20 ++++++++++++-------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index e74011d3..8caaebdf 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2103,3 +2103,32 @@ host tool; it still uses normal command approvals. Mailjet/Gmail delivery, sender-domain verification and UniFi SMTP settings remain pending. Bridge clients and queue-age/send-failure metrics remain implementation follow-ups in `plan/CLAMPS-EMAIL.md`. + +## 2026-10-09 — live Mailjet relay and Gmail receipt + +- Created fresh owned `clamps-test-mailjet` on native `qemu:///session`, SSH port + 2202. Signed uCore boot and base readiness passed; retained smoke VM untouched. +- Explicit `--smtp-only --live-smtp` provisioning used cached KeePass access and + shared encrypted credential delivery. Normal VM provisioning remains capture-only; + the override does not change VM environment or enable other production services. +- Submitted one operator-authorized message. Initial delivery deferred because + the compatibility CA symlink was absent and the SMTP worker could not read + RAM credentials with the generic SELinux label. TLS policy prevented plaintext + fallback. Fixed the extracted trust-bundle path and runtime credential labeling, + updated both guest binaries, and retried the original queue entry without + submitting a duplicate. +- Mailjet accepted the original message over verified TLS with SMTP authentication. + Operator confirmed Gmail receipt. SELinux remained enforcing; credentials have + `postfix_etc_t`, root/Postfix ownership and restricted Unix permissions. Queue + drained. Encrypted credential preparation recreated the deleted RAM map with + the correct label. Restarting the prerequisite stopped its dependent Postfix unit; + apply recovered it. A subsequent ordinary repeat production apply preserved + Postfix PID and empty queue. +- Local checks: 328 workspace tests, formatting, pedantic Clippy, Fedora base + repeat-apply, and Ruff check/format passed. Isolated native capture and + production-policy fixtures passed from absent mutable package state; configured + CA bundle exists, and an upstream without STARTTLS receives no credentials/mail. +- SPF/DKIM/DMARC headers, explicit invalid-certificate/authentication cases, and + UniFi recovery-email integration remain unverified. No additional provider + messages were submitted. The disposable Mailjet VM and fixture containers were + removed after acceptance; provider secrets/private sender details are not recorded. diff --git a/ublue/plan/CLAMPS-EMAIL.md b/ublue/plan/CLAMPS-EMAIL.md index dc6deb8c..d5df76c5 100644 --- a/ublue/plan/CLAMPS-EMAIL.md +++ b/ublue/plan/CLAMPS-EMAIL.md @@ -2,9 +2,9 @@ Status: native Postfix image packages and shared provisioning implemented. Isolated native-consumer and rebuilt uCore capture acceptance passed. -Production provider/TLS authentication acceptance remains pending. -Mailjet entry fields, including sender, pass the production vault audit. Provider -authentication and verified sender/domain DNS remain live acceptance work. +Live Mailjet verified-TLS/authentication and operator-confirmed Gmail receipt passed. +Mailjet entry fields, including sender, pass the production vault audit. Sender/domain +authentication headers and provider account limits remain unverified. See [the design](../design/email-delivery.md). ## Findings @@ -31,7 +31,7 @@ See [the design](../design/email-delivery.md). 2. **Implemented:** add native Postfix and required authentication/TLS packages to the common image, initially inactive. Check the base image for existing packages and units first. Keep all runtime values and authentication material out of it. -3. **Implemented; capture VM gates passed:** add a cohesive `skillet_smtp` service +3. **Implemented; capture and live Mailjet gates passed:** add a cohesive `skillet_smtp` service crate and typed configuration template. Declare fleet inclusion centrally, separate from credential-free base apply. Deliver the entire prerequisite set before activation using existing encrypted @@ -75,9 +75,13 @@ The rebuilt image passed named disposable capture acceptance with enforcing SELinux, loopback-only submission, boot map regeneration, and queue persistence through reboot and signed image rebase. Record: `butane/ACCEPTANCE.md`. -Next: production-policy testing of certificate/authentication failures and -credential-map access under enforcing SELinux, then explicit Mailjet/Gmail -acceptance with provider-verified sender and SPF/DKIM/DMARC inspection. Configure -UniFi SMTP through its UI. Bridge client access and queue-age/send-failure +Live acceptance passed production credential-map access under enforcing SELinux, +verified STARTTLS, SMTP authentication, and one operator-confirmed Gmail delivery. +The original deferred message was retried after fixing the trust-bundle path and +RAM credential labels; no duplicate was submitted. + +Next: configure UniFi SMTP through its UI and inspect SPF/DKIM/DMARC headers. +Explicit certificate/authentication failure cases remain unverified; the isolated +no-STARTTLS refusal check passed. Bridge client access and queue-age/send-failure monitoring remain separate implementation work. No provider mail is sent by routine smoke runs. From b5c9774538824ceb5f1f3b9ab499431787e402c9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 15:46:37 +0200 Subject: [PATCH 230/252] docs(clamps): park UniFi email and identify exit-node milestone --- ublue/design/email-delivery.md | 3 ++- ublue/plan/CLAMPS-EMAIL.md | 13 +++++++++---- ublue/plan/CLAMPS-PARITY.md | 12 +++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/ublue/design/email-delivery.md b/ublue/design/email-delivery.md index af604055..df9aaac5 100644 --- a/ublue/design/email-delivery.md +++ b/ublue/design/email-delivery.md @@ -3,7 +3,8 @@ Status: image packages and shared SMTP provisioning implemented. Native Postfix container and rebuilt uCore capture acceptance passed. Live Mailjet submission over verified STARTTLS and operator-confirmed Gmail receipt passed. Mail -authentication headers and UniFi recovery email remain unverified. +authentication headers remain unverified; UniFi recovery email is parked by +operator decision. ## Implementation boundary diff --git a/ublue/plan/CLAMPS-EMAIL.md b/ublue/plan/CLAMPS-EMAIL.md index d5df76c5..7464beee 100644 --- a/ublue/plan/CLAMPS-EMAIL.md +++ b/ublue/plan/CLAMPS-EMAIL.md @@ -43,8 +43,7 @@ See [the design](../design/email-delivery.md). sendmail and loopback submission first. Add explicit bridge client access only for declared applications; do not publish LAN/tailnet SMTP or broadly allow arbitrary bridge clients. Applications own their SMTP client - integration. Configure UniFi recovery email via a supported interface, with - any necessary operator UI handoff recorded. + integration. UniFi recovery email is parked by operator decision; see below. 5. **Unit health implemented; queue/failure metrics pending:** add module-owned monitoring for service health, deferred queue age and send failures without credential/message-body disclosure. Test hosts use a local @@ -61,7 +60,7 @@ See [the design](../design/email-delivery.md). and retry after a simulated upstream outage without regenerating credentials. - Explicit live acceptance after provider setup: send a message to an operator-controlled Gmail inbox. Inspect SPF/DKIM/DMARC results and actual - delivery; no guaranteed inbox placement claim. Verify UniFi recovery email. + delivery; no guaranteed inbox placement claim. UniFi recovery email is parked. Do not send during routine smoke runs. - Record exact passed/pending checks in `butane/ACCEPTANCE.md`, maintain designs, README usage and the root secrets checklist; focused validated commits, no push. @@ -80,8 +79,14 @@ verified STARTTLS, SMTP authentication, and one operator-confirmed Gmail deliver The original deferred message was retried after fixing the trust-bundle path and RAM credential labels; no duplicate was submitted. -Next: configure UniFi SMTP through its UI and inspect SPF/DKIM/DMARC headers. +Remaining SMTP follow-ups: inspect SPF/DKIM/DMARC headers. Explicit certificate/authentication failure cases remain unverified; the isolated no-STARTTLS refusal check passed. Bridge client access and queue-age/send-failure monitoring remain separate implementation work. No provider mail is sent by routine smoke runs. + +## Parked by operator decision + +UniFi SMTP/recovery-email configuration is parked (2026-10-09). It is not the +next milestone or a required cutover gate. Shared host mail delivery is accepted; +application email setup can resume only when requested. diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 9d0fcd10..607d36b9 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -16,7 +16,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | nginx, ACME, OAuth proxy, web pod | Separate bridge containers; generic Caddy routes, Cloudflare DNS-01, tailnet restriction, per-service names and aliases | Production certificate/renewal and external-source denial; inventory any legacy cross-host routes still needed | | Syncthing | Container and leaf data subvolume; peer ports retained; host GUI publication removed; explicit host/container identity mapping | PARKED: live ownership fix acceptance; production peer connectivity and identity/data migration | | btrbk | Caller-selected Syncthing snapshots, hourly timer, retention and restore accepted | Off-machine backup policy and physical cutover acceptance; nested source subvolumes unsupported | -| UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply acceptance | Isolated backup restore, version compatibility, adoption and Site Manager access; see `CLAMPS-UNIFI.md` | +| UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply, isolated backup restore, restored-data persistence and operator UI inspection accepted | Production device adoption and Site Manager access at cutover; SMTP setup parked; see `CLAMPS-UNIFI.md` | | Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | | Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Explicit rotation acceptance and private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | | Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks, submissions and automatic boot discovery passed; NPM/USM perf/HTTP traffic and reboot acceptance passed with explicit PERFMON; physical Secure Boot, provider tag read-back and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | @@ -39,10 +39,12 @@ exists. Additional private run-list recipes remain an inventory question. 1. Disposable DDNS publication/recovery/cleanup passed; finish explicit rotation acceptance and privately inventory production records; do not mutate production DNS. -2. Datadog eBPF VM acceptance passed. Next, handle isolated UniFi backup/restore - planning and execution; retain physical Secure Boot, provider tag read-back - and richer monitoring follow-ups in `CLAMPS-DATADOG.md`. -3. Close exit-node and remaining host-baseline gaps before cutover. +2. Datadog eBPF and isolated UniFi restore/UI acceptance passed. Shared SMTP + delivery to Gmail also passed; UniFi email setup is parked. Retain physical + Secure Boot, provider tag read-back and richer monitoring follow-ups in + `CLAMPS-DATADOG.md`. +3. Next: add caller-controlled production Tailscale exit-node behavior, disabled + by default for smoke VMs, then close remaining host-baseline gaps before cutover. 4. Run the parked VM and deferred network/production acceptance when their required infrastructure is available; transfer state only with a recovery path. From 3d15abe1347afc4780354b4a6f315b84331505e5 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 16:06:48 +0200 Subject: [PATCH 231/252] feat(tailscale): configure caller-selected production exit nodes --- ublue/butane/ACCEPTANCE.md | 22 ++++++++ ublue/design/private-ui-access.md | 4 +- ublue/design/tailscale.md | 30 +++++++++++ ublue/plan/CLAMPS-MIGRATION.md | 4 +- ublue/plan/CLAMPS-PARITY.md | 6 +-- ublue/skillet/AGENTS.md | 3 ++ ublue/skillet/Cargo.lock | 2 + ublue/skillet/README.md | 12 ++++- ublue/skillet/crates/host-profiles/Cargo.toml | 2 + .../crates/host-profiles/src/hosts_tests.rs | 27 ++++++++++ ublue/skillet/crates/host-profiles/src/lib.rs | 40 +++++++++++--- .../crates/host-profiles/src/profile.rs | 16 +++++- .../crates/host-profiles/src/tailscale.rs | 53 +++++++++++++++++++ .../host-profiles/src/tailscale/tests.rs | 27 ++++++++++ .../workstation/src/credential_delivery.rs | 5 +- .../src/credential_delivery/tests.rs | 9 ++-- .../workstation/src/tailscale_enrollment.rs | 5 +- .../src/tailscale_enrollment/tests.rs | 7 ++- 18 files changed, 253 insertions(+), 21 deletions(-) create mode 100644 ublue/design/tailscale.md create mode 100644 ublue/skillet/crates/host-profiles/src/tailscale.rs create mode 100644 ublue/skillet/crates/host-profiles/src/tailscale/tests.rs diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 8caaebdf..27e0261e 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2132,3 +2132,25 @@ host tool; it still uses normal command approvals. UniFi recovery-email integration remain unverified. No additional provider messages were submitted. The disposable Mailjet VM and fixture containers were removed after acceptance; provider secrets/private sender details are not recorded. + +## 2026-10-09 — caller-selected Tailscale exit-node configuration + +- Clamps declares production exit-node advertisement. Production delivery wraps + its one-use key with resolved routing policy in a versioned encrypted payload; + disposable delivery explicitly disables advertisement. No hostname-based + environment inference, new vault entries, or control-plane ACL mutations. +- Raw retained credentials remain readable with advertisement disabled; + production redelivery activates the declared policy. Only the decoded key is + delivered to the Podman secret. Invalid/unknown payload versions fail safely. +- Retained host networking, state, capabilities and `TS_AUTH_ONCE=true`. + Quadlet health readiness orders native `tailscale set` after CLI availability, + so persisted preferences converge on service startups as well as first join. + Explicit false withdraws previous advertisement. Existing shared IPv4/IPv6 + forwarding settings already enable routing; no image changes were needed. +- Formatting, pedantic Clippy, all 331 workspace tests and Fedora base repeat-apply + passed. Installed Podman 6.1 Quadlet compiler accepted a focused directive + fixture for both true and false, including healthy notification and the native + post-start preference command. Fixtures did not start containers or enroll nodes. +- Live VM/client routing was intentionally omitted by operator decision, rather + than recorded as passing. Production tailnet exit-node approval and client + selection remain operator deployment steps. Retained smoke VM was not modified. diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index 5f163b14..8f18e64e 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -11,7 +11,9 @@ DNS listener and Syncthing's peer-transfer ports separate from their UIs. Run Tailscale in its Podman container with `Network=host`, matching the existing Chef configuration on rupik, calculon, and boxy. This gives the host a normal Tailscale interface for host-level routing and port forwarding. -Persist its state under `/var/lib/data/tailscale`. Use separate tagged +Persist its state under `/var/lib/data/tailscale`. Caller-selected production +exit-node policy and its enrollment migration are documented in +[host routing](tailscale.md). Use separate tagged identities and access rules for production servers and smoke VMs. The workstation uses a narrowly scoped OAuth client to mint one-use enrollment keys; only those short-lived keys are delivered to a host, never the OAuth diff --git a/ublue/design/tailscale.md b/ublue/design/tailscale.md new file mode 100644 index 00000000..968a3842 --- /dev/null +++ b/ublue/design/tailscale.md @@ -0,0 +1,30 @@ +# Tailscale host routing + +Run Tailscale rootfully with host networking and persistent state. The canonical +host caller selects `advertise_exit_node`; clamps enables it. Production delivery +resolves that declaration into a versioned encrypted enrollment payload. Disposable +delivery always selects false. Never infer environment from a runtime hostname. +Only the decoded enrollment key becomes the Podman secret; policy stays in clear +managed Quadlet configuration. No new vault entries or OAuth scopes are required. + +Keep `TS_AUTH_ONCE=true` to preserve one-use enrollment. Extra startup arguments +alone cannot converge preferences on already authenticated nodes. A native +`tailscale set --advertise-exit-node=` runs after the container's status +healthcheck satisfies Quadlet `Notify=healthy`, including every service startup. +Explicit false withdraws a retained advertisement. No shell program is embedded. +The existing host baseline already enables IPv4 and IPv6 forwarding. + +Retained raw-key credentials remain readable and default to no advertisement. +Redeliver production Tailscale credentials to enable caller-selected routing; +new disposable credentials explicitly disable it. Invalid versioned payloads +fail without exposing values. Host composition rejects advertisements its caller +did not select. + +Tailnet exit-node approval remains operator policy; this change does not edit +access controls or make clients select an exit node. Local policy, compatibility, +rendering and Quadlet compilation are checked. Live routing exercises are omitted +by operator decision; they are not an additional acceptance gate for this slice. + +References: [exit-node policy](https://tailscale.com/docs/features/exit-nodes), +[container startup](https://github.com/tailscale/tailscale/blob/main/cmd/containerboot/tailscaled.go), +[Quadlet readiness](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#notify-defaults-to-false). diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 998dd7b4..daaa6a6c 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -129,10 +129,10 @@ Each row is a separate implementation and validation step. Adjust order for depe | 1 | Syncthing (implemented; ownership correction committed; live retry parked) | Data, device identity, folder configuration, UID/GID, required ports; production peer and state transfer remain | | 2 | btrbk (implemented and verified) | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | | 3 | UniFi (implemented; empty-controller/repeat-apply acceptance passed) | Follow the [UniFi port plan](CLAMPS-UNIFI.md): rootful host-network container, persistent data, and isolated backup restore/version compatibility before cutover. No Caddy route. | -| 4 | Tailscale (implemented; smoke enrollment and cleanup accepted) | Host network, state, OAuth enrollment, and cleanup exist. Chef's exit-node advertisement is missing in the current declaration; add caller-controlled behavior and validate production forwarding. UI names use Cloudflare DNS-only records. | +| 4 | Tailscale (implemented; smoke enrollment and cleanup accepted) | Host network, state, OAuth enrollment, and cleanup exist. Caller-selected production exit-node advertisement is implemented, disabled for smoke VMs. Existing forwarding policy is retained; approve the production device in tailnet policy. Live routing exercises are omitted by operator decision. UI names use Cloudflare DNS-only records. | | 5 | Private UI access with Caddy + ACME (implemented; staging accepted) | Generic declarations/aliases, encrypted delivery, scoped account tokens, A/AAAA/CNAME reconciliation and cleanup exist. Staging ACME/HTTPS passed. Production renewal, real-client resolution and separate-network denial remain; see [UI plan](GENERIC-PRIVATE-UIS.md). | | 6 | Cloudflare DDNS | Guest service, production delivery, and journaled disposable lifecycle implemented; live disposable acceptance awaits test config and production one-writer handover remains separate; see [DDNS plan](CLAMPS-CLOUDFLARE-DDNS.md) | -| 7 | Datadog and remaining host baseline | Retain Datadog; port it next. Audit hardening, users, SSH/sudo, ET and WOL targets against the [parity checklist](CLAMPS-PARITY.md) | +| 7 | Datadog and remaining host baseline | Datadog implementation and live VM acceptance passed. Next audit hardening, users, SSH/sudo, ET and WOL targets against the [parity checklist](CLAMPS-PARITY.md) | For each service: inspect effective Chef inputs, settle its open decisions, add only needed Skillet support, migrate a copy of state, validate functionality and repeat convergence. Account for ARM-to-x86 application/image and data compatibility. Avoid activating duplicate production identities or DNS writers during testing. diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 607d36b9..6962fdb4 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -17,7 +17,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | Syncthing | Container and leaf data subvolume; peer ports retained; host GUI publication removed; explicit host/container identity mapping | PARKED: live ownership fix acceptance; production peer connectivity and identity/data migration | | btrbk | Caller-selected Syncthing snapshots, hourly timer, retention and restore accepted | Off-machine backup policy and physical cutover acceptance; nested source subvolumes unsupported | | UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply, isolated backup restore, restored-data persistence and operator UI inspection accepted | Production device adoption and Site Manager access at cutover; SMTP setup parked; see `CLAMPS-UNIFI.md` | -| Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled | Chef advertises an exit node; current Skillet declaration does not. Add caller-controlled production exit-node behavior and prove forwarding/firewall/client routing | +| Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled; caller-selected production exit-node advertisement implemented, disabled for disposable VMs | Approve the production exit node in tailnet policy and select it on clients; live routing exercise omitted by operator decision; see `../design/tailscale.md` | | Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Explicit rotation acceptance and private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | | Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks, submissions and automatic boot discovery passed; NPM/USM perf/HTTP traffic and reboot acceptance passed with explicit PERFMON; physical Secure Boot, provider tag read-back and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | | EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | @@ -43,8 +43,8 @@ exists. Additional private run-list recipes remain an inventory question. delivery to Gmail also passed; UniFi email setup is parked. Retain physical Secure Boot, provider tag read-back and richer monitoring follow-ups in `CLAMPS-DATADOG.md`. -3. Next: add caller-controlled production Tailscale exit-node behavior, disabled - by default for smoke VMs, then close remaining host-baseline gaps before cutover. +3. Production exit-node configuration is implemented; local policy checks passed. + Next: close remaining host-baseline gaps before cutover. 4. Run the parked VM and deferred network/production acceptance when their required infrastructure is available; transfer state only with a recovery path. diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index 73ea72c9..a1052ead 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -112,6 +112,9 @@ tracks existing violations and their migration, not completed implementation. - Regenerate volatile credential files before their consumers start. Validate persisted environment policy during both apply and boot preparation; retained queues or application data must not silently cross deployment policies. +- Authentication-only startup modes may skip later preference flags. Converge + persisted preferences through supported consumer commands after readiness, + without reusing enrollment credentials or relying solely on initial startup. - Discovery acceptance must cover concurrent boot startup as well as steady state. Derive startup ordering from existing caller declarations and keep ordering separate from dependencies that activate optional services; an diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 192d2dab..93f63254 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2445,6 +2445,8 @@ dependencies = [ name = "skillet_hosts" version = "0.1.0" dependencies = [ + "serde", + "serde_json", "skillet_btrbk", "skillet_caddy", "skillet_core", diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 36a0e396..7963a532 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -348,11 +348,21 @@ Clamps runs `tailscale/tailscale` as a host-network container, matching Chef on rupik, calculon, and boxy. Its state persists under `/var/lib/data/tailscale`. Skillet reads the workstation OAuth client from KeePassXC, mints a one-use enrollment key, and sends only that key to the host -as an encrypted systemd credential and Podman secret. Smoke VM destruction +inside a versioned encrypted systemd credential, together with resolved routing +policy. Only the decoded key becomes the Podman secret. Smoke VM destruction removes the matching `tag:skillet-smoke` device before deleting the VM. This requires `curl` on the workstation. See the [private UI design](../design/private-ui-access.md). +Clamps selects `advertise_exit_node: true` in its canonical host declaration. +Production credential delivery enables that preference; smoke provisioning always +disables it. Approve the provisioned production device as an exit node in the +Tailscale admin console (or your existing auto-approval policy), then select it +on clients. A restrictive tailnet policy must allow those users access to +`autogroup:internet`; see [exit-node setup](https://tailscale.com/docs/features/exit-nodes). +No extra vault secret is needed. For an existing raw-key credential, +redeliver with the production command below to activate the new policy. + 1. In the Tailscale admin console, open **Access controls** and ensure the policy defines `tag:skillet-provisioner`, `tag:skillet-server`, and `tag:skillet-smoke`. Set `tagOwners` so the provisioner tag owns the server diff --git a/ublue/skillet/crates/host-profiles/Cargo.toml b/ublue/skillet/crates/host-profiles/Cargo.toml index eaa2a85e..fb5c6da5 100644 --- a/ublue/skillet/crates/host-profiles/Cargo.toml +++ b/ublue/skillet/crates/host-profiles/Cargo.toml @@ -19,6 +19,8 @@ skillet_caddy.workspace = true skillet_ddns.workspace = true skillet_podman.workspace = true thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true [dev-dependencies] skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 1d06edc6..94da59fa 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -62,6 +62,7 @@ fn clamps_tailscale_uses_host_network_and_persistent_state() { "clamps-test-smoke", "test-auth-key".to_string(), "/var/lib/data/tailscale", + false, ); assert_eq!(config.name, "tailscale"); @@ -483,3 +484,29 @@ fn smtp_is_shared_and_has_an_independent_credential_phase() { ); } } + +#[test] +fn tailscale_exit_node_is_caller_selected_and_preferences_follow_readiness() { + assert!(super::profile_for_name("clamps") + .unwrap() + .tailscale_exit_node()); + assert!(!super::profile_for_name("beezelbot") + .unwrap() + .tailscale_exit_node()); + for enabled in [false, true] { + let config = tailscale_config( + "fixture-host", + "fixture-key".into(), + "/var/lib/data/tailscale", + enabled, + ); + let container = &config.extra_config["Container"]; + assert!(container.contains(&format!( + "Environment=TS_EXTRA_ARGS=--advertise-exit-node={enabled}" + ))); + assert!(container.contains(&"Environment=TS_AUTH_ONCE=true".into())); + assert!(container.contains(&"Notify=healthy".into())); + assert!(container.contains(&"HealthCmd=tailscale status --peers=false".into())); + assert!(config.extra_config["Service"].contains(&format!("ExecStartPost=/usr/bin/podman exec tailscale tailscale set --advertise-exit-node={enabled}"))); + } +} diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index 3e1406a5..364f6611 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -10,6 +10,7 @@ use std::collections::BTreeMap; use thiserror::Error; pub mod profile; +pub mod tailscale; pub use profile::{ declared_profiles, profile_for_host, profile_for_name, AcceptanceListener, AcceptanceOwner, AcceptanceService, CredentialConsumer, HealthProbe, HostAcceptancePlan, HostId, HostProfile, @@ -141,7 +142,12 @@ pub fn ui_config_for_host(hostname: &str) -> Option { }) } -fn tailscale_config(hostname: &str, auth_key: String, state_path: &str) -> PodmanConfig { +fn tailscale_config( + hostname: &str, + auth_key: String, + state_path: &str, + advertise_exit_node: bool, +) -> PodmanConfig { let extra_config = BTreeMap::from([ ( "Container".to_string(), @@ -154,11 +160,23 @@ fn tailscale_config(hostname: &str, auth_key: String, state_path: &str) -> Podma "AutoUpdate=registry".to_string(), "Environment=TS_ACCEPT_DNS=false".to_string(), "Environment=TS_AUTH_ONCE=true".to_string(), + format!("Environment=TS_EXTRA_ARGS=--advertise-exit-node={advertise_exit_node}"), + "Notify=healthy".to_string(), + "HealthCmd=tailscale status --peers=false".to_string(), + "HealthInterval=10s".to_string(), + "HealthStartPeriod=60s".to_string(), format!("Environment=TS_HOSTNAME={hostname}"), "Environment=TS_STATE_DIR=/var/lib/tailscale".to_string(), "Environment=TS_USERSPACE=false".to_string(), ], ), + ( + "Service".to_string(), + vec![ + "TimeoutStartSec=180".to_string(), + format!("ExecStartPost=/usr/bin/podman exec tailscale tailscale set --advertise-exit-node={advertise_exit_node}"), + ], + ), ( "Unit".to_string(), vec!["After=network-online.target".to_string()], @@ -295,16 +313,26 @@ fn apply_profile( } ServiceConfig::Syncthing { .. } => apply_syncthing(system, files, profile, service)?, ServiceConfig::Unifi => skillet_unifi::apply(system, files)?, - ServiceConfig::Tailscale { state_path } => { - let auth_key = credentials - .require(TAILSCALE_AUTH_KEY_CREDENTIAL)? - .to_string(); + ServiceConfig::Tailscale { + state_path, + advertise_exit_node, + } => { + let input = tailscale::EnrollmentInput::parse( + credentials.require(TAILSCALE_AUTH_KEY_CREDENTIAL)?, + ) + .map_err(|e| ApplyError::FixtureInput(e.to_string()))?; + if input.advertise_exit_node && !advertise_exit_node { + return Err(ApplyError::FixtureInput( + "host does not declare exit-node routing".into(), + )); + } + let auth_key = input.auth_key; system.ensure_podman_secret(TAILSCALE_AUTH_KEY_CREDENTIAL, &auth_key)?; let hostname = runtime_hostname(files, profile.id.as_str())?; skillet_podman::container( system, files, - tailscale_config(&hostname, auth_key, state_path), + tailscale_config(&hostname, auth_key, state_path, input.advertise_exit_node), )?; } ServiceConfig::Btrbk { .. } => { diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index f52cf88f..d874e807 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -59,6 +59,7 @@ pub enum ServiceConfig { network_monitoring: bool, }, Tailscale { + advertise_exit_node: bool, state_path: &'static str, }, Btrbk { @@ -173,6 +174,18 @@ pub enum HealthProbe { } impl HostProfile { + pub fn tailscale_exit_node(&self) -> bool { + self.services.iter().any(|service| { + matches!( + service.config, + ServiceConfig::Tailscale { + advertise_exit_node: true, + .. + } + ) + }) + } + /// Runtime checks follow the services and UI capabilities declared by /// this profile, so fixture acceptance cannot stand in for application /// acceptance. @@ -384,7 +397,7 @@ fn acceptance_service(profile: &str, service: &HostService) -> AcceptanceService ], health_probe: None, }, - ServiceConfig::Tailscale { state_path } => AcceptanceService { + ServiceConfig::Tailscale { state_path, .. } => AcceptanceService { unit: "tailscale.service".into(), container: Some("tailscale".into()), network_mode: Some("host".into()), @@ -455,6 +468,7 @@ fn clamps() -> HostProfile { }, HostService { config: ServiceConfig::Tailscale { + advertise_exit_node: true, state_path: "/var/lib/data/tailscale", }, ui: None, diff --git a/ublue/skillet/crates/host-profiles/src/tailscale.rs b/ublue/skillet/crates/host-profiles/src/tailscale.rs new file mode 100644 index 00000000..8220ee8f --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/tailscale.rs @@ -0,0 +1,53 @@ +//! Typed encrypted enrollment payload; clear policy is resolved before delivery. +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +#[error("invalid Tailscale enrollment payload")] +pub struct InputError; + +// No Debug: auth_key is sensitive. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnrollmentInput { + version: u8, + pub auth_key: String, + pub advertise_exit_node: bool, +} + +impl EnrollmentInput { + pub fn new(auth_key: String, advertise_exit_node: bool) -> Self { + Self { + version: 1, + auth_key, + advertise_exit_node, + } + } + pub fn parse(payload: &str) -> Result { + let input = if payload.trim_start().starts_with('{') { + serde_json::from_str(payload).map_err(|_| InputError)? + } else { + // Retained raw credentials stay usable without implicitly enabling routing. + Self::new(payload.trim().to_string(), false) + }; + input.validate()?; + Ok(input) + } + pub fn payload(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| InputError) + } + fn validate(&self) -> Result<(), InputError> { + if self.version != 1 + || self.auth_key.trim().is_empty() + || self.auth_key.contains(['\n', '\r', '\0']) + { + return Err(InputError); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "tailscale/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/host-profiles/src/tailscale/tests.rs b/ublue/skillet/crates/host-profiles/src/tailscale/tests.rs new file mode 100644 index 00000000..b2e8d10f --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/tailscale/tests.rs @@ -0,0 +1,27 @@ +use super::*; +#[test] +fn explicit_payload_round_trips_and_legacy_defaults_to_no_exit_node() { + for enabled in [false, true] { + let input = EnrollmentInput::new("fixture-key".into(), enabled); + let parsed = EnrollmentInput::parse(&input.payload().unwrap()).unwrap(); + assert_eq!(parsed.auth_key, "fixture-key"); + assert_eq!(parsed.advertise_exit_node, enabled); + } + let legacy = EnrollmentInput::parse("fixture-key\n").unwrap(); + assert_eq!(legacy.auth_key, "fixture-key"); + assert!(!legacy.advertise_exit_node); +} +#[test] +fn malformed_versioned_input_is_rejected_without_echoing_values() { + for input in [ + "", + "{private-invalid", + r#"{"version":2,"auth_key":"private-key","advertise_exit_node":true}"#, + r#"{"version":1,"auth_key":"private-key","advertise_exit_node":true,"extra":1}"#, + ] { + assert_eq!( + EnrollmentInput::parse(input).err().unwrap().to_string(), + "invalid Tailscale enrollment payload" + ); + } +} diff --git a/ublue/skillet/crates/workstation/src/credential_delivery.rs b/ublue/skillet/crates/workstation/src/credential_delivery.rs index f9c08e4e..16883ce8 100644 --- a/ublue/skillet/crates/workstation/src/credential_delivery.rs +++ b/ublue/skillet/crates/workstation/src/credential_delivery.rs @@ -131,7 +131,10 @@ pub fn deliver_tailscale_credential( "tailscale_auth_key", "skillet-full-apply.service", credential::ActivationPolicy::StartConsumer, - auth_key.key.as_bytes(), + skillet_hosts::tailscale::EnrollmentInput::new(auth_key.key, profile.tailscale_exit_node()) + .payload() + .map_err(|e| CredentialDeliveryError::Invalid(e.to_string()))? + .as_bytes(), )?; Ok(()) } diff --git a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs index c58fdcee..568175ef 100644 --- a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs +++ b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs @@ -201,10 +201,11 @@ fn tailscale_production_key_uses_profile_tag_and_shared_credential_installer() { &*provider.description.borrow(), "Skillet clamps production host" ); - assert_eq!( - guest.calls.lock().unwrap()[0].1.as_deref(), - Some(b"dummy auth key".as_slice()) - ); + let calls = guest.calls.lock().unwrap(); + let payload = std::str::from_utf8(calls[0].1.as_deref().unwrap()).unwrap(); + let input = skillet_hosts::tailscale::EnrollmentInput::parse(payload).unwrap(); + assert_eq!(input.auth_key, "dummy auth key"); + assert!(input.advertise_exit_node); } #[test] diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs index 14d5c902..948f117c 100644 --- a/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs @@ -124,7 +124,10 @@ pub fn enroll_disposable_vm( "tailscale_auth_key", "skillet-full-apply.service", credential::ActivationPolicy::StartConsumer, - auth_key.key.as_bytes(), + skillet_hosts::tailscale::EnrollmentInput::new(auth_key.key, false) + .payload() + .map_err(|e| EnrollmentError::Invalid(e.to_string()))? + .as_bytes(), )?; addresses = tailscale::wait_for_addresses( || guest_addresses(guest), diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs index 362284fd..dd9f236c 100644 --- a/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs @@ -220,7 +220,12 @@ fn unenrolled_vm_receives_auth_key_before_device_lookup_and_state_is_saved() { assert_eq!(provider.devices_found.get(), 1); let installs = guest.installs.lock().unwrap(); assert_eq!(installs.len(), 1); - assert_eq!(installs[0].1.as_deref(), Some(b"dummy-auth-key".as_slice())); + let input = skillet_hosts::tailscale::EnrollmentInput::parse( + std::str::from_utf8(installs[0].1.as_deref().unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(input.auth_key, "dummy-auth-key"); + assert!(!input.advertise_exit_node); assert!(installs[0] .0 .iter() From cdeb737b57fb2b968c8310503a3fdd33ef245a15 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 17:56:47 +0200 Subject: [PATCH 232/252] docs(clamps): audit host access and baseline parity --- ublue/butane/ACCEPTANCE.md | 25 +++++++++++++++ ublue/plan/CLAMPS-BASELINE-AUDIT.md | 48 +++++++++++++++++++++++++++++ ublue/plan/CLAMPS-PARITY.md | 11 ++++--- 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 ublue/plan/CLAMPS-BASELINE-AUDIT.md diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 27e0261e..2a239f3b 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2154,3 +2154,28 @@ host tool; it still uses normal command approvals. - Live VM/client routing was intentionally omitted by operator decision, rather than recorded as passing. Production tailnet exit-node approval and client selection remain operator deployment steps. Retained smoke VM was not modified. + + +## 2026-10-09 — read-only host baseline audit + +- Used the owned `test vm ssh clamps smoke` transport for bounded read-only + inspection of the retained VM. No reapply, reboot, service or account changes. +- SSH server syntax and client configuration parsing passed. Effective key-only + authentication, root policy, forwarding and SFTP match the public Chef role. + SSH service enabled/active; home and Ignition key permissions appropriate. +- Sudo syntax passed. Base uCore sudo-group rule grants passwordless access, + independently of the VM override. This differs from Chef's default password + requirement. Production forced-password-change behavior is removed by VM + staging and was not accepted here. +- Eternal Terminal installed/enabled/active with dual-stack TCP 2022 listeners; + an end-to-end ET client session and physical firewall access were not tested. +- Giacomo UID/GID is 1001, versus Chef's declared 1000; Core owns 1000. Live + user authorized_keys contains seven keys in addition to the Ignition file; + no key material or password hashes copied into this record. +- Brew and dotfiles oneshots succeeded and their markers exist. Installer `-V` + explicitly skips Vim plugins; plugin installation remains deferred. +- SELinux enforcing, auditd active/enabled, both forwarding sysctls set to one. + OS-hardening placeholder and observed login-policy differences remain open. + WOL installed; private target shortcuts and actual wake-up were not tested. +- Source comparison confirmed identical image/Skillet SSH and sysctl files. + Findings and ordered follow-ups: `../plan/CLAMPS-BASELINE-AUDIT.md`. diff --git a/ublue/plan/CLAMPS-BASELINE-AUDIT.md b/ublue/plan/CLAMPS-BASELINE-AUDIT.md new file mode 100644 index 00000000..3d92cd26 --- /dev/null +++ b/ublue/plan/CLAMPS-BASELINE-AUDIT.md @@ -0,0 +1,48 @@ +# Clamps baseline audit + +Status: audited 2026-10-09; configuration unchanged. Compare the public Rupik +Chef role and pinned cookbook sources with Butane, Skillet, uCore image inputs +and read-only observations on the retained owned `clamps-test-smoke` VM. +Private Chef attributes and physical Clamps access remain outside this evidence. + +## Results + +| Area | Result | Remaining decision or check | +| --- | --- | --- | +| SSH | Server syntax and client configuration parse passed. Key authentication, password/interactive authentication disabled, key-only root access, TCP forwarding and internal SFTP match the Chef role. Server enabled and active; Ignition keys supported; home/SSH permissions accepted. Image and Skillet SSH files are identical. | Physical LAN/tailnet access and final key inventory. No new SSH defect found. | +| sudo | Syntax passed. uCore supplies `%sudo ALL=(ALL) NOPASSWD: ALL`; Giacomo belongs to this group. VM also has its own passwordless rule. | Confirm retaining passwordless administration: the public Chef sysadmins rule required a password by default. Production forced-password-change bootstrap is removed from VM staging and has not been accepted here. | +| Eternal Terminal | Package installed, unit enabled and active, listening on port 2022 for IPv4 and IPv6. Chef supplied no custom ET configuration. | End-to-end ET client and physical firewall reachability remain unverified. | +| User identity | Core is UID/GID 1000; Giacomo is 1001 with Bash and wheel/sudo/docker membership. Ignition has five baseline keys; the live user authorized_keys has seven, plus the VM injection in the Ignition file. | Decide whether to retain UID 1001 or migrate account IDs; account for existing data ownership. Verify final production keys without assuming all keys come from Ignition. Dario is not enabled by the public Rupik role; do not add it automatically. | +| Bootstrap password | Production Butane contains a fixed password hash and a first-login password-expiry service. SSH password login remains disabled. | Review the committed bootstrap hash and production console/password-change policy before cutover. Never reproduce the hash in audit records. VM staging removes forced expiry, so VM success does not accept that flow. | +| Homebrew/dotfiles | Both oneshots completed successfully, markers exist and Brew is installed. Current bootstrap runs the dotfiles installer with `-b core -V`. | `-V` skips Vim plugins. Their installation remains deferred by the operator's earlier choice. Existing parity text saying the entire install hook was deferred was stale. | +| OS hardening | SSH/sysctl convergence implemented; image copies identical. IPv4/IPv6 forwarding enabled, SELinux enforcing, auditd enabled and active. | Skillet's OS-hardening function remains a placeholder. Broader Chef policy has not been reconciled with uCore defaults; see below. | +| Wake-on-LAN | Common image and VM have Fedora `wol`. | Chef generated per-target shortcuts, but its public target map is empty. Obtain private target inventory only if those shortcuts are required; actual wake-up is unverified. | + +## Hardening reconciliation needed + +The pinned Chef OS cookbook manages login/password policy, limits, PAM, +profile/umask policy, securetty, audit configuration, filesystem module +blacklists and selected access permissions. Their equivalence on uCore has +not been established by the SSH/sysctl implementation. + +Observed login defaults are maximum/minimum/warning days `99999/0/7`, while +Chef's server role declares `730/-1/30`. Chef-specific limits, profile and module +blacklist files are absent. This establishes a policy difference; it does not +establish that Fedora lacks equivalent protections elsewhere. Auditd and SELinux +already run. Do not copy obsolete distribution-specific Chef rules wholesale. + +## Follow-up order and exit criteria + +1. Decide production account IDs, required keys, passwordless sudo and bootstrap + password/expiry handling. Record intentional changes and test a fresh production + account path without the VM-specific account overrides. Preserve data ownership + and a working administrative access path. +2. Reconcile each Chef OS policy with the current uCore baseline. Implement only + retained requirements through the correct image/runtime owner, or document the + deliberate replacement. Remove the misleading placeholder status only after + requirements are accounted for; validate syntax, repeat apply and effective state. +3. Keep Vim plugin installation deferred unless explicitly resumed. Confirm WOL + targets privately if needed. Complete physical SSH/ET/firewall access at cutover. + +Evidence: `../butane/ACCEPTANCE.md`. This audit does not authorize or perform +account, authentication, service or kernel-policy changes. diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 6962fdb4..ec5ae1d2 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -1,6 +1,7 @@ # Rupik to Clamps parity checklist -Status: source audit, 2026-10-06. This compares the repository's Chef include +Status: source audit updated 2026-10-09; baseline VM observations recorded in +`CLAMPS-BASELINE-AUDIT.md`. This compares the repository's Chef include path with current Skillet, Butane, and image definitions. It is not an inventory of effective private Chef attributes or live Rupik state. Never copy production domains, addresses, record names, zone IDs, tokens, or backups into this file. @@ -20,9 +21,9 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled; caller-selected production exit-node advertisement implemented, disabled for disposable VMs | Approve the production exit node in tailnet policy and select it on clients; live routing exercise omitted by operator decision; see `../design/tailscale.md` | | Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Explicit rotation acceptance and private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | | Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks, submissions and automatic boot discovery passed; NPM/USM perf/HTTP traffic and reboot acceptance passed with explicit PERFMON; physical Secure Boot, provider tag read-back and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | -| EternalTerminal | Common image installs `et` and enables `et.service` | Physical service/listener/access validation | +| EternalTerminal | Common image installs `et`; VM unit active/enabled and dual-stack port 2022 listening | Physical firewall and end-to-end client access | | Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | -| Users, SSH, sudo, shell/dotfiles | Generic user environment/bootstrap and hardening exist; user acceptance previously passed | Compare additional Chef-managed accounts, permissions, and relevant hardening settings. Dotfiles install hook was explicitly deferred | +| Users, SSH, sudo, shell/dotfiles | SSH syntax/effective configuration accepted; uCore supplies passwordless sudo; Brew and dotfiles installer completed, with Vim plugins explicitly skipped | Decide UID 1001 versus Chef 1000, production keys and bootstrap password/expiry policy; broader OS-hardening function remains a placeholder. See `CLAMPS-BASELINE-AUDIT.md` | | Ubuntu package/Chef updates | Signed uCore images and Podman auto-update replace that maintenance mechanism | Physical OS update/rebase acceptance; no Chef daemon migration | | Argon One | Omitted intentionally for x86_64 Clamps | None | @@ -44,7 +45,9 @@ exists. Additional private run-list recipes remain an inventory question. Secure Boot, provider tag read-back and richer monitoring follow-ups in `CLAMPS-DATADOG.md`. 3. Production exit-node configuration is implemented; local policy checks passed. - Next: close remaining host-baseline gaps before cutover. + Next: resolve account/bootstrap policy and reconcile broader OS hardening + using the ordered follow-ups in `CLAMPS-BASELINE-AUDIT.md`; audit completed + without configuration changes. 4. Run the parked VM and deferred network/production acceptance when their required infrastructure is available; transfer state only with a recovery path. From eb38ba25c30c6d53b848a02023ff46b50f42e189 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Fri, 9 Oct 2026 18:38:03 +0200 Subject: [PATCH 233/252] feat(access): finalize sudo and first-login password policy --- ublue/AGENTS.md | 5 + ublue/butane/ACCEPTANCE.md | 24 ++ ublue/butane/includes/passwd.bu | 24 -- ublue/design/administrative-access.md | 29 +++ ublue/plan/CLAMPS-BASELINE-AUDIT.md | 23 +- ublue/plan/CLAMPS-PARITY.md | 2 +- ublue/skillet/Cargo.lock | 3 + ublue/skillet/README.md | 26 +++ ublue/skillet/crates/cli-common/Cargo.toml | 3 + ublue/skillet/crates/cli-common/src/access.rs | 219 ++++++++++++++++++ .../crates/cli-common/src/access/tests.rs | 115 +++++++++ ublue/skillet/crates/cli-common/src/lib.rs | 7 + ublue/skillet/crates/cli/src/main.rs | 12 + 13 files changed, 457 insertions(+), 35 deletions(-) create mode 100644 ublue/design/administrative-access.md create mode 100644 ublue/skillet/crates/cli-common/src/access.rs create mode 100644 ublue/skillet/crates/cli-common/src/access/tests.rs diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 09ecbcb7..a9f2ee01 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -58,6 +58,11 @@ ## Shared infrastructure boundaries +- Authentication finalization must follow explicit provisioning completion, not + base readiness. One-time account mutations need durable recovery state and + must preserve later operator password changes; routine apply must not reopen + temporary privileged access or repeat password expiry. + - Optional services require explicit selection in the canonical host caller declaration. Shared baseline services retain their common defaults; the presence of environment configuration or credentials must not enable an diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 2a239f3b..68878353 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2179,3 +2179,27 @@ host tool; it still uses normal command approvals. WOL installed; private target shortcuts and actual wake-up were not tested. - Source comparison confirmed identical image/Skillet SSH and sysctl files. Findings and ordered follow-ups: `../plan/CLAMPS-BASELINE-AUDIT.md`. + + +## 2026-10-09 — explicit administrative access finalization + +- Operator accepted UID 1001 and selected temporary passwordless provisioning, + followed by password-required sudo and first-login password change. +- Shared guest/host CLI now offers `access finalize --user `; base apply + never invokes it. New production Butane inputs no longer run early automatic + expiry. Existing install media must be regenerated; installed legacy expiry + units require explicit retirement. No retained VM or production account changed. +- Finalization validates sudo policy, installs the account's PASSWD override and + expires its existing password. Root-only intent/completion state and a lock + support retry without expiring a password already changed by the operator. +- Formatting, pedantic Clippy, 338 workspace tests, Fedora base repeat-apply and + all eight bootstrap regressions passed. CI unit coverage includes command + parsing, one-time expiry, expiry failure/retry, interrupted recovery preserving + a changed password, invalid journal rejection and account-name validation. +- An isolated Fedora container with native sudo/shadow tools passed temporary + passwordless access, final expiry, passwordless rejection, new-password sudo + acceptance, repeat finalization preserving password age, and sudo syntax. + Synthetic passwords only; fixture removed. No workstation packages installed. +- Interactive first-login password change over SSH/console on a fresh uCore + install remains unverified. Later workstation delivery currently assumes + passwordless sudo; finish it first, then use interactive sudo/root administration. diff --git a/ublue/butane/includes/passwd.bu b/ublue/butane/includes/passwd.bu index a4ced259..17003b64 100644 --- a/ublue/butane/includes/passwd.bu +++ b/ublue/butane/includes/passwd.bu @@ -22,27 +22,3 @@ passwd: - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID19B1eeB6KClwXbmu+RWo+3nrXl33Yf3+Oh/WVigrwl giacomo@calculon - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB+LUBD9jvN98k6es/W/0h/nGibhHGf7OY6JZG+H3pQL giacomo@mac - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICYS/jhr4/Ld55BT2YjP+b+LHWNkaSuRYSuLre2Mbxwz giacomo@ubikxps -storage: - files: - - path: /usr/local/bin/force_pw_change.sh - contents: - inline: | - #!/bin/bash - chage -d 0 giacomo - mode: 0755 -systemd: - units: - - name: force-pw-change.service - enabled: true - contents: | - [Unit] - Description=Force password change for user - After=network-online.target - ConditionPathExists=!/var/lib/.force-pw-change - [Service] - Type=oneshot - ExecStart=/usr/local/bin/force_pw_change.sh - ExecStartPost=/usr/bin/touch /var/lib/.force-pw-change - RemainAfterExit=yes - [Install] - WantedBy=multi-user.target diff --git a/ublue/design/administrative-access.md b/ublue/design/administrative-access.md new file mode 100644 index 00000000..1e910cc3 --- /dev/null +++ b/ublue/design/administrative-access.md @@ -0,0 +1,29 @@ +# Administrative access lifecycle + +Production provisioning temporarily uses uCore's passwordless sudo-group policy. +Finalization is an explicit operator step after service and credential provisioning, +not part of base apply or a boot-triggered expiry service. A provisioning-complete +signal cannot be inferred from base readiness when later credentials are optional. + +`skillet access finalize --user ` installs a final user-specific sudo rule +requiring that user's password and expires its existing bootstrap password. The +operator changes the password at the next interactive login. SSH continues to use +keys; finalization does not enable SSH password authentication. The existing +bootstrap password must be known, and console recovery should be available. + +A root-only per-account journal records intent before expiry and completion after +success. Interrupted delivery is retryable; a fingerprint of the bootstrap hash +allows recovery to preserve a password already changed by the operator. Repeat +finalization restores sudo policy without expiring the password again. A root-held +lock serializes finalization. Sudo syntax is checked before and after installation; +a later sudoers include is rejected rather than risking a conflicting override. + +Smoke VMs retain temporary passwordless access unless explicitly finalized. Base +apply, boot and image rebases preserve the final policy and expiry receipt. Later +SSH provisioning automation currently assumes passwordless sudo; after finalization +use interactive sudo/root administration until an authenticated transport is added. +The policy applies to the selected account; other accounts remain unchanged. + +The current production Butane bootstrap password hash is retained by this change. +Private bootstrap-password delivery is a separate future decision; never include +hashes, password fingerprints or passwords in logs, manifests or audit records. diff --git a/ublue/plan/CLAMPS-BASELINE-AUDIT.md b/ublue/plan/CLAMPS-BASELINE-AUDIT.md index 3d92cd26..546cc6ce 100644 --- a/ublue/plan/CLAMPS-BASELINE-AUDIT.md +++ b/ublue/plan/CLAMPS-BASELINE-AUDIT.md @@ -1,6 +1,9 @@ # Clamps baseline audit -Status: audited 2026-10-09; configuration unchanged. Compare the public Rupik +Status: audited 2026-10-09. Subsequent operator decisions accept UID 1001 +and require passwordless provisioning followed by explicit password-required +sudo and first-login password change; see `../design/administrative-access.md`. +The original audit made no configuration changes. Compare the public Rupik Chef role and pinned cookbook sources with Butane, Skillet, uCore image inputs and read-only observations on the retained owned `clamps-test-smoke` VM. Private Chef attributes and physical Clamps access remain outside this evidence. @@ -10,10 +13,10 @@ Private Chef attributes and physical Clamps access remain outside this evidence. | Area | Result | Remaining decision or check | | --- | --- | --- | | SSH | Server syntax and client configuration parse passed. Key authentication, password/interactive authentication disabled, key-only root access, TCP forwarding and internal SFTP match the Chef role. Server enabled and active; Ignition keys supported; home/SSH permissions accepted. Image and Skillet SSH files are identical. | Physical LAN/tailnet access and final key inventory. No new SSH defect found. | -| sudo | Syntax passed. uCore supplies `%sudo ALL=(ALL) NOPASSWD: ALL`; Giacomo belongs to this group. VM also has its own passwordless rule. | Confirm retaining passwordless administration: the public Chef sysadmins rule required a password by default. Production forced-password-change bootstrap is removed from VM staging and has not been accepted here. | +| sudo | Syntax passed. uCore supplies `%sudo ALL=(ALL) NOPASSWD: ALL`; Giacomo belongs to this group. VM also has its own passwordless rule. | Operator selected temporary passwordless provisioning, then explicit access finalization requiring a sudo password and first-login password change. Live production login acceptance remains pending. | | Eternal Terminal | Package installed, unit enabled and active, listening on port 2022 for IPv4 and IPv6. Chef supplied no custom ET configuration. | End-to-end ET client and physical firewall reachability remain unverified. | -| User identity | Core is UID/GID 1000; Giacomo is 1001 with Bash and wheel/sudo/docker membership. Ignition has five baseline keys; the live user authorized_keys has seven, plus the VM injection in the Ignition file. | Decide whether to retain UID 1001 or migrate account IDs; account for existing data ownership. Verify final production keys without assuming all keys come from Ignition. Dario is not enabled by the public Rupik role; do not add it automatically. | -| Bootstrap password | Production Butane contains a fixed password hash and a first-login password-expiry service. SSH password login remains disabled. | Review the committed bootstrap hash and production console/password-change policy before cutover. Never reproduce the hash in audit records. VM staging removes forced expiry, so VM success does not accept that flow. | +| User identity | Core is UID/GID 1000; Giacomo is 1001 with Bash and wheel/sudo/docker membership. Ignition has five baseline keys; the live user authorized_keys has seven, plus the VM injection in the Ignition file. | UID 1001 accepted by the operator; account for existing data ownership. Verify final production keys without assuming all keys come from Ignition. Dario is not enabled by the public Rupik role; do not add it automatically. | +| Bootstrap password | Production Butane retains a fixed bootstrap password hash; automatic first-boot expiry was replaced with explicit finalization. SSH password login remains disabled. | Review the committed bootstrap hash and production console/password-change policy before cutover. Never reproduce the hash in audit records. VM staging removes forced expiry, so VM success does not accept that flow. | | Homebrew/dotfiles | Both oneshots completed successfully, markers exist and Brew is installed. Current bootstrap runs the dotfiles installer with `-b core -V`. | `-V` skips Vim plugins. Their installation remains deferred by the operator's earlier choice. Existing parity text saying the entire install hook was deferred was stale. | | OS hardening | SSH/sysctl convergence implemented; image copies identical. IPv4/IPv6 forwarding enabled, SELinux enforcing, auditd enabled and active. | Skillet's OS-hardening function remains a placeholder. Broader Chef policy has not been reconciled with uCore defaults; see below. | | Wake-on-LAN | Common image and VM have Fedora `wol`. | Chef generated per-target shortcuts, but its public target map is empty. Obtain private target inventory only if those shortcuts are required; actual wake-up is unverified. | @@ -33,10 +36,10 @@ already run. Do not copy obsolete distribution-specific Chef rules wholesale. ## Follow-up order and exit criteria -1. Decide production account IDs, required keys, passwordless sudo and bootstrap - password/expiry handling. Record intentional changes and test a fresh production - account path without the VM-specific account overrides. Preserve data ownership - and a working administrative access path. +1. UID 1001 is accepted. Post-provisioning password-required sudo and one-time + expiry are implemented through explicit access finalization. Verify production + keys and accept the interactive password-change/login flow on a fresh install + without VM-specific account overrides. Preserve working administrative access. 2. Reconcile each Chef OS policy with the current uCore baseline. Implement only retained requirements through the correct image/runtime owner, or document the deliberate replacement. Remove the misleading placeholder status only after @@ -44,5 +47,5 @@ already run. Do not copy obsolete distribution-specific Chef rules wholesale. 3. Keep Vim plugin installation deferred unless explicitly resumed. Confirm WOL targets privately if needed. Complete physical SSH/ET/firewall access at cutover. -Evidence: `../butane/ACCEPTANCE.md`. This audit does not authorize or perform -account, authentication, service or kernel-policy changes. +Evidence: `../butane/ACCEPTANCE.md`. The later access-policy implementation does not finalize any existing VM or +production account. Physical login/password-change acceptance remains pending. diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index ec5ae1d2..9fbdcf9e 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -23,7 +23,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks, submissions and automatic boot discovery passed; NPM/USM perf/HTTP traffic and reboot acceptance passed with explicit PERFMON; physical Secure Boot, provider tag read-back and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | | EternalTerminal | Common image installs `et`; VM unit active/enabled and dual-stack port 2022 listening | Physical firewall and end-to-end client access | | Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | -| Users, SSH, sudo, shell/dotfiles | SSH syntax/effective configuration accepted; uCore supplies passwordless sudo; Brew and dotfiles installer completed, with Vim plugins explicitly skipped | Decide UID 1001 versus Chef 1000, production keys and bootstrap password/expiry policy; broader OS-hardening function remains a placeholder. See `CLAMPS-BASELINE-AUDIT.md` | +| Users, SSH, sudo, shell/dotfiles | SSH syntax/effective configuration accepted; uCore supplies passwordless sudo; Brew and dotfiles installer completed, with Vim plugins explicitly skipped | UID 1001 accepted; explicit post-provisioning sudo/password-expiry finalization implemented, live interactive login pending; review production keys; broader OS-hardening function remains a placeholder. See `CLAMPS-BASELINE-AUDIT.md` | | Ubuntu package/Chef updates | Signed uCore images and Podman auto-update replace that maintenance mechanism | Physical OS update/rebase acceptance; no Chef daemon migration | | Argon One | Omitted intentionally for x86_64 Clamps | None | diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 93f63254..b169862a 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2378,8 +2378,10 @@ name = "skillet_cli_common" version = "0.1.0" dependencies = [ "clap", + "nix", "serde", "serde_yml", + "sha2 0.10.9", "skillet_core", "skillet_hosts", "skillet_smtp", @@ -2387,6 +2389,7 @@ dependencies = [ "thiserror 1.0.69", "tracing", "tracing-subscriber", + "zeroize", ] [[package]] diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 7963a532..b202c6c3 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -547,3 +547,29 @@ checks repeat apply, queue restart/retry and map regeneration; production-policy mode also checks that an upstream without STARTTLS receives no credentials/mail. It refuses to run outside that test container. Compile the CLI via Cargo and use its reported artifact; the fixture is separate from VM lifecycle management. + + +## Finalize production administrative access + +Finish service configuration and credential delivery first. On the host, run: + +```sh +sudo /var/usrlocal/bin/skillet access finalize --user giacomo +``` + +Keep the current administrative session open. Start a second interactive login +(with an SSH TTY or the console), use the known bootstrap password to change it, +and verify sudo asks for the new password. SSH authentication stays key-only. +The command requires an existing unlocked password; it does not create one. + +Finalization is explicit and persistent, with interrupted-operation recovery. +Repeating it never expires the changed password again. Base apply does not undo +it. Smoke VMs are not finalized automatically. Subsequent workstation credential +and VM automation assumes passwordless sudo: finish those operations before +finalization, then use interactive sudo or root for further administration. +Do not delete `/var/lib/skillet/access/` to rerun provisioning. + +Older install media containing `force-pw-change.service` must be regenerated. +For an already installed host using that service, stop/disable it and remove its +unit before adopting this explicit workflow; no live machine is migrated here. +See [the access lifecycle decision](../design/administrative-access.md). diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index d9e3eed6..3bba6dc3 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -7,6 +7,9 @@ edition = "2021" workspace = true [dependencies] +nix.workspace = true +sha2.workspace = true +zeroize = "1.9" skillet_smtp.workspace = true skillet_core.workspace = true skillet_hosts.workspace = true diff --git a/ublue/skillet/crates/cli-common/src/access.rs b/ublue/skillet/crates/cli-common/src/access.rs new file mode 100644 index 00000000..f53071f9 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/access.rs @@ -0,0 +1,219 @@ +//! Explicit, one-time transition from bootstrap to interactive administration. +use crate::CliCommonError; +use nix::{ + fcntl::{Flock, FlockArg}, + unistd::Uid, +}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Write as _, + os::unix::fs::OpenOptionsExt as _, + path::{Path, PathBuf}, + process::Command, +}; + +#[derive(clap::Subcommand, Debug)] +pub enum AccessCommands { + /// Finish provisioning: require a sudo password and change password on next login + Finalize { + /// Existing administrator account to finalize + #[arg(long)] + user: String, + }, +} + +pub fn dispatch(command: AccessCommands) -> Result<(), CliCommonError> { + match command { + AccessCommands::Finalize { user } => { + validate_user(&user)?; + if !Uid::effective().is_root() { + return Err(config( + "access finalization requires root; run it with sudo", + )); + } + let root = Path::new("/var/lib/skillet/access"); + fs::create_dir_all(root)?; + let lock_file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(root.join("finalize.lock"))?; + let _lock = Flock::lock(lock_file, FlockArg::LockExclusiveNonblock) + .map_err(|_| config("another access finalization is running"))?; + let runtime = LinuxAccess { + root: root.to_path_buf(), + }; + finalize(&runtime, &user)?; + tracing::info!(user, "Access finalized; sudo requires the account password"); + Ok(()) + } + } +} + +fn config(message: &str) -> CliCommonError { + CliCommonError::Config(message.to_string()) +} + +fn validate_user(user: &str) -> Result<(), CliCommonError> { + if user == "root" + || user.len() > 32 + || user.is_empty() + || !user.bytes().enumerate().all(|(i, b)| { + b.is_ascii_lowercase() || b == b'_' || (i > 0 && (b.is_ascii_digit() || b == b'-')) + }) + { + return Err(config("finalization requires a non-root Unix account name")); + } + Ok(()) +} + +struct PasswordState { + fingerprint: String, + expired: bool, +} + +// A single adapter owns the account observation and the matching mutation. +trait AccessRuntime { + fn password(&self, user: &str) -> Result; + fn read_state(&self, user: &str) -> Result, CliCommonError>; + fn write_state(&self, user: &str, state: &str) -> Result<(), CliCommonError>; + fn require_sudo_password(&self, user: &str) -> Result<(), CliCommonError>; + fn expire_password(&self, user: &str) -> Result<(), CliCommonError>; +} + +fn finalize(runtime: &impl AccessRuntime, user: &str) -> Result<(), CliCommonError> { + validate_user(user)?; + let password = runtime.password(user)?; + let state = runtime.read_state(user)?; + // Re-converge durable sudo policy without repeating a one-time expiry. + if state.as_deref() == Some("complete\n") { + return runtime.require_sudo_password(user); + } + let original = if let Some(state) = &state { + state + .strip_prefix("pending:") + .and_then(|value| value.strip_suffix('\n')) + .filter(|value| value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())) + .ok_or_else(|| config("invalid access finalization journal"))? + .to_string() + } else { + runtime.write_state(user, &format!("pending:{}\n", password.fingerprint))?; + password.fingerprint.clone() + }; + runtime.require_sudo_password(user)?; + // If interrupted after expiry or after an operator changed the password, + // finish the journal without resetting the operator's new password. + if !password.expired && password.fingerprint == original { + runtime.expire_password(user)?; + } + runtime.write_state(user, "complete\n") +} + +fn sudo_policy(user: &str) -> String { + format!("# Managed by skillet access finalize\nDefaults:{user} !rootpw, !targetpw, !runaspw\n{user} ALL=(ALL:ALL) PASSWD: ALL\n") +} + +struct LinuxAccess { + root: PathBuf, +} + +fn atomic_write(path: &Path, content: &[u8], mode: u32) -> Result<(), CliCommonError> { + use std::os::unix::fs::PermissionsExt as _; + let parent = path.parent().ok_or_else(|| config("file has no parent"))?; + if fs::read(path).ok().as_deref() == Some(content) + && fs::metadata(path)?.permissions().mode() & 0o777 == mode + { + return Ok(()); + } + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(mode))?; + temporary.write_all(content)?; + temporary.as_file().sync_all()?; + temporary + .persist(path) + .map_err(|error| CliCommonError::Io(error.error))?; + fs::File::open(parent)?.sync_all()?; + Ok(()) +} + +fn execute(program: &str, args: &[&str]) -> Result<(), CliCommonError> { + let result = Command::new(program).args(args).output()?; + if !result.status.success() { + // Never include account database contents or command output in errors. + return Err(config(&format!( + "{program} failed during access finalization" + ))); + } + Ok(()) +} + +impl AccessRuntime for LinuxAccess { + fn password(&self, user: &str) -> Result { + let shadow = zeroize::Zeroizing::new(fs::read_to_string("/etc/shadow")?); + let entry = shadow + .lines() + .find(|line| line.split(':').next() == Some(user)) + .ok_or_else(|| config("administrator account is absent from /etc/shadow"))?; + let mut fields = entry.split(':'); + fields.next(); + let hash = fields + .next() + .ok_or_else(|| config("invalid password entry"))?; + if hash.is_empty() || hash.starts_with(['!', '*']) { + return Err(config( + "administrator needs an unlocked bootstrap password before finalization", + )); + } + let changed = fields + .next() + .ok_or_else(|| config("invalid password age entry"))?; + Ok(PasswordState { + fingerprint: format!("{:x}", Sha256::digest(hash.as_bytes())), + expired: changed == "0", + }) + } + fn read_state(&self, user: &str) -> Result, CliCommonError> { + match fs::read_to_string(self.root.join(user)) { + Ok(state) => Ok(Some(state)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } + } + fn write_state(&self, user: &str, state: &str) -> Result<(), CliCommonError> { + atomic_write(&self.root.join(user), state.as_bytes(), 0o600) + } + fn require_sudo_password(&self, user: &str) -> Result<(), CliCommonError> { + let directory = Path::new("/etc/sudoers.d"); + let name = format!("zz-skillet-access-{user}"); + for entry in fs::read_dir(directory)? { + let filename = entry?.file_name().to_string_lossy().into_owned(); + if filename > name && !filename.contains('.') && !filename.ends_with('~') { + return Err(config( + "a later sudoers rule exists; review sudo policy before finalizing", + )); + } + } + let mut candidate = tempfile::NamedTempFile::new_in(directory)?; + candidate.write_all(sudo_policy(user).as_bytes())?; + let candidate_path = candidate + .path() + .to_str() + .ok_or_else(|| config("invalid sudoers path"))?; + execute("/usr/sbin/visudo", &["-c", "-f", candidate_path])?; + execute("/usr/sbin/visudo", &["-c"])?; + atomic_write(&directory.join(name), sudo_policy(user).as_bytes(), 0o440)?; + execute("/usr/sbin/visudo", &["-c"]) + } + fn expire_password(&self, user: &str) -> Result<(), CliCommonError> { + execute("/usr/bin/chage", &["-d", "0", user]) + } +} + +#[cfg(test)] +#[path = "access/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/cli-common/src/access/tests.rs b/ublue/skillet/crates/cli-common/src/access/tests.rs new file mode 100644 index 00000000..e6fa4a75 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/access/tests.rs @@ -0,0 +1,115 @@ +use super::*; +use std::cell::{Cell, RefCell}; + +struct Fake { + state: RefCell>, + fingerprint: RefCell, + expired: Cell, + expirations: Cell, + policies: Cell, + fail_expiry: Cell, +} +impl Default for Fake { + fn default() -> Self { + Self { + state: RefCell::new(None), + fingerprint: RefCell::new("a".repeat(64)), + expired: Cell::new(false), + expirations: Cell::new(0), + policies: Cell::new(0), + fail_expiry: Cell::new(false), + } + } +} +impl AccessRuntime for Fake { + fn password(&self, _: &str) -> Result { + Ok(PasswordState { + fingerprint: self.fingerprint.borrow().clone(), + expired: self.expired.get(), + }) + } + fn read_state(&self, _: &str) -> Result, CliCommonError> { + Ok(self.state.borrow().clone()) + } + fn write_state(&self, _: &str, state: &str) -> Result<(), CliCommonError> { + *self.state.borrow_mut() = Some(state.to_string()); + Ok(()) + } + fn require_sudo_password(&self, _: &str) -> Result<(), CliCommonError> { + self.policies.set(self.policies.get() + 1); + Ok(()) + } + fn expire_password(&self, _: &str) -> Result<(), CliCommonError> { + assert!(self + .state + .borrow() + .as_deref() + .is_some_and(|s| s.starts_with("pending:"))); + assert!(self.policies.get() > 0); + if self.fail_expiry.get() { + return Err(config("expiry failed")); + } + self.expirations.set(self.expirations.get() + 1); + self.expired.set(true); + Ok(()) + } +} +#[test] +fn finalization_requires_password_and_expires_once() { + let runtime = Fake::default(); + finalize(&runtime, "admin").unwrap(); + runtime.expired.set(false); + *runtime.fingerprint.borrow_mut() = "b".repeat(64); + finalize(&runtime, "admin").unwrap(); + assert_eq!(runtime.expirations.get(), 1); + assert_eq!(runtime.policies.get(), 2); + assert_eq!(runtime.state.borrow().as_deref(), Some("complete\n")); +} +#[test] +fn interrupted_expiry_is_retryable() { + let runtime = Fake::default(); + runtime.fail_expiry.set(true); + assert!(finalize(&runtime, "admin").is_err()); + runtime.fail_expiry.set(false); + finalize(&runtime, "admin").unwrap(); + assert_eq!(runtime.expirations.get(), 1); +} +#[test] +fn recovery_preserves_password_changed_after_interruption() { + for changed in [false, true] { + let runtime = Fake::default(); + *runtime.state.borrow_mut() = Some(format!("pending:{}\n", "a".repeat(64))); + if changed { + *runtime.fingerprint.borrow_mut() = "b".repeat(64); + } else { + runtime.expired.set(true); + } + finalize(&runtime, "admin").unwrap(); + assert_eq!(runtime.expirations.get(), 0); + } +} +#[test] +fn account_names_cannot_inject_policy_or_options() { + for user in ["", "root", "-admin", "admin\nroot", "admin ALL", "../admin"] { + assert!(validate_user(user).is_err()); + } + assert!(validate_user("admin-2").is_ok()); + assert!(sudo_policy("admin").contains("PASSWD: ALL")); + assert!(!sudo_policy("admin").contains("NOPASSWD")); +} +#[test] +fn shared_host_cli_accepts_finalization() { + use clap::Parser as _; + assert!( + crate::HostArgs::try_parse_from(["host", "access", "finalize", "--user", "admin"]).is_ok() + ); +} + +#[test] +fn malformed_journal_does_not_change_policy_or_account() { + let runtime = Fake::default(); + *runtime.state.borrow_mut() = Some("invalid".to_string()); + assert!(finalize(&runtime, "admin").is_err()); + assert_eq!(runtime.policies.get(), 0); + assert_eq!(runtime.expirations.get(), 0); +} diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index 0af06437..cf7228b2 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -10,6 +10,7 @@ use thiserror::Error; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; +pub mod access; pub mod hosts; mod smtp_runtime; use hosts::ApplyPhase; @@ -55,6 +56,11 @@ pub struct HostArgs { #[derive(clap::Subcommand, Debug)] pub enum HostCommands { + /// Finalize interactive administrative access after provisioning + Access { + #[command(subcommand)] + command: access::AccessCommands, + }, /// Prepare native SMTP runtime files from an encrypted systemd credential Smtp { #[command(subcommand)] @@ -111,6 +117,7 @@ where tracing::subscriber::set_global_default(subscriber)?; match args.command { + HostCommands::Access { command } => access::dispatch(command), HostCommands::Smtp { command: SmtpCommands::Prepare, } => prepare_smtp(), diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 11800566..07202808 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -20,6 +20,11 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { + /// Finalize interactive administrative access on this guest + Access { + #[command(subcommand)] + command: skillet_cli_common::access::AccessCommands, + }, /// Prepare guest SMTP runtime credentials Smtp { #[command(subcommand)] @@ -292,6 +297,7 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { + Commands::Access { command } => skillet_cli_common::access::dispatch(command)?, Commands::Smtp { command: skillet_cli_common::SmtpCommands::Prepare, } => skillet_cli_common::prepare_smtp()?, @@ -1318,6 +1324,12 @@ mod tests { use super::Args; use clap::Parser; + #[test] + fn access_finalization_parses_explicit_account() { + assert!(Args::try_parse_from(["skillet", "access", "finalize", "--user", "admin"]).is_ok()); + assert!(Args::try_parse_from(["skillet", "access", "finalize"]).is_err()); + } + #[test] fn test_run_accepts_explicit_base_phase() { let parsed = Args::try_parse_from([ From 3a5acccc44402221460178f85f954a74328e2aca Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 10 Oct 2026 15:03:51 +0200 Subject: [PATCH 234/252] docs(storage): audit TPM encryption and boot prerequisites --- ublue/butane/ACCEPTANCE.md | 22 +++++++++++++++++++++ ublue/design/storage.md | 28 ++++++++++++++++++++++++++ ublue/plan/TPM-ENCRYPTED-ROOT.md | 34 ++++++++++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 68878353..0d57995e 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2203,3 +2203,25 @@ host tool; it still uses normal command approvals. - Interactive first-login password change over SSH/console on a fresh uCore install remains unverified. Later workstation delivery currently assumes passwordless sudo; finish it first, then use interactive sudo/root administration. + + +## 2026-10-10 — TPM/boot capability audit + +- Used the owned retained `clamps/smoke` SSH transport for read-only package, + boot-layout and deployed-initramfs inspection. No guest state mutation, + reboot, credential delivery, enrollment or formatting. +- Deployed uCore: Clevis 21, cryptsetup 2.8.8, dracut 108, systemd 259.8 with + TPM2 support, TPM2 tools 5.7. Initramfs includes Clevis TPM2, cryptsetup, + systemd TPM token plugin and Btrfs. Boot entries use separate kernel/initrd; + EFI runtime and TPM devices absent in the existing BIOS VM. ukify and + systemd-measure were not found; signed PCR policies are not configured. +- Workstation: KVM present; swtpm 0.8, QEMU 10.1, libvirt 11.10, edk2 OVMF + installed. q35 domain capabilities advertise EFI/Secure Boot and emulated + TPM2. Secure Boot firmware and enrolled-key NVRAM template files exist. + No physical TPM device exposed. No workstation packages installed. +- Reviewed current CoreOS/Clevis/systemd upstream documentation. Native TPM + encryption and signed-PCR boot integrity are distinct guarantees; protection + scope choice remains pending. Initial FCOS image initramfs, PCR banks, actual + UEFI/Secure Boot/MOK startup and encrypted rebase/rollback are unverified. +- Updated storage design and TPM plan. No encrypted profile or VM was created; + current install templates and both repositories' runtime code are unchanged. diff --git a/ublue/design/storage.md b/ublue/design/storage.md index 5ba45c68..b0e425f8 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -68,3 +68,31 @@ physical disk choice remain open in References: [XDG base directories](https://specifications.freedesktop.org/basedir/), [FHS `/srv`](https://specifications.freedesktop.org/fhs/latest/srv.html), [Btrfs subvolumes](https://btrfs.readthedocs.io/en/stable/btrfs-subvolume.html). + + +## TPM capability audit (2026-10-10) + +The retained signed-uCore VM has Clevis 21, cryptsetup 2.8.8, systemd 259.8 +with TPM2 support, and TPM2 tools. Its deployed initramfs contains Clevis TPM2, +cryptsetup/systemd TPM token support and Btrfs. No extra package installation is +currently justified for native Clevis unlock; actual encrypted-boot acceptance +must also check the initial FCOS image and each subsequent deployment. + +The native Rocky test backend has KVM, swtpm and q35 OVMF Secure Boot firmware +with enrolled-key NVRAM templates. The workstation exposes no physical TPM; +virtual-TPM tests do not require one. Existing smoke domains boot BIOS without +TPM. New encrypted tests must explicitly select UEFI/Secure Boot, independent +TPM2 state and owned persistent NVRAM. Firmware capability is not boot acceptance. + +Current boot entries use separate kernel/initramfs files under GRUB. Neither +ukify nor systemd-measure was found on the deployed guest; no signed PCR-policy +pipeline exists in our image recipes. PCR 7 binds Secure Boot policy, not all +kernel/initramfs/command-line bytes. TPM-only unlock must not be described as +protection against whole-machine theft or tampering. Signed-policy boot integrity +requires a separate implemented update/rollback chain; adding packages alone +would not establish it. The first profile's protection scope is awaiting the +operator's choice; no PCR enrollment or encrypted formatting has been performed. + +Sources: [FCOS encrypted root](https://github.com/coreos/fedora-coreos-docs/blob/main/modules/ROOT/pages/storage.adoc#encrypted-storage-luks), +[Clevis TPM threat model](https://github.com/latchset/clevis/blob/master/src/pins/tpm2/clevis-encrypt-tpm2.1.adoc#threat-model), +[systemd PCR policies](https://github.com/systemd/systemd/blob/main/man/systemd-cryptenroll.xml). diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md index c7118817..fa8a13ac 100644 --- a/ublue/plan/TPM-ENCRYPTED-ROOT.md +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -4,8 +4,11 @@ Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) before proceeding with the remaining feature milestones in this plan. Existing acceptance evidence and deferred tasks remain applicable. -Status: planned, 2026-10-02. No encrypted-root implementation or acceptance -is claimed. Start after the refactoring prerequisite and private-UI acceptance work. +Status: capability/source audit started 2026-10-10. Native virtual TPM and +UEFI prerequisites are available, and deployed uCore initramfs support is +confirmed. Protection-scope selection is pending; no encrypted-root +implementation or acceptance is claimed. The refactor live retry remains +explicitly parked by the operator; it is not represented as passing. ## Scope and decisions @@ -30,6 +33,33 @@ Make small validated changes in the order below. Never format a populated physical disk as part of Skillet convergence; use only named disposable disks until physical install and restore are explicitly arranged. +## Audit findings and next slice (2026-10-10) + +- Read-only retained-VM inspection confirmed native Clevis/TPM/Btrfs unlock + components in the deployed initramfs. No image/package change is needed yet. + The exact initial cached FCOS image still needs boot-time verification. +- Native workstation q35 capabilities advertise EFI, secure loaders and TPM2 + emulation. swtpm 0.8, QEMU 10.1, libvirt 11.10 and OVMF with enrolled-key + templates are installed. No physical TPM is exposed; virtual tests remain + possible. Flatpak equivalence and physical hardware are unverified. +- Native XML already selects q35 but supplies no UEFI loader/NVRAM or TPM. + Extend `crates/vm/src/{manifest,domain_xml,backend,creation,lifecycle}.rs` + through one typed install/boot profile; preserve retained BIOS manifests. +- `includes/data-storage.bu` uses raw `by-partlabel/root` in both the creation + helper and mount unit. Select the verified unlocked Btrfs filesystem before + encrypted tests; retain UUID/topology and absent/wrong-mount guards. +- Separate GRUB kernel/initramfs entries are deployed, not an observed signed + UKI/PCR policy pipeline. Native PCR-7-based unlock offers a narrower guarantee + than authenticated whole-machine boot. Operator protection-scope choice is + pending; do not choose empty PCRs or silently claim stronger protection. +- After scope selection, specify enrollment timing across FCOS, uCore and MOK, + then implement the explicit install profile and named UEFI/TPM VM. Keep the + existing unencrypted retained smoke VM unchanged. Recovery material must be + established before TPM replacement/tampering tests. + +Evidence and policy limits: `../design/storage.md` and +`../butane/ACCEPTANCE.md`. Audit completion is not encrypted-boot acceptance. + ## 1. Establish hardware, image, and boot policy - Inventory exposed TPM2, supported PCR banks, UEFI/Secure Boot, installed From de914bff12b7712148d922d858da01233e1ee321 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 10 Oct 2026 16:52:48 +0200 Subject: [PATCH 235/252] feat(vm): add native TPM-encrypted root profile --- ublue/AGENTS.md | 6 + ublue/butane/ACCEPTANCE.md | 39 +++++ ublue/butane/README.md | 33 ++++ ublue/butane/bin/test-vm | 6 +- ublue/butane/includes/data-storage.bu | 8 +- ublue/design/smoke-vms.md | 18 +++ ublue/design/storage.md | 36 ++++- ublue/plan/TPM-ENCRYPTED-ROOT.md | 38 +++-- ublue/skillet/Cargo.lock | 1 + ublue/skillet/README.md | 33 ++++ ublue/skillet/crates/cli/Cargo.toml | 1 + ublue/skillet/crates/cli/src/main.rs | 48 ++++++ ublue/skillet/crates/cli/src/vm.rs | 113 +++++++++----- ublue/skillet/crates/cli/src/vm_encryption.rs | 142 ++++++++++++++++++ ublue/skillet/crates/vm/src/backend.rs | 124 ++++++++++++++- ublue/skillet/crates/vm/src/creation.rs | 24 ++- ublue/skillet/crates/vm/src/creation_tests.rs | 1 + ublue/skillet/crates/vm/src/domain_xml.rs | 129 +++++++++++++--- .../skillet/crates/vm/src/domain_xml_tests.rs | 70 ++++++++- ublue/skillet/crates/vm/src/firmware.rs | 77 ++++++++++ ublue/skillet/crates/vm/src/firmware/tests.rs | 9 ++ ublue/skillet/crates/vm/src/install.rs | 102 +++++++++++++ ublue/skillet/crates/vm/src/install/tests.rs | 45 ++++++ ublue/skillet/crates/vm/src/lib.rs | 2 + .../skillet/crates/vm/src/lifecycle/tests.rs | 1 + ublue/skillet/crates/vm/src/manifest.rs | 34 +++++ ublue/skillet/crates/vm/src/readiness.rs | 55 ++++++- .../skillet/crates/vm/src/readiness/tests.rs | 17 +++ ublue/skillet/crates/vm/src/staging.rs | 26 +++- ublue/skillet/crates/vm/src/staging_tests.rs | 27 ++++ ublue/skillet/integration_tests/tpm-root.sh | 35 +++++ 31 files changed, 1202 insertions(+), 98 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/vm_encryption.rs create mode 100644 ublue/skillet/crates/vm/src/firmware.rs create mode 100644 ublue/skillet/crates/vm/src/firmware/tests.rs create mode 100644 ublue/skillet/crates/vm/src/install.rs create mode 100644 ublue/skillet/crates/vm/src/install/tests.rs create mode 100644 ublue/skillet/integration_tests/tpm-root.sh diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index a9f2ee01..9899e1f2 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -85,6 +85,12 @@ - Give each resource lifecycle one owner. Every public entry point must use the same ownership validation, recovery, and cleanup policy; transport adapters must not provide a shortcut that bypasses those guarantees. +- Removing an optional generated configuration section must tolerate its + absence, while rejecting malformed present sections. Cover fresh inputs and + retained compatibility inputs through the actual staging/compiler path. +- Installation profiles are explicit and independent of host identity. Preserve + compatible defaults and reject missing prerequisites rather than silently + weakening a requested security policy. Convergence never reformats live data. - Preserve downstream schema types when parsing and re-emitting configuration. Validate staged output with the actual consumer/compiler where possible, not only with a parser round trip. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 0d57995e..5d17cfa9 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,42 @@ +## Native optional TPM root — 2026-10-10 + +- A fresh `clamps / tpm-native` disposable VM used native `qemu:///session`, + q35 UEFI Secure Boot with enrolled keys, independent NVRAM and an emulated + TPM2 with SHA256 PCR bank. No workstation packages or physical disks changed. +- Native Ignition created LUKS2 beneath Btrfs; the FCOS 44.20260829.3.1 boot + and both unsigned/signed uCore bootstrap reboots unlocked unattended. The + final signed deployment was 44.20260913.3.2, kernel 7.2.5, following the + initial FCOS kernel 7.1.10. Secure Boot remained enabled. Clevis reported one + binding to SHA256 PCR 7. No signing/MOK-policy change was performed. +- `test vm encryption-check clamps tpm-native` passed: a generated independent + 64-byte recovery key opens this volume through the passphrase path; only TPM + and recovery keyslots remain; Btrfs `/var` and `/var/lib/data` share the same + filesystem, with `/data` mounted at the latter. Root label matches that UUID. + The flushed data marker and recovery key worked after an owned cold stop/start + without an unlock prompt. Private recovery directory is 0700; key and 16-MiB + header backup are 0600 and belong to the disposable run. +- Formatting, pedantic workspace Clippy, all 347 workspace tests, ShellCheck + for the adapter/acceptance helper/extracted storage helpers, bootstrap state + regressions, and Fedora base/repeat-apply container checks passed. Routine CI + needs no vault or provider API. New tests cover retained defaults, capability + rejection, firmware/TPM ownership, conflicting formatting, account-only input + staging, symlink refusal and wrong Secure Boot/PCR policy. +- `test smoke clamps --instance tpm-native` passed the real-systemd fixture: + repeated apply, native Podman/network behavior, stored test credentials/data + and another unattended reboot. A fresh `plain-native` VM also passed signed + bootstrap/base/user readiness; `/var` remained plain `/dev/vda4` Btrfs. + Both original test runs were disposed through the owner, including the + encrypted disk, TPM/NVRAM and private recovery artifacts. Recreation used a + different UUID and fresh firmware/TPM state, created a nonempty 0600 serial + log, reached SSH, and passed the final label/UUID data-subvolume guard. Its + serial log confirmed successful data-top preparation and data mount. This + final lifecycle probe was intentionally stopped before another complete + image bootstrap and disposed; the retained original smoke VM was untouched. +- Flatpak, physical hardware, + network-disconnected boot, post-bootstrap rollback, TPM-loss/PCR-negative + boot tests and production vault-backed recovery remain unverified. This is + the accepted disk-loss/removal scope, not authenticated whole-machine boot. + ## Structured SMTP vault audit — 2026-10-09 - `skillet secrets check --host clamps --environment test` opened the unlocked diff --git a/ublue/butane/README.md b/ublue/butane/README.md index c4531dc4..71bb4f20 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -219,3 +219,36 @@ copies. UniFi 10.0.162's offline CLI restore failed in this acceptance attempt; use the setup/UI flow through `test vm ssh --forward 18443:8443`. See the plan for the retained instance and pending verification. Do not enable cloud access or change device inform settings during this isolated test. + +## Optional TPM-encrypted test root + +From `ublue/skillet`: + +```sh +cargo run --release -p skillet -- test vm create clamps encrypted --root-profile tpm --port 2202 +cargo run --release -p skillet -- test vm encryption-check clamps encrypted +cargo run --release -p skillet -- test vm destroy clamps encrypted +``` + +The equivalent wrapper is `ublue/butane/bin/test-vm clamps create encrypted +--root-profile tpm --port 2202`, followed by `encryption-check encrypted` and +`destroy encrypted`. Choose a free SSH port when retaining another VM. +`--root-profile unencrypted` is the default and supports hosts without TPM. +Profiles cannot be switched on an existing VM; use a separate instance. + +The TPM profile needs native libvirt 10.10+, swtpm 0.7+, KVM, and q35 UEFI +Secure Boot firmware with enrolled keys. Creation checks capabilities and fails +explicitly if unsupported. No packages are installed. Early boot diagnostics +are saved privately in the run's `serial.log` before SSH is available. + +`encryption-check` saves a disposable recovery key and LUKS header backup in +`butane/runs//recovery/` (directory 0700, files 0600), verifies +the independent key, cold-boots the owned VM and checks data persistence. +Reruns reuse the recovery key. Disposal removes these artifacts. No vault or +provider credentials are required. Keep production recovery material separately; +physical installation and its vault-backed recovery workflow are still pending. + +See the [storage design](../design/storage.md) and +[encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md) for policy and remaining +acceptance. PCR 7 does not measure every boot component; this profile has the +accepted encrypted-disk protection scope. diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index c0851152..f4bd5522 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -7,7 +7,7 @@ script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" repo_dir="$(realpath "$script_dir/../..")" usage() { - echo "Usage: $0 [HOST] list | $0 HOST create|ready|update|status|ssh|logs|reboot|destroy INSTANCE [--port PORT] [--image IMAGE]" >&2 + echo "Usage: $0 [HOST] list | $0 HOST create|ready|update|status|ssh|logs|reboot|encryption-check|destroy INSTANCE [--port PORT] [--image IMAGE] [--root-profile unencrypted|tpm]" >&2 exit 2 } @@ -36,7 +36,7 @@ case "$operation" in args=(test vm create "$host" "$instance") while (($#)); do case "$1" in - --port|--image) + --port|--image|--root-profile) (($# >= 2)) || usage args+=("$1" "$2") shift 2 @@ -46,7 +46,7 @@ case "$operation" in done skillet_cli "${args[@]}" ;; - ready|update|status|ssh|logs|reboot|destroy) + ready|update|status|ssh|logs|reboot|encryption-check|destroy) (($# == 0)) || usage skillet_cli test vm "$operation" "$host" "$instance" ;; diff --git a/ublue/butane/includes/data-storage.bu b/ublue/butane/includes/data-storage.bu index 70afd23f..fb69a6d0 100644 --- a/ublue/butane/includes/data-storage.bu +++ b/ublue/butane/includes/data-storage.bu @@ -24,9 +24,9 @@ storage: exit 1 } var_uuid="$(findmnt -T /var -n -o UUID)" - root_device=/dev/disk/by-partlabel/root - [[ -n "$var_uuid" && "$(blkid -s UUID -o value "$root_device")" == "$var_uuid" ]] || { - echo 'The root partition does not match the Btrfs filesystem backing /var' >&2 + root_device="/dev/disk/by-uuid/$var_uuid" + [[ -n "$var_uuid" && "$(blkid -s UUID -o value /dev/disk/by-label/root)" == "$var_uuid" ]] || { + echo 'The root filesystem does not match the Btrfs filesystem backing /var' >&2 exit 1 } top="$(mktemp -d /run/skillet-data-top.XXXXXX)" @@ -97,7 +97,7 @@ systemd: After=skillet-data-top.service [Mount] - What=/dev/disk/by-partlabel/root + What=/dev/disk/by-label/root Where=/var/lib/data Type=btrfs Options=subvol=data,noatime,compress=zstd:3 diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index d3afe40c..dbeb361d 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -160,3 +160,21 @@ fatal and duplicate local ports are refused. This allows browser inspection without publishing a guest application port on LAN or tailnet. A forward is not a network isolation policy; restored production controllers still require a persistent guest fence before import. + +## Optional encrypted root + +Each manifest records a root profile; absent fields in retained manifests mean +`unencrypted`. Explicit `tpm` requires UEFI Secure Boot with enrolled keys and +an emulated TPM2 SHA256 bank. Native and Flatpak definitions share the same +XML policy for this profile; Flatpak live equivalence remains unverified. +TPM state, NVRAM, serial diagnostics and recovery artifacts belong to the named +run. Lifecycle ownership verifies the firmware/TPM paths in addition to UUID +and disk. Cold starts preserve them; disposal removes them. Independent runs +never share TPM or firmware state. + +`encryption-check` uses the same lock and guarded transport as other guest +operations. The independent recovery key is persisted before guest enrollment +so interruption is retryable. Test secrets are private ignored files, never +vault production entries, command arguments or output. A cold power cycle +checks unattended unlock and Btrfs data persistence. This validates guest +functionality, not security against a compromised hypervisor. diff --git a/ublue/design/storage.md b/ublue/design/storage.md index b0e425f8..72fe1ae9 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -26,9 +26,8 @@ clients; Pi-hole's database and Podman's image layers are internal state. The shared `butane/includes/data-storage.bu` include creates the top-level `data` subvolume on the Btrfs filesystem backing `/var`, mounts it, prepares -the `containers` subvolume, and configures rootful Podman. It checks that the -standard `root` partition -label points to that same filesystem. Each host's Butane template must merge +the `containers` subvolume, and configures rootful Podman. It verifies the filesystem UUID backing `/var` and mounts the filesystem +labelled `root`, whether it resides on a plain partition or a LUKS mapper. Each host's Butane template must merge the include; clamps currently does. Host Skillet creates application subvolumes and directories. Existing populated Podman stores require an explicit migration; changing an installed host's graphroot is not automatic. @@ -44,7 +43,7 @@ this rule by design. The `containers` subvolume can then be omitted from service-data snapshots. A shared filesystem shares free space and physical failure; snapshots are not independent backups. -## Planned encryption +## Optional native encryption Decision, 2026-10-02: add LUKS2 beneath the shared Btrfs backing filesystem and prefer local TPM2 unlock on both local and remote physical hosts. Normal @@ -55,8 +54,28 @@ EFI and `/boot` remain outside encryption. An explicit boot-integrity policy and update/rollback compatibility must be proved before claiming protection against theft of the whole machine. Keep an independent recovery key in the private vault and a protected off-machine LUKS header backup. -Encryption is not implemented. Installation owns formatting; the current raw -root partition mount assumptions must be changed to the unlocked filesystem. +Decision, 2026-10-10: the first profile uses native Ignition/Clevis TPM2 with +SHA256 PCR 7 and requires Secure Boot. Its accepted protection scope is removal +or loss of the encrypted disk; separate GRUB kernel/initramfs files do not +provide authenticated whole-machine boot. Custom UKIs and signed PCR policies +are deferred. No network service participates in unlock. + +Implemented for disposable installs: `unencrypted` remains the default; +explicit `tpm` selects LUKS2 beneath Btrfs and fails when prerequisites are +missing. There is no automatic downgrade. Profile selection is independent of +host identity. Filesystem UUID/label checks support both layouts. TPM enrollment +occurs during native Ignition installation; the same firmware policy must +survive the signed-uCore bootstrap. Changing Secure Boot/MOK policy requires a +working recovery path before enrollment changes. On physical hardware, enroll +any required image signing/MOK keys before the first encrypted Ignition boot; +QEMU success with its enrolled firmware keys does not replace that procedure. + +Disposable acceptance creates an independent random recovery key and header +backup under the private run directory, then checks recovery without TPM and +cold-boots through the lifecycle owner. Only TPM and recovery keyslots are +accepted. These artifacts are deleted on disposal. Production vault enrollment, +physical-disk selection and physical recovery acceptance remain planned; +Skillet convergence never formats an installed filesystem. QEMU functional tests and physical acceptance are specified in the [TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). @@ -90,8 +109,9 @@ pipeline exists in our image recipes. PCR 7 binds Secure Boot policy, not all kernel/initramfs/command-line bytes. TPM-only unlock must not be described as protection against whole-machine theft or tampering. Signed-policy boot integrity requires a separate implemented update/rollback chain; adding packages alone -would not establish it. The first profile's protection scope is awaiting the -operator's choice; no PCR enrollment or encrypted formatting has been performed. +would not establish it. The operator accepted the narrower native profile above; stronger boot integrity +is a separate future project. Virtual acceptance does not establish physical +firmware compatibility. Sources: [FCOS encrypted root](https://github.com/coreos/fedora-coreos-docs/blob/main/modules/ROOT/pages/storage.adoc#encrypted-storage-luks), [Clevis TPM threat model](https://github.com/latchset/clevis/blob/master/src/pins/tpm2/clevis-encrypt-tpm2.1.adoc#threat-model), diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md index fa8a13ac..a7d4bbfe 100644 --- a/ublue/plan/TPM-ENCRYPTED-ROOT.md +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -4,11 +4,20 @@ Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) before proceeding with the remaining feature milestones in this plan. Existing acceptance evidence and deferred tasks remain applicable. -Status: capability/source audit started 2026-10-10. Native virtual TPM and -UEFI prerequisites are available, and deployed uCore initramfs support is -confirmed. Protection-scope selection is pending; no encrypted-root -implementation or acceptance is claimed. The refactor live retry remains -explicitly parked by the operator; it is not represented as passing. +Status: native profile implemented 2026-10-10. Fresh signed bootstrap, +independent-key recovery/cold boot, runtime fixture, plain-profile readiness, +and owned disposal/recreation passed in native disposable VMs. +The accepted scope is encrypted-disk loss/removal with native Clevis SHA256 +PCR 7 plus Secure Boot. Custom UKIs/signed PCR policies and Tang are deferred. +Unencrypted root remains supported and is the default. Physical and Flatpak +acceptance are unverified. The refactor live retry remains parked. + +Implemented: typed optional manifest/install profile, conflict validation, +mapper-safe Btrfs mounts, common UEFI/TPM XML, capability preflight, owned +TPM/NVRAM disposal, private serial logging, profile-aware readiness and the +repeatable independent-key/header/cold-boot acceptance command. Routine CI +covers rendering, prerequisites, staging and policy rejection without a vault. +Production recovery-key vault persistence and physical installation are pending. ## Scope and decisions @@ -42,18 +51,17 @@ until physical install and restore are explicitly arranged. emulation. swtpm 0.8, QEMU 10.1, libvirt 11.10 and OVMF with enrolled-key templates are installed. No physical TPM is exposed; virtual tests remain possible. Flatpak equivalence and physical hardware are unverified. -- Native XML already selects q35 but supplies no UEFI loader/NVRAM or TPM. - Extend `crates/vm/src/{manifest,domain_xml,backend,creation,lifecycle}.rs` - through one typed install/boot profile; preserve retained BIOS manifests. -- `includes/data-storage.bu` uses raw `by-partlabel/root` in both the creation - helper and mount unit. Select the verified unlocked Btrfs filesystem before - encrypted tests; retain UUID/topology and absent/wrong-mount guards. +- Retained unencrypted XML selects q35 without UEFI/NVRAM or TPM. + The new explicit profile extends the shared manifest/XML/backend/lifecycle + owner while preserving retained BIOS manifests. +- `includes/data-storage.bu` now selects the verified filesystem UUID/label + rather than a raw root partition; UUID/topology and mount guards remain. - Separate GRUB kernel/initramfs entries are deployed, not an observed signed UKI/PCR policy pipeline. Native PCR-7-based unlock offers a narrower guarantee - than authenticated whole-machine boot. Operator protection-scope choice is - pending; do not choose empty PCRs or silently claim stronger protection. -- After scope selection, specify enrollment timing across FCOS, uCore and MOK, - then implement the explicit install profile and named UEFI/TPM VM. Keep the + than authenticated whole-machine boot. The operator accepted this narrower + scope; custom UKIs/signed policies are deferred. Do not choose empty PCRs or silently claim stronger protection. +- With the accepted scope, verify enrollment timing across FCOS, uCore and MOK, + using the explicit profile and named UEFI/TPM VM. Keep the existing unencrypted retained smoke VM unchanged. Recovery material must be established before TPM replacement/tampering tests. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index b169862a..230ecd8f 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2317,6 +2317,7 @@ dependencies = [ "thiserror 1.0.69", "tracing", "tracing-subscriber", + "zeroize", ] [[package]] diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index b202c6c3..325204df 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -573,3 +573,36 @@ Older install media containing `force-pw-change.service` must be regenerated. For an already installed host using that service, stop/disable it and remove its unit before adopting this explicit workflow; no live machine is migrated here. See [the access lifecycle decision](../design/administrative-access.md). + +## Optional TPM-encrypted test root + +From `ublue/skillet`: + +```sh +cargo run --release -p skillet -- test vm create clamps encrypted --root-profile tpm --port 2202 +cargo run --release -p skillet -- test vm encryption-check clamps encrypted +cargo run --release -p skillet -- test vm destroy clamps encrypted +``` + +The equivalent wrapper is `ublue/butane/bin/test-vm clamps create encrypted +--root-profile tpm --port 2202`, followed by `encryption-check encrypted` and +`destroy encrypted`. Choose a free SSH port when retaining another VM. +`--root-profile unencrypted` is the default and supports hosts without TPM. +Profiles cannot be switched on an existing VM; use a separate instance. + +The TPM profile needs native libvirt 10.10+, swtpm 0.7+, KVM, and q35 UEFI +Secure Boot firmware with enrolled keys. Creation checks capabilities and fails +explicitly if unsupported. No packages are installed. Early boot diagnostics +are saved privately in the run's `serial.log` before SSH is available. + +`encryption-check` saves a disposable recovery key and LUKS header backup in +`butane/runs//recovery/` (directory 0700, files 0600), verifies +the independent key, cold-boots the owned VM and checks data persistence. +Reruns reuse the recovery key. Disposal removes these artifacts. No vault or +provider credentials are required. Keep production recovery material separately; +physical installation and its vault-backed recovery workflow are still pending. + +See the [storage design](../design/storage.md) and +[encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md) for policy and remaining +acceptance. PCR 7 does not measure every boot component; this profile has the +accepted encrypted-disk protection scope. diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml index 7f1364c2..cbe4265e 100644 --- a/ublue/skillet/crates/cli/Cargo.toml +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" workspace = true [dependencies] +zeroize = "1.9" osc8.workspace = true pulldown-cmark.workspace = true supports-hyperlinks.workspace = true diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 07202808..7e6286a8 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -8,6 +8,7 @@ use tracing_subscriber::FmtSubscriber; mod secret_delivery; mod secret_output; mod vm; +mod vm_encryption; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -147,6 +148,8 @@ enum TestCommands { #[derive(clap::Subcommand, Debug)] enum VmCommands { + /// Verify encrypted root, save disposable recovery artifacts and cold-boot it + EncryptionCheck(VmTargetArgs), /// Provision a disposable host VM and wait for it to become ready Create(VmCreateArgs), /// Destroy a disposable host VM and remove its temporary key and artifacts @@ -172,8 +175,26 @@ enum VmCommands { Update(VmDestroyArgs), } +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum RootProfileName { + Unencrypted, + Tpm, +} + +impl RootProfileName { + fn policy(self) -> skillet_vm::install::RootProfile { + match self { + Self::Unencrypted => skillet_vm::install::RootProfile::Unencrypted, + Self::Tpm => skillet_vm::install::RootProfile::Tpm, + } + } +} + #[derive(clap::Args, Debug)] struct VmCreateArgs { + /// Root storage policy; TPM requires UEFI Secure Boot and a virtual TPM + #[arg(long, value_enum, default_value_t = RootProfileName::Unencrypted)] + root_profile: RootProfileName, hostname: String, instance: String, #[arg(long, default_value_t = 2201)] @@ -360,6 +381,7 @@ fn main() -> Result<()> { fn run_vm_command(command: VmCommands) -> Result<()> { match command { + VmCommands::EncryptionCheck(args) => vm_encryption::check(&args)?, VmCommands::Create(args) => run_vm_create(&args)?, VmCommands::Destroy(args) => run_vm_destroy(&args)?, VmCommands::List(args) => run_vm_list(&args)?, @@ -1375,6 +1397,32 @@ mod tests { assert!(parsed.is_ok()); } + #[test] + fn vm_create_accepts_explicit_tpm_profile() { + assert!(Args::try_parse_from([ + "skillet", + "test", + "vm", + "create", + "clamps", + "tpm", + "--root-profile", + "tpm" + ]) + .is_ok()); + assert!(Args::try_parse_from([ + "skillet", + "test", + "vm", + "create", + "clamps", + "tpm", + "--root-profile", + "unknown" + ]) + .is_err()); + } + #[test] fn secret_unlock_accepts_database_and_key_file_overrides() { let parsed = Args::try_parse_from([ diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index f1bfe0bf..2ead36b2 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -59,42 +59,7 @@ pub(super) fn create(args: &VmCreateArgs) -> Result<()> { } let source_commit = skillet_vm::provisioning::source_revision(&workspace)?; let store = ManifestStore::new(&butane.join("runs"), current_uid())?; - let run_dir = store.run_dir(&identity); - let _prepared_run = if run_dir.exists() { - let run = store.load(&identity)?; - if run.ssh.port != args.port { - return Err(anyhow!( - "recorded VM uses SSH port {}; requested port is {}", - run.ssh.port, - args.port - )); - } - if matches!(run.phase, Phase::Preparing | Phase::Defined) - && run.source_commit != source_commit - { - return Err(anyhow!( - "unfinished VM was prepared from a different source revision; inspect or dispose it before retrying" - )); - } - skillet_vm::runtime::ensure_connection(&butane, current_uid(), &run.connection)?; - run - } else { - skillet_vm::provisioning::validate_ssh_port(args.port)?; - let runtime = skillet_vm::runtime::prepare(&butane, current_uid())?; - let backend = VirshBackend::new( - runtime.connection.clone(), - current_uid(), - &runtime.virsh_wrapper, - skillet_vm::backend::ProcessExecutor::default(), - )?; - if backend.contains_name(&identity.domain_name())? { - return Err(anyhow!( - "refusing unrecorded existing libvirt domain {}", - identity.domain_name() - )); - } - store.prepare_intent(&identity, runtime.connection, args.port, &source_commit)? - }; + prepare_run_intent(args, &butane, &store, &identity, &source_commit)?; let _create_lock = store.lock_create(&identity)?; let run = store.load(&identity)?; if matches!(run.phase, Phase::Preparing | Phase::Defined) && run.source_commit != source_commit @@ -141,6 +106,67 @@ pub(super) fn create(args: &VmCreateArgs) -> Result<()> { Ok(()) } +fn prepare_run_intent( + args: &VmCreateArgs, + butane: &std::path::Path, + store: &ManifestStore, + identity: &RunIdentity, + source_commit: &str, +) -> Result { + let run_dir = store.run_dir(identity); + if run_dir.exists() { + let run = store.load(identity)?; + if run.root_profile != args.root_profile.policy() { + return Err(anyhow!( + "recorded root profile differs; use another instance or dispose this run" + )); + } + if run.ssh.port != args.port { + return Err(anyhow!( + "recorded VM uses SSH port {}; requested port is {}", + run.ssh.port, + args.port + )); + } + if matches!(run.phase, Phase::Preparing | Phase::Defined) + && run.source_commit != source_commit + { + return Err(anyhow!( + "unfinished VM was prepared from a different source revision; inspect or dispose it before retrying" + )); + } + skillet_vm::runtime::ensure_connection(butane, current_uid(), &run.connection)?; + Ok(run) + } else { + skillet_vm::provisioning::validate_ssh_port(args.port)?; + let runtime = skillet_vm::runtime::prepare(butane, current_uid())?; + let backend = VirshBackend::new( + runtime.connection.clone(), + current_uid(), + &runtime.virsh_wrapper, + skillet_vm::backend::ProcessExecutor::default(), + )?; + if backend.contains_name(&identity.domain_name())? { + return Err(anyhow!( + "refusing unrecorded existing libvirt domain {}", + identity.domain_name() + )); + } + if args.root_profile.policy() == skillet_vm::install::RootProfile::Tpm { + backend.verify_tpm_support()?; + } + store + .prepare_intent_with_profile( + identity, + runtime.connection, + args.port, + source_commit, + args.root_profile.policy(), + ) + .map_err(Into::into) + } +} + fn prepare_guest_artifacts( butane: &std::path::Path, workspace: &std::path::Path, @@ -236,9 +262,22 @@ fn create_recorded_domain( podman_version, yq_version ); - skillet_vm::creation::create_native(store, identity, &backend, &emulator, &versions) + skillet_vm::creation::create_libvirt(store, identity, &backend, &emulator, &versions) .map_err(Into::into) } + skillet_vm::Backend::Flatpak + if run.root_profile == skillet_vm::install::RootProfile::Tpm => + { + backend.verify_tpm_support()?; + skillet_vm::creation::create_libvirt( + store, + identity, + &backend, + std::path::Path::new("/usr/bin/true"), + &backend.version(&run)?, + ) + .map_err(Into::into) + } skillet_vm::Backend::Flatpak => { let creator = FlatpakVirtInstall::for_run(&run, &butane.join("bin/virt-install"))?; let versions = format!( diff --git a/ublue/skillet/crates/cli/src/vm_encryption.rs b/ublue/skillet/crates/cli/src/vm_encryption.rs new file mode 100644 index 00000000..ac9b9cf1 --- /dev/null +++ b/ublue/skillet/crates/cli/src/vm_encryption.rs @@ -0,0 +1,142 @@ +//! Named disposable encryption acceptance, guarded by the VM lifecycle owner. +use super::{butane_root, vm, workspace_root, VmTargetArgs}; +use anyhow::{anyhow, Result}; +use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + transport::{ + GuestCommand, GuestTransport, HostKeyPolicy, OwnershipCheckedTransport, SshTransport, + }, + ManifestStore, Phase, RunIdentity, +}; +use std::{ + fs, + io::{Read as _, Write as _}, + os::unix::fs::PermissionsExt as _, +}; +use zeroize::Zeroizing; + +pub(super) fn check(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _lock = store.lock(&identity)?; + let mut run = store.load(&identity)?; + if run.root_profile != skillet_vm::install::RootProfile::Tpm || run.phase != Phase::Ready { + return Err(anyhow!( + "encryption acceptance requires a ready TPM-profile VM" + )); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let ownership = || { + backend + .inspect(&run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned encryption VM is absent".into()))? + .validate_owned(&run) + }; + ownership()?; + let ssh = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + let transport = OwnershipCheckedTransport::new(&ssh, &ownership); + let script = "/var/tmp/skillet-tpm-root-test.sh"; + transport.upload( + &workspace_root()?.join("integration_tests/tpm-root.sh"), + script, + )?; + let private = store.run_dir(&identity).join("recovery"); + store.validate(&run, &identity)?; + fs::create_dir_all(&private)?; + fs::set_permissions(&private, fs::Permissions::from_mode(0o700))?; + let key_path = private.join("root-recovery.key"); + if !key_path.exists() { + let mut key = Zeroizing::new(vec![0u8; 64]); + fs::File::open("/dev/urandom")?.read_exact(&mut key)?; + save_private(&key_path, &key)?; + } + let key = Zeroizing::new(fs::read(&key_path)?); + if key.len() != 64 { + return Err(anyhow!("disposable recovery key is invalid")); + } + execute(&transport, &["bash", script, "prepare"], Some(&key))?; + let header = Zeroizing::new(execute( + &transport, + &["cat", "/run/skillet-encryption-test/luks.header"], + None, + )?); + save_private(&private.join("root-luks.header"), &header)?; + execute(&transport, &["bash", script, "verify"], Some(&key))?; + let metadata = execute( + &transport, + &[ + "cryptsetup", + "luksDump", + "--dump-json-metadata", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + let metadata: serde_json::Value = serde_json::from_slice(&metadata)?; + if metadata["keyslots"].as_object().map(serde_json::Map::len) != Some(2) { + return Err(anyhow!( + "expected only TPM and independent recovery keyslots" + )); + } + ownership()?; + run.phase = Phase::Started; + store.save(&run)?; + backend.stop(&run)?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("encryption VM disappeared"))? + .validate_owned(&run)?; + backend.start(&run)?; + vm::ready_locked(&butane, &store, &identity)?; + let fresh = store.load(&identity)?; + let ownership = || { + backend + .inspect(&fresh)? + .ok_or_else(|| skillet_vm::Error::Invalid("encryption VM disappeared".into()))? + .validate_owned(&fresh) + }; + let transport = OwnershipCheckedTransport::new(&ssh, &ownership); + execute(&transport, &["bash", script, "verify"], Some(&key))?; + println!("Encrypted root acceptance passed: Secure Boot, LUKS2, data persistence, independent recovery key and unattended cold boot."); + println!( + "Disposable recovery artifacts: {} (removed with this VM)", + private.display() + ); + Ok(()) +} + +fn execute( + transport: &impl GuestTransport, + args: &[&str], + input: Option<&[u8]>, +) -> Result> { + let arguments: Vec<_> = std::iter::once("-n").chain(args.iter().copied()).collect(); + let out = transport.execute( + &GuestCommand { + program: "sudo", + arguments: &arguments, + }, + input, + )?; + if !out.status.success() { + return Err(anyhow!( + "encrypted-root guest assertion failed; no credential output is printed" + )); + } + Ok(out.stdout) +} + +fn save_private(path: &std::path::Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("recovery artifact has no parent"))?; + let mut temp = tempfile::NamedTempFile::new_in(parent)?; + temp.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + temp.write_all(bytes)?; + temp.as_file().sync_all()?; + temp.persist(path)?; + fs::File::open(parent)?.sync_all()?; + Ok(()) +} diff --git a/ublue/skillet/crates/vm/src/backend.rs b/ublue/skillet/crates/vm/src/backend.rs index 832bc4bb..dd4eb6fe 100644 --- a/ublue/skillet/crates/vm/src/backend.rs +++ b/ublue/skillet/crates/vm/src/backend.rs @@ -18,6 +18,15 @@ pub struct DomainSnapshot { pub uuid: Uuid, pub state: String, pub disks: Vec, + pub boot: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DomainBoot { + pub nvram: PathBuf, + pub tpm_state: PathBuf, + pub secure: bool, + pub sha256: bool, } impl DomainSnapshot { @@ -31,6 +40,23 @@ impl DomainSnapshot { "domain UUID, name or disks do not match owned run".into(), )); } + if run.root_profile == crate::install::RootProfile::Tpm { + let expected = DomainBoot { + nvram: crate::install::nvram_path(run)?, + tpm_state: crate::install::tpm_path(run)?, + secure: true, + sha256: true, + }; + if self.boot.as_ref() != Some(&expected) { + return Err(Error::Invalid( + "domain firmware or TPM state does not match the encrypted owned run".into(), + )); + } + } else if self.boot.is_some() { + return Err(Error::Invalid( + "unencrypted BIOS run has unexpected firmware or TPM state".into(), + )); + } Ok(()) } } @@ -348,6 +374,13 @@ impl VirshBackend { self.invoke(&["--version"]) } + pub fn verify_tpm_support(&self) -> Result<()> { + crate::firmware::verify_capabilities( + &self.invoke(&["domcapabilities", "--machine", "q35"])?, + &self.invoke(&["version", "--daemon"])?, + ) + } + pub fn probe(&self) -> Result<()> { self.invoke(&["uri"])?; Ok(()) @@ -407,7 +440,13 @@ impl VmBackend for VirshBackend { self.action("destroy", run) } fn undefine(&self, run: &VmRun) -> Result<()> { - self.action("undefine", run) + if run.root_profile == crate::install::RootProfile::Tpm { + self.validate_connection(run)?; + self.invoke(&["undefine", &run.uuid.to_string(), "--nvram", "--tpm"])?; + Ok(()) + } else { + self.action("undefine", run) + } } } @@ -416,6 +455,7 @@ struct DomainXml { name: String, uuid: Uuid, devices: DevicesXml, + os: Option, } #[derive(Deserialize)] @@ -441,6 +481,7 @@ struct ArchitectureXml { struct DevicesXml { #[serde(rename = "disk", default)] disks: Vec, + tpm: Option, } #[derive(Deserialize)] struct DiskXml { @@ -454,8 +495,88 @@ struct DiskSourceXml { file: Option, } +#[derive(Deserialize)] +struct OsXml { + nvram: Option, + firmware: Option, +} +#[derive(Deserialize)] +struct FirmwareXml { + #[serde(rename = "feature", default)] + features: Vec, +} +#[derive(Deserialize)] +struct FeatureXml { + #[serde(rename = "@name")] + name: String, + #[serde(rename = "@enabled")] + enabled: String, +} +#[derive(Deserialize)] +struct TpmXml { + #[serde(rename = "@model")] + model: String, + backend: TpmBackendXml, +} +#[derive(Deserialize)] +struct TpmBackendXml { + #[serde(rename = "@type")] + kind: String, + #[serde(rename = "@version")] + version: String, + source: Option, + active_pcr_banks: Option, +} +#[derive(Deserialize)] +struct TpmSourceXml { + #[serde(rename = "@type")] + kind: String, + #[serde(rename = "@path")] + path: PathBuf, +} +#[derive(Deserialize)] +struct PcrBanksXml { + sha256: Option<()>, +} + pub(crate) fn parse_domain(xml: &str, state: String) -> Result { let domain: DomainXml = quick_xml::de::from_str(xml)?; + let boot = match (domain.os, domain.devices.tpm) { + (Some(os), Some(tpm)) => { + let source = tpm + .backend + .source + .ok_or_else(|| Error::Invalid("TPM state source missing".into()))?; + if tpm.model != "tpm-crb" + || tpm.backend.kind != "emulator" + || tpm.backend.version != "2.0" + || source.kind != "dir" + { + return Err(Error::Invalid("unexpected TPM device definition".into())); + } + Some(DomainBoot { + nvram: os + .nvram + .ok_or_else(|| Error::Invalid("NVRAM source missing".into()))? + .trim() + .into(), + tpm_state: source.path, + secure: os.firmware.is_some_and(|f| { + ["secure-boot", "enrolled-keys"].iter().all(|name| { + f.features + .iter() + .any(|feature| feature.name == *name && feature.enabled == "yes") + }) + }), + sha256: tpm + .backend + .active_pcr_banks + .is_some_and(|banks| banks.sha256.is_some()), + }) + } + (_, None) => None, + _ => return Err(Error::Invalid("TPM definition lacks firmware".into())), + }; let mut disks = Vec::new(); for disk in domain.devices.disks { if disk.kind == "disk" { @@ -471,6 +592,7 @@ pub(crate) fn parse_domain(xml: &str, state: String) -> Result { uuid: domain.uuid, state, disks, + boot, }) } diff --git a/ublue/skillet/crates/vm/src/creation.rs b/ublue/skillet/crates/vm/src/creation.rs index 560b9dc0..ba742be8 100644 --- a/ublue/skillet/crates/vm/src/creation.rs +++ b/ublue/skillet/crates/vm/src/creation.rs @@ -10,9 +10,9 @@ use std::{ path::Path, }; -/// Define and start a native VM, persisting ownership after each successful +/// Define and start a VM through common libvirt XML, persisting ownership after each successful /// external transition so a retry can inspect and continue safely. -pub fn create_native( +pub fn create_libvirt( store: &ManifestStore, identity: &RunIdentity, backend: &impl VmBackend, @@ -23,15 +23,31 @@ pub fn create_native( store, identity, backend, - Backend::Native, + store.load(identity)?.connection.backend, tool_versions, |run| { - let xml = domain_xml::write_native_domain_xml(store, run, emulator)?; + let xml = domain_xml::write_domain_xml(store, run, emulator)?; backend.define(run, &xml) }, ) } +/// Native-only compatibility entry point using the shared XML owner. +pub fn create_native( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + emulator: &Path, + tool_versions: &str, +) -> Result { + if store.load(identity)?.connection.backend != Backend::Native { + return Err(Error::Invalid( + "native creation requires a native run".into(), + )); + } + create_libvirt(store, identity, backend, emulator, tool_versions) +} + /// Create a Flatpak-backed guest through the caller's focused virt-install /// adapter, then use the same ownership and recovery sequence as native VM /// creation. `virt-install` may start the guest as part of definition. diff --git a/ublue/skillet/crates/vm/src/creation_tests.rs b/ublue/skillet/crates/vm/src/creation_tests.rs index 32a97c3d..f6a2611b 100644 --- a/ublue/skillet/crates/vm/src/creation_tests.rs +++ b/ublue/skillet/crates/vm/src/creation_tests.rs @@ -46,6 +46,7 @@ impl VmBackend for FakeBackend { fn snapshot(run: &VmRun, state: &str) -> DomainSnapshot { DomainSnapshot { + boot: None, name: run.identity.domain_name(), uuid: run.uuid, state: state.into(), diff --git a/ublue/skillet/crates/vm/src/domain_xml.rs b/ublue/skillet/crates/vm/src/domain_xml.rs index 340e8412..61fffea1 100644 --- a/ublue/skillet/crates/vm/src/domain_xml.rs +++ b/ublue/skillet/crates/vm/src/domain_xml.rs @@ -1,4 +1,4 @@ -//! Native libvirt domain XML rendering for a recorded test run. +//! Libvirt domain XML rendering for a recorded test run. use crate::{Backend, Error, ManifestStore, Phase, Result, VmRun}; use quick_xml::{ events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}, @@ -11,21 +11,16 @@ use std::{ path::{Path, PathBuf}, }; -/// Write the native KVM definition beside its owned disk and Ignition files. +/// Write the KVM definition beside its owned disk and Ignition files. /// The output is atomically replaced and never follows a symlink. -pub fn write_native_domain_xml( - store: &ManifestStore, - run: &VmRun, - emulator: &Path, -) -> Result { +pub fn write_domain_xml(store: &ManifestStore, run: &VmRun, emulator: &Path) -> Result { store.validate(run, &run.identity)?; - if run.connection.backend != Backend::Native - || !matches!(run.phase, Phase::Preparing | Phase::Defined) + if !matches!(run.phase, Phase::Preparing | Phase::Defined) || !emulator.is_absolute() - || !emulator.is_file() + || (run.connection.backend == Backend::Native && !emulator.is_file()) { return Err(Error::Invalid( - "native domain XML requires a native preparing run and absolute emulator".into(), + "domain XML requires a preparing run and absolute emulator".into(), )); } let dir = run @@ -43,6 +38,15 @@ pub fn write_native_domain_xml( crate::manifest::reject_symlinks(dir)?; let path = dir.join("domain.xml"); crate::manifest::reject_symlinks(&path)?; + if run.root_profile == crate::install::RootProfile::Tpm { + let serial = dir.join("serial.log"); + crate::manifest::reject_symlinks(&serial)?; + let log = fs::OpenOptions::new() + .create(true) + .append(true) + .open(serial)?; + log.set_permissions(fs::Permissions::from_mode(0o600))?; + } let xml = render(run, emulator)?; let mut output = tempfile::NamedTempFile::new_in(dir)?; output @@ -67,19 +71,47 @@ fn render(run: &VmRun, emulator: &Path) -> Result { text_element_with_attrs(&mut writer, "memory", "8192", &[("unit", "MiB".into())])?; text_element(&mut writer, "vcpu", "2")?; - start(&mut writer, "os", &[])?; + let encrypted = run.root_profile == crate::install::RootProfile::Tpm; + let firmware_attrs = if encrypted { + vec![("firmware", "efi".into())] + } else { + Vec::new() + }; + start(&mut writer, "os", &firmware_attrs)?; + if encrypted { + start(&mut writer, "firmware", &[])?; + for name in ["secure-boot", "enrolled-keys"] { + empty( + &mut writer, + "feature", + &[("enabled", "yes".into()), ("name", name.into())], + )?; + } + end(&mut writer, "firmware")?; + } text_element_with_attrs( &mut writer, "type", "hvm", &[("arch", "x86_64".into()), ("machine", "q35".into())], )?; + if encrypted { + text_element_with_attrs( + &mut writer, + "nvram", + &crate::install::nvram_path(run)?.display().to_string(), + &[("format", "raw".into())], + )?; + } empty(&mut writer, "boot", &[("dev", "hd".into())])?; end(&mut writer, "os")?; start(&mut writer, "features", &[])?; empty(&mut writer, "acpi", &[])?; empty(&mut writer, "apic", &[])?; + if encrypted { + empty(&mut writer, "smm", &[("state", "on".into())])?; + } end(&mut writer, "features")?; empty(&mut writer, "cpu", &[("mode", "host-passthrough".into())])?; @@ -95,7 +127,12 @@ fn render(run: &VmRun, emulator: &Path) -> Result { end(&mut writer, "sysinfo")?; start(&mut writer, "devices", &[])?; - text_element(&mut writer, "emulator", &emulator.display().to_string())?; + if run.connection.backend == Backend::Native { + text_element(&mut writer, "emulator", &emulator.display().to_string())?; + } + if encrypted { + render_tpm(&mut writer, run)?; + } disk(&mut writer, &run.disk, "qcow2", "vda", false)?; disk(&mut writer, &run.ignition, "raw", "vdb", true)?; start(&mut writer, "interface", &[("type", "user".into())])?; @@ -113,22 +150,68 @@ fn render(run: &VmRun, emulator: &Path) -> Result { end(&mut writer, "portForward")?; empty(&mut writer, "model", &[("type", "virtio".into())])?; end(&mut writer, "interface")?; - start(&mut writer, "serial", &[("type", "pty".into())])?; - empty(&mut writer, "target", &[("port", "0".into())])?; - end(&mut writer, "serial")?; - start(&mut writer, "console", &[("type", "pty".into())])?; - empty( - &mut writer, - "target", - &[("type", "serial".into()), ("port", "0".into())], - )?; - end(&mut writer, "console")?; + render_console(&mut writer, run, encrypted)?; end(&mut writer, "devices")?; end(&mut writer, "domain")?; String::from_utf8(writer.into_inner()) .map_err(|_| Error::Invalid("generated libvirt XML was not UTF-8".into())) } +fn render_console(writer: &mut Writer>, run: &VmRun, encrypted: bool) -> Result<()> { + start(writer, "serial", &[("type", "pty".into())])?; + if encrypted { + empty( + writer, + "log", + &[ + ( + "file", + run.disk + .parent() + .ok_or_else(|| Error::Invalid("disk has no parent".into()))? + .join("serial.log") + .display() + .to_string(), + ), + ("append", "on".into()), + ], + )?; + } + empty(writer, "target", &[("port", "0".into())])?; + end(writer, "serial")?; + start(writer, "console", &[("type", "pty".into())])?; + empty( + writer, + "target", + &[("type", "serial".into()), ("port", "0".into())], + )?; + end(writer, "console")?; + Ok(()) +} + +fn render_tpm(writer: &mut Writer>, run: &VmRun) -> Result<()> { + start(writer, "tpm", &[("model", "tpm-crb".into())])?; + start( + writer, + "backend", + &[("type", "emulator".into()), ("version", "2.0".into())], + )?; + empty( + writer, + "source", + &[ + ("type", "dir".into()), + ("path", crate::install::tpm_path(run)?.display().to_string()), + ], + )?; + start(writer, "active_pcr_banks", &[])?; + empty(writer, "sha256", &[])?; + end(writer, "active_pcr_banks")?; + end(writer, "backend")?; + end(writer, "tpm")?; + Ok(()) +} + fn disk( writer: &mut Writer>, path: &Path, diff --git a/ublue/skillet/crates/vm/src/domain_xml_tests.rs b/ublue/skillet/crates/vm/src/domain_xml_tests.rs index 5759c1ea..c9d09915 100644 --- a/ublue/skillet/crates/vm/src/domain_xml_tests.rs +++ b/ublue/skillet/crates/vm/src/domain_xml_tests.rs @@ -26,7 +26,7 @@ fn native_xml_keeps_the_existing_boot_network_and_owned_disk_contract() { fs::create_dir_all(run.ignition.parent().unwrap()).unwrap(); fs::write(&run.ignition, "ignition").unwrap(); - let path = write_native_domain_xml(&store, &run, Path::new("/usr/bin/true")).unwrap(); + let path = write_domain_xml(&store, &run, Path::new("/usr/bin/true")).unwrap(); let xml = fs::read_to_string(path).unwrap(); assert!(xml.contains("")); assert!(xml.contains("")); @@ -38,3 +38,71 @@ fn native_xml_keeps_the_existing_boot_network_and_owned_disk_contract() { .validate_owned(&run) .unwrap(); } + +#[test] +fn encrypted_xml_owns_firmware_and_tpm_and_refuses_other_state() { + let tmp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(tmp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("fixture", "encrypted").unwrap(); + let run = store + .prepare_intent_with_profile( + &identity, + Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir: format!("/run/user/{}", crate::current_uid()).into(), + }, + 2208, + "fixture-revision", + crate::install::RootProfile::Tpm, + ) + .unwrap(); + fs::write(&run.disk, "disk").unwrap(); + fs::create_dir_all(run.ignition.parent().unwrap()).unwrap(); + fs::write(&run.ignition, "ignition").unwrap(); + let path = write_domain_xml(&store, &run, Path::new("/usr/bin/true")).unwrap(); + let xml = fs::read_to_string(path).unwrap(); + let snapshot = parse_domain(&xml, "defined".into()).unwrap(); + snapshot.validate_owned(&run).unwrap(); + assert!(xml.contains("name=\"enrolled-keys\"")); + assert!(xml.contains("")); + assert!(xml.contains("serial.log")); + assert_eq!( + fs::metadata(store.run_dir(&identity).join("serial.log")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let mut flatpak = run.clone(); + flatpak.connection.backend = Backend::Flatpak; + let xml = render(&flatpak, Path::new("/not-a-native-emulator")).unwrap(); + assert!(!xml.contains("")); + parse_domain(&xml, "defined".into()) + .unwrap() + .validate_owned(&flatpak) + .unwrap(); + let modified = xml.replace("/tpm\"", "/other-tpm\""); + assert!(parse_domain(&modified, "defined".into()) + .unwrap() + .validate_owned(&run) + .is_err()); + let mut old_json = serde_json::to_value(&run).unwrap(); + old_json.as_object_mut().unwrap().remove("root_profile"); + let legacy: crate::VmRun = serde_json::from_value(old_json).unwrap(); + assert_eq!( + legacy.root_profile, + crate::install::RootProfile::Unencrypted + ); +} + +#[test] +fn recovery_artifacts_cannot_follow_symlinks() { + let (_tmp, store, identity) = crate::manifest::tests::legacy_run(); + let mut run = store.import(&identity).unwrap(); + run.root_profile = crate::install::RootProfile::Tpm; + let recovery = store.run_dir(&identity).join("recovery"); + std::os::unix::fs::symlink("/tmp", recovery).unwrap(); + assert!(store.validate(&run, &identity).is_err()); +} diff --git a/ublue/skillet/crates/vm/src/firmware.rs b/ublue/skillet/crates/vm/src/firmware.rs new file mode 100644 index 00000000..a076652c --- /dev/null +++ b/ublue/skillet/crates/vm/src/firmware.rs @@ -0,0 +1,77 @@ +//! Capability parsing for explicitly requested UEFI/TPM root installations. +use crate::{Error, Result}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Caps { + os: Os, + devices: Devices, +} +#[derive(Deserialize)] +struct Os { + #[serde(rename = "enum", default)] + enums: Vec, + loader: Loader, +} +#[derive(Deserialize)] +struct Loader { + #[serde(rename = "enum", default)] + enums: Vec, +} +#[derive(Deserialize)] +struct Devices { + tpm: Tpm, +} +#[derive(Deserialize)] +struct Tpm { + #[serde(rename = "@supported")] + supported: String, + #[serde(rename = "enum", default)] + enums: Vec, +} +#[derive(Deserialize)] +struct Enumeration { + #[serde(rename = "@name")] + name: String, + #[serde(rename = "value", default)] + values: Vec, +} +fn has(values: &[Enumeration], name: &str, value: &str) -> bool { + values + .iter() + .any(|e| e.name == name && e.values.iter().any(|v| v == value)) +} +pub fn verify_capabilities(xml: &str, version: &str) -> Result<()> { + let caps: Caps = quick_xml::de::from_str(xml)?; + if !has(&caps.os.enums, "firmware", "efi") + || !has(&caps.os.loader.enums, "secure", "yes") + || caps.devices.tpm.supported != "yes" + || !has(&caps.devices.tpm.enums, "backendModel", "emulator") + || !has(&caps.devices.tpm.enums, "backendVersion", "2.0") + { + return Err(Error::Invalid( + "TPM profile requires q35 UEFI Secure Boot and emulated TPM2 support".into(), + )); + } + let daemon = version + .lines() + .find_map(|line| line.strip_prefix("Running against daemon: ")) + .ok_or_else(|| { + Error::Invalid("cannot establish libvirt daemon version for owned TPM state".into()) + })?; + let numbers = daemon + .trim() + .split('.') + .map(str::parse::) + .collect::, _>>() + .map_err(|_| Error::Invalid("invalid libvirt daemon version".into()))?; + if numbers.len() < 2 || (numbers[0], numbers[1]) < (10, 10) { + return Err(Error::Invalid( + "owned TPM state requires libvirt daemon 10.10 or newer".into(), + )); + } + Ok(()) +} +#[cfg(test)] +#[path = "firmware/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/firmware/tests.rs b/ublue/skillet/crates/vm/src/firmware/tests.rs new file mode 100644 index 00000000..57e77f46 --- /dev/null +++ b/ublue/skillet/crates/vm/src/firmware/tests.rs @@ -0,0 +1,9 @@ +use super::*; +#[test] +fn missing_tpm_never_falls_back() { + let xml="efiyes"; + assert!(verify_capabilities(xml, "Running against daemon: 11.10.0").is_err()); + let xml=xml.replace("supported='no'", "supported='yes'").replace("", "emulator2.0"); + assert!(verify_capabilities(&xml, "Running against daemon: 11.10.0").is_ok()); + assert!(verify_capabilities(&xml, "Running against daemon: 10.9.0").is_err()); +} diff --git a/ublue/skillet/crates/vm/src/install.rs b/ublue/skillet/crates/vm/src/install.rs new file mode 100644 index 00000000..c4622f79 --- /dev/null +++ b/ublue/skillet/crates/vm/src/install.rs @@ -0,0 +1,102 @@ +//! Explicit root installation policy, independent of host and VM identity. +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; +use serde_yml::Value; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RootProfile { + #[default] + Unencrypted, + Tpm, +} + +/// Transform a fresh install definition, never a mounted or populated disk. +pub fn configure_root(config: &mut Value, profile: RootProfile) -> Result<()> { + if profile == RootProfile::Unencrypted { + return Ok(()); + } + let storage = config + .get_mut("storage") + .and_then(Value::as_mapping_mut) + .ok_or_else(|| Error::Invalid("TPM installation requires storage configuration".into()))?; + if storage.contains_key(Value::String("luks".into())) { + return Err(Error::Invalid( + "TPM profile conflicts with existing LUKS configuration".into(), + )); + } + let filesystems = storage + .get_mut(Value::String("filesystems".into())) + .and_then(Value::as_sequence_mut) + .ok_or_else(|| { + Error::Invalid("TPM installation requires an explicit root filesystem".into()) + })?; + let raw_root_count = filesystems + .iter() + .filter(|entry| { + entry.get("device").and_then(Value::as_str) == Some("/dev/disk/by-partlabel/root") + }) + .count(); + if raw_root_count != 1 + || filesystems + .iter() + .any(|entry| entry.get("device").and_then(Value::as_str) == Some("/dev/mapper/root")) + { + return Err(Error::Invalid( + "conflicting root filesystem formatting declarations".into(), + )); + } + let mut roots = filesystems + .iter_mut() + .filter(|entry| entry.get("label").and_then(Value::as_str) == Some("root")); + let root = roots + .next() + .ok_or_else(|| Error::Invalid("root filesystem is absent".into()))?; + if root.get("device").and_then(Value::as_str) != Some("/dev/disk/by-partlabel/root") + || root.get("format").and_then(Value::as_str) != Some("btrfs") + { + return Err(Error::Invalid( + "TPM profile requires the declared Btrfs root partition".into(), + )); + } + let map = root + .as_mapping_mut() + .ok_or_else(|| Error::Invalid("invalid root filesystem".into()))?; + map.insert( + Value::String("device".into()), + Value::String("/dev/mapper/root".into()), + ); + if roots.next().is_some() { + return Err(Error::Invalid("multiple root filesystems declared".into())); + } + let luks = serde_yml::to_value(serde_json::json!([{ + "name": "root", "label": "luks-root", + "device": "/dev/disk/by-partlabel/root", "wipe_volume": true, + "clevis": {"custom": { + "needs_network": false, "pin": "tpm2", + "config": serde_json::json!({"pcr_bank": "sha256", "pcr_ids": "7"}).to_string() + }} + }])) + .map_err(|e| Error::Invalid(format!("invalid built-in TPM profile: {e}")))?; + storage.insert(Value::String("luks".into()), luks); + Ok(()) +} + +pub fn nvram_path(run: &crate::VmRun) -> Result { + Ok(run + .disk + .parent() + .ok_or_else(|| Error::Invalid("disk has no parent".into()))? + .join("nvram.fd")) +} +pub fn tpm_path(run: &crate::VmRun) -> Result { + Ok(run + .disk + .parent() + .ok_or_else(|| Error::Invalid("disk has no parent".into()))? + .join("tpm")) +} + +#[cfg(test)] +#[path = "install/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/install/tests.rs b/ublue/skillet/crates/vm/src/install/tests.rs new file mode 100644 index 00000000..b7bb8ffa --- /dev/null +++ b/ublue/skillet/crates/vm/src/install/tests.rs @@ -0,0 +1,45 @@ +use super::*; +fn source() -> Value { + serde_yml::from_str("storage:\n filesystems:\n - device: /dev/disk/by-partlabel/root\n format: btrfs\n label: root\n wipe_filesystem: true\n").unwrap() +} +#[test] +fn unencrypted_is_preserved_and_tpm_replaces_raw_root() { + let mut value = source(); + let original = value.clone(); + configure_root(&mut value, RootProfile::Unencrypted).unwrap(); + assert_eq!(value, original); + configure_root(&mut value, RootProfile::Tpm).unwrap(); + assert_eq!( + value["storage"]["filesystems"][0]["device"].as_str(), + Some("/dev/mapper/root") + ); + assert_eq!( + value["storage"]["luks"][0]["clevis"]["custom"]["config"].as_str(), + Some("{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"7\"}") + ); + assert!(configure_root(&mut value, RootProfile::Tpm).is_err()); +} +#[test] +fn missing_root_is_an_error_not_unencrypted_fallback() { + let mut value = serde_yml::from_str("storage: {}").unwrap(); + assert!(configure_root(&mut value, RootProfile::Tpm).is_err()); +} + +#[test] +fn conflicting_filesystems_and_preexisting_encryption_are_rejected() { + for extra in [ + "device: /dev/disk/by-partlabel/root\nlabel: other\nformat: xfs", + "device: /dev/mapper/root\nlabel: other\nformat: btrfs", + "device: /dev/other\nlabel: root\nformat: btrfs", + ] { + let mut value = source(); + value["storage"]["filesystems"] + .as_sequence_mut() + .unwrap() + .push(serde_yml::from_str(extra).unwrap()); + assert!(configure_root(&mut value, RootProfile::Tpm).is_err()); + } + let mut value = source(); + value["storage"]["luks"] = Value::Sequence(Vec::new()); + assert!(configure_root(&mut value, RootProfile::Tpm).is_err()); +} diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs index 5ec421a8..a335f8a7 100644 --- a/ublue/skillet/crates/vm/src/lib.rs +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -7,6 +7,8 @@ pub mod creation; pub mod credential; pub mod delivery; pub mod domain_xml; +pub mod firmware; +pub mod install; pub mod lifecycle; pub mod manifest; mod process; diff --git a/ublue/skillet/crates/vm/src/lifecycle/tests.rs b/ublue/skillet/crates/vm/src/lifecycle/tests.rs index c8a45c66..d7c34c7f 100644 --- a/ublue/skillet/crates/vm/src/lifecycle/tests.rs +++ b/ublue/skillet/crates/vm/src/lifecycle/tests.rs @@ -16,6 +16,7 @@ impl FakeBackend { fn new(run: &VmRun, present: bool) -> Self { Self { domain: RefCell::new(present.then(|| DomainSnapshot { + boot: None, name: run.identity.domain_name(), uuid: run.uuid, state: "running".into(), diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs index 9505f17f..a3a4769b 100644 --- a/ublue/skillet/crates/vm/src/manifest.rs +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -109,6 +109,8 @@ pub struct VmRun { pub owner_uid: u32, pub identity: RunIdentity, pub environment: Environment, + #[serde(default)] + pub root_profile: crate::install::RootProfile, pub guest_hostname: String, pub uuid: Uuid, pub connection: Connection, @@ -166,6 +168,23 @@ impl ManifestStore { connection: Connection, ssh_port: u16, source_commit: &str, + ) -> Result { + self.prepare_intent_with_profile( + identity, + connection, + ssh_port, + source_commit, + crate::install::RootProfile::Unencrypted, + ) + } + + pub fn prepare_intent_with_profile( + &self, + identity: &RunIdentity, + connection: Connection, + ssh_port: u16, + source_commit: &str, + root_profile: crate::install::RootProfile, ) -> Result { if !(2200..=2299).contains(&ssh_port) || source_commit.trim().is_empty() { return Err(Error::Invalid( @@ -191,6 +210,7 @@ impl ManifestStore { && existing.connection == connection && existing.ssh.port == ssh_port && existing.source_commit == source_commit + && existing.root_profile == root_profile { return Ok(existing); } @@ -205,6 +225,7 @@ impl ManifestStore { owner_uid: self.owner_uid, identity: identity.clone(), environment: Environment::Test, + root_profile, guest_hostname: identity.domain_name(), uuid: Uuid::new_v4(), connection, @@ -463,6 +484,18 @@ impl ManifestStore { ] { reject_symlinks(path)?; } + if run.root_profile == crate::install::RootProfile::Tpm { + reject_symlinks(&crate::install::nvram_path(run)?)?; + reject_symlinks(&crate::install::tpm_path(run)?)?; + reject_symlinks(&dir.join("serial.log"))?; + for name in [ + "recovery", + "recovery/root-recovery.key", + "recovery/root-luks.header", + ] { + reject_symlinks(&dir.join(name))?; + } + } for hashes in [&run.captured, &run.deployed].into_iter().flatten() { if !valid_hash(&hashes.host) || !valid_hash(&hashes.generic) { return Err(Error::Invalid("artifact hashes must be SHA256".into())); @@ -511,6 +544,7 @@ impl ManifestStore { owner_uid: self.owner_uid, identity: identity.clone(), environment: Environment::Test, + root_profile: crate::install::RootProfile::Unencrypted, guest_hostname: identity.domain_name(), uuid, connection: Connection { diff --git a/ublue/skillet/crates/vm/src/readiness.rs b/ublue/skillet/crates/vm/src/readiness.rs index c67c312f..620c4910 100644 --- a/ublue/skillet/crates/vm/src/readiness.rs +++ b/ublue/skillet/crates/vm/src/readiness.rs @@ -118,7 +118,8 @@ pub fn ready( command.extend(arguments); checked(transport, "sudo", &command)?; } - verify(run, run_dir, policy, transport) + verify(run, run_dir, policy, transport)?; + verify_root_profile(run, transport) })(); if result.is_err() && (io.ownership)().is_ok() { // Diagnostics are best effort; never replace the original failure. @@ -131,6 +132,58 @@ pub fn ready( store.save(run) } +fn verify_root_profile(run: &VmRun, transport: &impl GuestTransport) -> Result<()> { + if run.root_profile == crate::install::RootProfile::Unencrypted { + return Ok(()); + } + let secure = checked(transport, "sudo", &["-n", "mokutil", "--sb-state"])?; + let status = checked(transport, "sudo", &["-n", "cryptsetup", "status", "root"])?; + let source = checked(transport, "findmnt", &["-T", "/var", "-n", "-o", "SOURCE"])?; + let binding = checked( + transport, + "sudo", + &[ + "-n", + "clevis", + "luks", + "list", + "-d", + "/dev/disk/by-partlabel/root", + ], + )?; + let secure = String::from_utf8_lossy(&secure); + let status = String::from_utf8_lossy(&status); + let source = String::from_utf8_lossy(&source); + let binding = String::from_utf8_lossy(&binding); + if !secure.lines().any(|line| line == "SecureBoot enabled") + || !status.contains("LUKS2") + || !source.trim().starts_with("/dev/mapper/root") + { + return Err(Error::Invalid( + "encrypted readiness requires active Secure Boot and mapper-backed LUKS2 root".into(), + )); + } + let entries: Vec<_> = binding + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + if entries.len() != 1 { + return Err(Error::Invalid( + "root must have exactly one Clevis TPM binding".into(), + )); + } + let (_, policy) = entries[0] + .split_once("tpm2 ") + .ok_or_else(|| Error::Invalid("unexpected root unlock pin".into()))?; + let policy: serde_json::Value = serde_json::from_str(policy.trim().trim_matches('\''))?; + if policy["pcr_bank"] != "sha256" || policy["pcr_ids"] != "7" { + return Err(Error::Invalid( + "root unlock policy is not the selected SHA256 PCR7 policy".into(), + )); + } + Ok(()) +} + fn wait( run: &VmRun, policy: &ReadinessPolicy, diff --git a/ublue/skillet/crates/vm/src/readiness/tests.rs b/ublue/skillet/crates/vm/src/readiness/tests.rs index 39f91ee5..0b2ac3d7 100644 --- a/ublue/skillet/crates/vm/src/readiness/tests.rs +++ b/ublue/skillet/crates/vm/src/readiness/tests.rs @@ -109,6 +109,10 @@ impl GuestTransport for FakeGuest { ("systemctl", ["restart" | "start" | "reset-failed", _rest @ ..]) => { return Ok(output(i32::from(self.fail_unit), "")); } + ("mokutil", ["--sb-state"]) => if self.fault == "secure-boot" { "SecureBoot disabled\n" } else { "SecureBoot enabled\n" }.into(), + ("cryptsetup", ["status", "root"]) => "type: LUKS2\n".into(), + ("findmnt", ["-T", "/var", "-n", "-o", "SOURCE"]) => "/dev/mapper/root[/ostree/deploy/default/var]\n".into(), + ("clevis", ["luks", "list", "-d", "/dev/disk/by-partlabel/root"]) => if self.fault == "pcr" { "0: tpm2 '{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"0\"}'\n" } else { "0: tpm2 '{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"7\"}'\n" }.into(), ("rpm-ostree", ["status", "--json"]) => serde_json::json!({"deployments":[{"booted":true,"container-image-reference":self.origin}]}).to_string(), ("rpm-ostree", ["status"]) => "Signed deployment\n".into(), ("journalctl", _) => "Sanitized fixture journal\n".into(), @@ -459,3 +463,16 @@ fn ownership_loss_after_long_brew_operation_stops_before_dotfiles_start() { })); assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Started); } + +#[test] +fn encrypted_profile_requires_secure_boot_and_selected_pcr_policy() { + for fault in ["none", "secure-boot", "pcr"] { + let (_tmp, _store, mut run, _policy) = fixture(); + run.root_profile = crate::install::RootProfile::Tpm; + let guest = FakeGuest { + fault, + ..FakeGuest::default() + }; + assert_eq!(verify_root_profile(&run, &guest).is_ok(), fault == "none"); + } +} diff --git a/ublue/skillet/crates/vm/src/staging.rs b/ublue/skillet/crates/vm/src/staging.rs index f54670f8..6c7730c2 100644 --- a/ublue/skillet/crates/vm/src/staging.rs +++ b/ublue/skillet/crates/vm/src/staging.rs @@ -76,6 +76,7 @@ fn stage_config( find_file(&mut host_config, "/etc/hostname")?, &run.guest_hostname, )?; + crate::install::configure_root(&mut host_config, run.root_profile)?; write_yaml_if_changed(&config_target, &host_config)?; for entry in fs::read_dir(includes)? { @@ -322,6 +323,12 @@ fn set_inline(file: &mut Mapping, inline: &str) -> Result<()> { } fn remove_file(root: &mut Value, path: &str) -> Result<()> { + if root + .get("storage") + .is_none_or(|value| value.is_mapping() && value.get("files").is_none()) + { + return Ok(()); + } let files = sequence_at_mut(root, &["storage", "files"])?; files.retain(|value| { value @@ -333,6 +340,12 @@ fn remove_file(root: &mut Value, path: &str) -> Result<()> { } fn remove_unit(root: &mut Value, name: &str) -> Result<()> { + if root + .get("systemd") + .is_none_or(|value| value.is_mapping() && value.get("units").is_none()) + { + return Ok(()); + } let units = sequence_at_mut(root, &["systemd", "units"])?; units.retain(|value| { value @@ -366,7 +379,18 @@ fn add_authorized_key(root: &mut Value, key: &str) -> Result<()> { } fn add_vm_sudoers(root: &mut Value) -> Result<()> { - let files = sequence_at_mut(root, &["storage", "files"])?; + let storage = root + .as_mapping_mut() + .ok_or_else(|| Error::Invalid("Butane root is not a mapping".into()))? + .entry(Value::String("storage".into())) + .or_insert_with(|| Value::Mapping(Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| Error::Invalid("Butane storage is not a mapping".into()))?; + let files = storage + .entry(Value::String("files".into())) + .or_insert_with(|| Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or_else(|| Error::Invalid("Butane files are not a sequence".into()))?; if files.iter().any(|value| { value .as_mapping() diff --git a/ublue/skillet/crates/vm/src/staging_tests.rs b/ublue/skillet/crates/vm/src/staging_tests.rs index d8b2c99f..65a6eec8 100644 --- a/ublue/skillet/crates/vm/src/staging_tests.rs +++ b/ublue/skillet/crates/vm/src/staging_tests.rs @@ -220,3 +220,30 @@ fn staging_is_idempotent_and_refuses_progressed_runs_or_linked_sources() { ) .is_err()); } + +#[test] +fn staging_accepts_account_only_passwd_includes_and_tpm_root() { + let (_tmp, store, mut run, config, includes, host, generic, image) = fixture(); + fs::write(includes.join("passwd.bu"),"variant: fcos\nversion: 1.6.0\npasswd:\n users:\n - name: giacomo\n ssh_authorized_keys: []\n").unwrap(); + let mut value: Value = serde_yml::from_str(&fs::read_to_string(&config).unwrap()).unwrap(); + value["storage"].as_mapping_mut().unwrap().insert(Value::String("filesystems".into()),serde_yml::from_str("- device: /dev/disk/by-partlabel/root\n format: btrfs\n label: root\n wipe_filesystem: true\n").unwrap()); + fs::write(&config, serde_yml::to_string(&value).unwrap()).unwrap(); + run.root_profile = crate::install::RootProfile::Tpm; + store.save(&run).unwrap(); + for _ in 0..2 { + stage_butane_source(&store, &run, &config, &includes, &host, &generic, &image).unwrap(); + } + let path = store.run_dir(&run.identity).join("source/clamps.bu"); + let staged: Value = serde_yml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!( + staged["storage"]["filesystems"][0]["device"].as_str(), + Some("/dev/mapper/root") + ); + let passwd = fs::read_to_string( + store + .run_dir(&run.identity) + .join("source/includes/passwd.bu"), + ) + .unwrap(); + assert!(passwd.contains("90-skillet-vm")); +} diff --git a/ublue/skillet/integration_tests/tpm-root.sh b/ublue/skillet/integration_tests/tpm-root.sh new file mode 100644 index 00000000..1edd2f03 --- /dev/null +++ b/ublue/skillet/integration_tests/tpm-root.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Guest assertions only. Rust owns SSH, resource ownership, and power cycling. +set -euo pipefail +umask 077 +device=/dev/disk/by-partlabel/root +state=/run/skillet-encryption-test +case "${1:-}" in + prepare) + mkdir -p "$state" + cat > "$state/recovery.key" + [[ "$(stat -c %s "$state/recovery.key")" == 64 ]] + if ! cryptsetup open --test-passphrase --key-file "$state/recovery.key" "$device"; then + slot="$(clevis luks list -d "$device" | awk 'NR == 1 {sub(/:$/, "", $1); print $1}')" + [[ "$slot" =~ ^[0-9]+$ ]] + clevis luks pass -d "$device" -s "$slot" | + cryptsetup luksAddKey --key-file=- "$device" "$state/recovery.key" + fi + rm -f "$state/luks.header" + cryptsetup luksHeaderBackup "$device" --header-backup-file "$state/luks.header" + printf '%s\n' 'skillet encrypted-root persistence' > /var/lib/data/.skillet-encryption-test + sync -f /var/lib/data/.skillet-encryption-test + ;; + verify) + [[ "$(cat /var/lib/data/.skillet-encryption-test)" == 'skillet encrypted-root persistence' ]] + mokutil --sb-state | grep -Fx 'SecureBoot enabled' + cryptsetup status root | grep -F 'LUKS2' + [[ "$(findmnt -T /var -n -o SOURCE)" == /dev/mapper/root* ]] + [[ "$(findmnt -M /var/lib/data -n -o UUID)" == "$(findmnt -M /var -n -o UUID)" ]] + [[ "$(findmnt -T /var -n -o FSTYPE)" == btrfs ]] + [[ "$(findmnt -M /var/lib/data -n -o FSROOT)" == /data ]] + [[ "$(blkid -s UUID -o value /dev/disk/by-label/root)" == "$(findmnt -T /var -n -o UUID)" ]] + cryptsetup open --test-passphrase --key-file=- "$device" + ;; + *) exit 2 ;; +esac From b65777dec37d80d137cfc1d64cc93450cafcafb5 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 10 Oct 2026 17:16:11 +0200 Subject: [PATCH 236/252] feat(storage): archive verified root recovery keys in KeePassXC --- SECRETS.md | 7 +- ublue/AGENTS.md | 3 + ublue/butane/ACCEPTANCE.md | 18 ++ ublue/butane/README.md | 5 + ublue/butane/bin/test-vm | 16 +- ublue/design/secrets.md | 11 + ublue/design/smoke-vms.md | 6 + ublue/design/storage.md | 7 +- ublue/plan/TPM-ENCRYPTED-ROOT.md | 5 +- ublue/skillet/Cargo.lock | 1 + ublue/skillet/README.md | 38 ++++ ublue/skillet/crates/cli/src/main.rs | 33 +++ ublue/skillet/crates/cli/src/root_recovery.rs | 92 ++++++++ .../crates/cli/src/root_recovery_tests.rs | 63 +++++ ublue/skillet/crates/cli/src/vm.rs | 7 + ublue/skillet/crates/workstation/Cargo.toml | 2 + ublue/skillet/crates/workstation/src/lib.rs | 2 + .../crates/workstation/src/root_recovery.rs | 215 ++++++++++++++++++ .../workstation/src/root_recovery/tests.rs | 194 ++++++++++++++++ .../workstation/src/secret-requirements.json | 4 +- .../skillet/crates/workstation/src/secrets.rs | 17 +- .../crates/workstation/src/secrets/tests.rs | 18 ++ ublue/skillet/crates/workstation/src/vault.rs | 13 ++ .../crates/workstation/src/vault/tests.rs | 80 +++++++ ublue/skillet/scripts/root-recovery.sh | 22 ++ 25 files changed, 866 insertions(+), 13 deletions(-) create mode 100644 ublue/skillet/crates/cli/src/root_recovery.rs create mode 100644 ublue/skillet/crates/cli/src/root_recovery_tests.rs create mode 100644 ublue/skillet/crates/workstation/src/root_recovery.rs create mode 100644 ublue/skillet/crates/workstation/src/root_recovery/tests.rs create mode 100644 ublue/skillet/scripts/root-recovery.sh diff --git a/SECRETS.md b/SECRETS.md index 05876a0e..484db1f2 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -69,13 +69,14 @@ Production-only native Postfix relay credentials; smoke machines use a local sin | --- | --- | --- | | `skillet/smtp` | Required | Create one entry using your Mailjet SMTP credentials: Username = SMTP username; Password = SMTP password. Custom fields: host = in-v3.mailjet.com; port = 587; tls = starttls (required TLS with certificate verification); sender = a Mailjet-verified sender email address. [Mailjet credentials](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters). | -## Storage (planned) +## Storage -No entry required today. Keep recovery material off the encrypted host. [Encryption plan](ublue/plan/TPM-ENCRYPTED-ROOT.md). +Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet/README.md#root-recovery-keys). Password contains the printable passphrase; custom luks-uuid and recovery-state fields are managed by Skillet. | Entry | Status | Fields / how to obtain them | | --- | --- | --- | -| `skillet/hosts//storage/root-recovery-key` | Planned | LUKS recovery key; prepare through the future TPM/LUKS workflow. | +| `skillet/hosts//storage/root-recovery-key` | Generated | Run skillet secrets root-recovery-key before finishing production provisioning. The command unlocks the vault, saves before enrollment, and verifies the key against this volume. | +| `skillet/environments/test/hosts//instances//storage/root-recovery-key` | Generated | Run skillet test vm recovery-key HOST INSTANCE. Archived test keys remain in the vault after VM disposal; delete the instance entry before reusing its name for a new disk. | ## Syncthing diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 9899e1f2..46fd74f4 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -113,6 +113,9 @@ - File and directory mutations use the same typed ownership contract for named and numeric UID/GID values. Numeric ownership must not require a host account lookup; preserve omitted metadata fields on existing paths. +- Persist independently usable recovery credentials before enrolling them. Bind + recovery records to the resource identity, retain pending state for retries, + and refuse silent replacement or re-enrollment of verified credentials. - Persist ownership before mutations and preserve it across partial failures. Cleanup must tolerate already-absent owned resources while refusing ambiguous or unrelated resources. Remove recovery metadata only after cleanup completes. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 5d17cfa9..945bc196 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,21 @@ +## Recovery-key vault commands — 2026-10-10 + +- Implemented `secrets root-recovery-key HOST` for production and lifecycle-owned + `test vm recovery-key HOST INSTANCE` for separately scoped vault archives. + TPM VM creation prints the follow-up. Vault unlock uses the existing prompt/cache. +- Unit tests cover interrupted pending enrollment, reuse without rotation, + refusal of mismatched host/volume, persistence failure before enrollment, + verify-only failure for previously verified keys, CLI argument separation, + instance-aware unused-entry auditing, and real encrypted KDBX round trips + preserving recovery identity/state through atomic saves. +- Local fmt, pedantic Clippy, workspace tests, CI ShellCheck and Fedora base/repeat + apply passed. A private regular-file LUKS2 fixture accepted and verified the + 128-character printable recovery passphrase; no real host disks were touched. +- Not run: new SSH-to-TPM-VM vault archive acceptance or production enrollment. + No production vault key was created. Header export and physical recovery remain + pending. This slice does not simulate TPM loss or replace the earlier disposable + cold-boot acceptance; the two enrollment commands have separate key lifetimes. + ## Native optional TPM root — 2026-10-10 - A fresh `clamps / tpm-native` disposable VM used native `qemu:///session`, diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 71bb4f20..99f4824c 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -252,3 +252,8 @@ See the [storage design](../design/storage.md) and [encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md) for policy and remaining acceptance. PCR 7 does not measure every boot component; this profile has the accepted encrypted-disk protection scope. + +TPM creation prints `skillet test vm recovery-key HOST INSTANCE` to optionally +archive a separately scoped printable passphrase in KeePassXC. Production uses +`skillet secrets root-recovery-key HOST` with explicit verified SSH options, +before access finalization. See [recovery key usage](../skillet/README.md#root-recovery-keys). diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm index f4bd5522..7c583b97 100755 --- a/ublue/butane/bin/test-vm +++ b/ublue/butane/bin/test-vm @@ -7,7 +7,7 @@ script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" repo_dir="$(realpath "$script_dir/../..")" usage() { - echo "Usage: $0 [HOST] list | $0 HOST create|ready|update|status|ssh|logs|reboot|encryption-check|destroy INSTANCE [--port PORT] [--image IMAGE] [--root-profile unencrypted|tpm]" >&2 + echo "Usage: $0 [HOST] list | $0 HOST create|ready|update|status|ssh|logs|reboot|encryption-check|recovery-key|destroy INSTANCE [--port PORT] [--image IMAGE] [--root-profile unencrypted|tpm]" >&2 exit 2 } @@ -46,6 +46,20 @@ case "$operation" in done skillet_cli "${args[@]}" ;; + recovery-key) + args=(test vm recovery-key "$host" "$instance") + while (($#)); do + case "$1" in + --database|--key-file) + (($# >= 2)) || usage + args+=("$1" "$2") + shift 2 + ;; + *) usage ;; + esac + done + skillet_cli "${args[@]}" + ;; ready|update|status|ssh|logs|reboot|encryption-check|destroy) (($# == 0)) || usage skillet_cli test vm "$operation" "$host" "$instance" diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index 9256a23d..ba37f013 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -194,3 +194,14 @@ References: [KeePassXC](https://keepassxc.org/docs/KeePassXC_GettingStarted), [Rust KDBX reader](https://github.com/sseemayer/keepass-rs), [Cloudflare token creation](https://developers.cloudflare.com/fundamentals/api/how-to/create-via-api/), [systemd credentials](https://github.com/systemd/systemd/blob/main/docs/CREDENTIALS.md). + +Root recovery uses an independent printable high-entropy passphrase in Password, +with protected `luks-uuid` and `recovery-state` metadata. Save a pending entry +atomically before enrollment; verify against the same volume before marking it +verified. Retries reuse pending keys; verified keys are never silently re-enrolled +or replaced. Production paths are `skillet/hosts//storage/root-recovery-key`; +VM archives use `skillet/environments/test/hosts//instances//storage/root-recovery-key`. +VM disposal retains vault archives deliberately, so new disks require an explicit +archive removal or a fresh instance name. Keys travel only through verified SSH +stdin and a restricted guest `/run` file removed after use. This workflow requires +provisioning-time noninteractive sudo and precedes access finalization. diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index dbeb361d..59880a42 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -178,3 +178,9 @@ so interruption is retryable. Test secrets are private ignored files, never vault production entries, command arguments or output. A cold power cycle checks unattended unlock and Btrfs data persistence. This validates guest functionality, not security against a compromised hypervisor. + +The optional `recovery-key` command archives an independent printable passphrase +in an instance-scoped vault entry through the same lock and guarded SSH transport. +It never writes production entries. Archives survive disposal; reusing an instance +name for a new volume fails until its old entry is explicitly removed. This is +separate from `encryption-check`'s private disposable two-slot acceptance fixture. diff --git a/ublue/design/storage.md b/ublue/design/storage.md index 72fe1ae9..7905cf45 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -73,9 +73,10 @@ QEMU success with its enrolled firmware keys does not replace that procedure. Disposable acceptance creates an independent random recovery key and header backup under the private run directory, then checks recovery without TPM and cold-boots through the lifecycle owner. Only TPM and recovery keyslots are -accepted. These artifacts are deleted on disposal. Production vault enrollment, -physical-disk selection and physical recovery acceptance remain planned; -Skillet convergence never formats an installed filesystem. +accepted. These artifacts are deleted on disposal. Production vault enrollment +is implemented with write-ahead persistence and volume identity checks. +Physical-disk selection, header export and physical recovery acceptance remain +planned. Skillet convergence never formats an installed filesystem. QEMU functional tests and physical acceptance are specified in the [TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md index a7d4bbfe..2e5e1a7c 100644 --- a/ublue/plan/TPM-ENCRYPTED-ROOT.md +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -17,7 +17,10 @@ mapper-safe Btrfs mounts, common UEFI/TPM XML, capability preflight, owned TPM/NVRAM disposal, private serial logging, profile-aware readiness and the repeatable independent-key/header/cold-boot acceptance command. Routine CI covers rendering, prerequisites, staging and policy rejection without a vault. -Production recovery-key vault persistence and physical installation are pending. +Production recovery-key vault persistence is implemented with atomic pending/verified +state, volume identity validation, and a separate VM archive command. Live +acceptance of this new vault workflow, production header export and physical +installation remain pending. ## Scope and decisions diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 230ecd8f..5ae0fc69 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2565,6 +2565,7 @@ dependencies = [ "skillet_vm", "tempfile", "thiserror 1.0.69", + "zeroize", ] [[package]] diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 325204df..1a13bba3 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -606,3 +606,41 @@ See the [storage design](../design/storage.md) and [encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md) for policy and remaining acceptance. PCR 7 does not measure every boot component; this profile has the accepted encrypted-disk protection scope. + +## Root recovery keys + +Before finalizing production access (while `sudo -n` still works), enroll and +save an independent printable LUKS recovery passphrase: + +```bash +cargo run --release -p skillet -- secrets root-recovery-key clamps \ + --target giacomo@HOST --identity /absolute/path/to/key \ + --known-hosts /absolute/path/to/known_hosts +``` + +This opens the normal vault unlock prompt unless its password is cached. +Optional `--database` and `--key-file` select another vault. SSH host keys must +already be recorded. The Password at +`skillet/hosts/clamps/storage/root-recovery-key` is the passphrase to enter at +LUKS recovery. Skillet generates it, saves it before enrollment, and verifies +it against the running root volume. Repeat runs verify the existing key; +a different volume is rejected. No key is printed or written to workstation +plaintext files. Back up the vault off the host before relying on it. + +For a TPM disposable VM, creation prints this follow-up: + +```bash +cargo run --release -p skillet -- test vm recovery-key clamps encrypted +# Equivalent wrapper: +../butane/bin/test-vm clamps recovery-key encrypted +``` + +Its archived entry is separate: +`skillet/environments/test/hosts/clamps/instances/encrypted/storage/root-recovery-key`. +Disposal retains this vault entry; remove it before reusing the instance name +for a different disk. This command enrolls an additional passphrase; it does +not export the TPM-bound volume key. The separate `encryption-check` uses its +own disposable key and enforces exactly two slots; use a fresh instance for +that acceptance scenario rather than combining both enrollment workflows. +Header backup/export and physical recovery acceptance remain pending. +See [storage decisions](../design/storage.md). diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 7e6286a8..85bbf29d 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -5,6 +5,7 @@ use std::{fs, io::Write as _, path::PathBuf, process::Command}; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; +mod root_recovery; mod secret_delivery; mod secret_output; mod vm; @@ -66,8 +67,31 @@ enum SecretCommands { Unlock(SecretUnlockArgs), /// Remove the cached vault password from the kernel keyring Lock(SecretLockArgs), + /// Enroll and save a production recovery passphrase + RootRecoveryKey(RootRecoveryArgs), } +#[derive(clap::Args, Debug)] +struct RootRecoveryArgs { + hostname: String, + #[command(flatten)] + vault: SecretUnlockArgs, + #[arg(long)] + target: String, + #[arg(long, default_value_t = 22)] + port: u16, + #[arg(long)] + identity: PathBuf, + #[arg(long)] + known_hosts: PathBuf, +} +#[derive(clap::Args, Debug)] +struct VmRecoveryArgs { + #[command(flatten)] + target: VmTargetArgs, + #[command(flatten)] + vault: SecretUnlockArgs, +} #[derive(clap::Args, Debug)] struct SecretCheckArgs { /// Check requirements for this host; defaults to all declared profiles @@ -150,6 +174,8 @@ enum TestCommands { enum VmCommands { /// Verify encrypted root, save disposable recovery artifacts and cold-boot it EncryptionCheck(VmTargetArgs), + /// Save this instance recovery passphrase in `KeePassXC` + RecoveryKey(VmRecoveryArgs), /// Provision a disposable host VM and wait for it to become ready Create(VmCreateArgs), /// Destroy a disposable host VM and remove its temporary key and artifacts @@ -333,6 +359,9 @@ fn main() -> Result<()> { Commands::Secret { command: SecretCommands::Unlock(args), } => secret_delivery::unlock_vault(args.database.as_deref(), args.key_file.as_deref())?, + Commands::Secret { + command: SecretCommands::RootRecoveryKey(args), + } => root_recovery::production(&args)?, Commands::Secret { command: SecretCommands::Lock(args), } => secret_delivery::lock_vault(args.database.as_deref())?, @@ -382,6 +411,7 @@ fn main() -> Result<()> { fn run_vm_command(command: VmCommands) -> Result<()> { match command { VmCommands::EncryptionCheck(args) => vm_encryption::check(&args)?, + VmCommands::RecoveryKey(args) => root_recovery::vm(&args)?, VmCommands::Create(args) => run_vm_create(&args)?, VmCommands::Destroy(args) => run_vm_destroy(&args)?, VmCommands::List(args) => run_vm_list(&args)?, @@ -1454,3 +1484,6 @@ mod application_acceptance_tests; #[cfg(test)] mod vm_ssh_tests; + +#[cfg(test)] +mod root_recovery_tests; diff --git a/ublue/skillet/crates/cli/src/root_recovery.rs b/ublue/skillet/crates/cli/src/root_recovery.rs new file mode 100644 index 00000000..44829789 --- /dev/null +++ b/ublue/skillet/crates/cli/src/root_recovery.rs @@ -0,0 +1,92 @@ +//! CLI boundaries for production and lifecycle-owned disposable recovery. +use super::{butane_root, workspace_root, RootRecoveryArgs, SecretUnlockArgs, VmRecoveryArgs}; +use anyhow::{anyhow, Result}; +use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + transport::{GuestTransport, HostKeyPolicy, OwnershipCheckedTransport, SshTransport}, + ManifestStore, Phase, RunIdentity, SshTarget, +}; +use skillet_workstation::{ + root_recovery::{self, VaultStore}, + vault::Vault, +}; +pub(super) fn production(args: &RootRecoveryArgs) -> Result<()> { + if skillet_hosts::profile_for_name(&args.hostname).is_none() { + return Err(anyhow!("unknown host profile")); + } + let (user, address) = args + .target + .split_once('@') + .ok_or_else(|| anyhow!("SSH target must be USER@HOST"))?; + let guest = SshTransport::new( + SshTarget { + user: user.into(), + address: address.into(), + port: args.port, + identity: args.identity.clone(), + known_hosts: args.known_hosts.clone(), + }, + HostKeyPolicy::Verify, + )?; + save( + &args.hostname, + &format!("skillet/hosts/{}/storage/root-recovery-key", args.hostname), + &args.vault, + &guest, + ) +} +pub(super) fn vm(args: &VmRecoveryArgs) -> Result<()> { + let butane = butane_root()?; + let id = RunIdentity::new(&args.target.hostname, &args.target.instance)?; + let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _lock = store.lock(&id)?; + let run = store.load(&id)?; + if run.phase != Phase::Ready || run.root_profile != skillet_vm::install::RootProfile::Tpm { + return Err(anyhow!("recovery requires a ready TPM-profile VM")); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let ownership = || { + backend + .inspect(&run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned VM is absent".into()))? + .validate_owned(&run) + }; + ownership()?; + let ssh = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + let guest = OwnershipCheckedTransport::new(&ssh, &ownership); + save( + id.host(), + &format!( + "skillet/environments/test/hosts/{}/instances/{}/storage/root-recovery-key", + id.host(), + args.target.instance + ), + &args.vault, + &guest, + ) +} +fn save( + host: &str, + path: &str, + args: &SecretUnlockArgs, + guest: &impl GuestTransport, +) -> Result<()> { + let database = match &args.database { + Some(path) => path.clone(), + None => Vault::default_path()?, + }; + let mut vault = Vault::open(&database, args.key_file.as_deref())?; + root_recovery::provision( + host, + path, + &workspace_root()?.join("scripts/root-recovery.sh"), + &mut VaultStore { + vault: &mut vault, + key_file: args.key_file.as_deref(), + }, + guest, + root_recovery::generate_key, + )?; + println!("Saved and verified recovery passphrase in KeePassXC: {path}"); + Ok(()) +} diff --git a/ublue/skillet/crates/cli/src/root_recovery_tests.rs b/ublue/skillet/crates/cli/src/root_recovery_tests.rs new file mode 100644 index 00000000..87355b95 --- /dev/null +++ b/ublue/skillet/crates/cli/src/root_recovery_tests.rs @@ -0,0 +1,63 @@ +use super::*; +#[test] +fn production_recovery_requires_explicit_verified_ssh_target() { + assert!(Args::try_parse_from(["skillet", "secrets", "root-recovery-key", "clamps"]).is_err()); + let parsed = Args::try_parse_from([ + "skillet", + "secrets", + "root-recovery-key", + "clamps", + "--target", + "admin@host", + "--identity", + "/tmp/key", + "--known-hosts", + "/tmp/known", + "--database", + "/tmp/vault", + ]) + .unwrap(); + let Commands::Secret { + command: SecretCommands::RootRecoveryKey(args), + } = parsed.command + else { + panic!("wrong command"); + }; + assert_eq!(args.port, 22); + assert_eq!(args.vault.database, Some(PathBuf::from("/tmp/vault"))); +} +#[test] +fn disposable_recovery_keeps_instance_distinct_and_loads_target_from_owner() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "vm", + "recovery-key", + "clamps", + "encrypted", + "--database", + "/tmp/vault", + ]) + .unwrap(); + let Commands::Test { + command: TestCommands::Vm { + command: VmCommands::RecoveryKey(args), + }, + } = parsed.command + else { + panic!("wrong command"); + }; + assert_eq!(args.target.hostname, "clamps"); + assert_eq!(args.target.instance, "encrypted"); + assert!(Args::try_parse_from([ + "skillet", + "test", + "vm", + "recovery-key", + "clamps", + "encrypted", + "--target", + "admin@arbitrary" + ]) + .is_err()); +} diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs index 2ead36b2..04994e05 100644 --- a/ublue/skillet/crates/cli/src/vm.rs +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -103,6 +103,13 @@ pub(super) fn create(args: &VmCreateArgs) -> Result<()> { args.port, run.ssh.identity.display() ); + if run.root_profile == skillet_vm::install::RootProfile::Tpm { + println!( + "Recovery key backup pending. Run: skillet test vm recovery-key {} {}", + identity.host(), + args.instance + ); + } Ok(()) } diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml index cf4c8795..60e0fedf 100644 --- a/ublue/skillet/crates/workstation/Cargo.toml +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -24,3 +24,5 @@ skillet_hosts.workspace = true skillet_vm.workspace = true tempfile.workspace = true thiserror.workspace = true + +zeroize = "1.9" diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index e6dd79d8..579454c5 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -13,3 +13,5 @@ pub mod tailscale_enrollment; pub mod ui_provisioning; pub mod vault; pub mod zone_token; + +pub mod root_recovery; diff --git a/ublue/skillet/crates/workstation/src/root_recovery.rs b/ublue/skillet/crates/workstation/src/root_recovery.rs new file mode 100644 index 00000000..d0338eee --- /dev/null +++ b/ublue/skillet/crates/workstation/src/root_recovery.rs @@ -0,0 +1,215 @@ +//! Portable LUKS recovery enrollment with write-ahead vault persistence. +use crate::vault::{Vault, VaultError}; +use skillet_vm::transport::{GuestCommand, GuestTransport}; +use std::{io::Read as _, path::Path}; +use thiserror::Error; +use zeroize::Zeroizing; + +#[derive(Debug, Error)] +pub enum RecoveryError { + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), + #[error("root recovery: {0}")] + Invalid(&'static str), + #[error("recovery-key randomness is unavailable: {0}")] + Randomness(#[from] std::io::Error), +} + +pub struct Record { + pub key: Zeroizing, + pub volume: String, + pub verified: bool, +} + +/// Cohesive persistence of a key and its enrollment/recovery identity. +pub trait RecoveryStore { + fn load(&self, path: &str) -> Result, RecoveryError>; + fn save(&mut self, path: &str, record: &Record) -> Result<(), RecoveryError>; +} + +pub struct VaultStore<'a> { + pub vault: &'a mut Vault, + pub key_file: Option<&'a Path>, +} +impl RecoveryStore for VaultStore<'_> { + fn load(&self, path: &str) -> Result, RecoveryError> { + let Some(key) = self.vault.get(path)? else { + return Ok(None); + }; + let volume = self + .vault + .get_field(path, "luks-uuid")? + .ok_or(RecoveryError::Invalid( + "entry lacks luks-uuid; refusing to replace it", + ))?; + let state = self + .vault + .get_field(path, "recovery-state")? + .ok_or(RecoveryError::Invalid("entry lacks recovery-state"))?; + if !matches!(state.as_str(), "pending" | "verified") { + return Err(RecoveryError::Invalid("invalid recovery-state")); + } + Ok(Some(Record { + key: Zeroizing::new(key), + volume, + verified: state == "verified", + })) + } + fn save(&mut self, path: &str, record: &Record) -> Result<(), RecoveryError> { + self.vault.ensure_unchanged()?; + if self.vault.get(path)?.is_none() { + self.vault.insert(path, &record.key)?; + } + self.vault.set_field(path, "luks-uuid", &record.volume)?; + self.vault.set_field( + path, + "recovery-state", + if record.verified { + "verified" + } else { + "pending" + }, + )?; + self.vault.save_verified(self.key_file, path, &record.key)?; + Ok(()) + } +} + +pub fn generate_key() -> Result { + let mut bytes = Zeroizing::new([0u8; 64]); + std::fs::File::open("/dev/urandom")?.read_exact(&mut *bytes)?; + Ok(hex::encode(bytes.as_slice())) +} + +/// Persist before enrollment; interrupted pending enrollment safely reuses its key. +pub fn provision( + host: &str, + path: &str, + helper: &Path, + store: &mut impl RecoveryStore, + guest: &impl GuestTransport, + generate: impl FnOnce() -> Result, +) -> Result<(), RecoveryError> { + if text(guest, "cat", &["/etc/skillet/host"])? != host { + return Err(RecoveryError::Invalid( + "SSH host identity differs from selected host", + )); + } + let status = text(guest, "sudo", &["-n", "cryptsetup", "status", "root"])?; + let source = text(guest, "findmnt", &["-T", "/var", "-n", "-o", "SOURCE"])?; + if !status.contains("LUKS2") + || !(source == "/dev/mapper/root" || source.starts_with("/dev/mapper/root[")) + { + return Err(RecoveryError::Invalid("requires mapper-backed LUKS2 root")); + } + let backing = status + .lines() + .find_map(|line| line.trim().strip_prefix("device:").map(str::trim)) + .ok_or(RecoveryError::Invalid("root backing device is absent"))?; + if text(guest, "readlink", &["-f", backing])? + != text(guest, "readlink", &["-f", "/dev/disk/by-partlabel/root"])? + { + return Err(RecoveryError::Invalid( + "root partition label does not identify the active root", + )); + } + let volume = text( + guest, + "sudo", + &[ + "-n", + "cryptsetup", + "luksUUID", + "/dev/disk/by-partlabel/root", + ], + )?; + if volume.len() != 36 + || !volume.bytes().enumerate().all(|(i, b)| { + if [8, 13, 18, 23].contains(&i) { + b == b'-' + } else { + b.is_ascii_hexdigit() + } + }) + { + return Err(RecoveryError::Invalid("invalid LUKS volume identity")); + } + let existing = store.load(path)?; + let created = existing.is_none(); + let mut record = match existing { + Some(record) => record, + None => Record { + key: Zeroizing::new(generate()?), + volume: volume.clone(), + verified: false, + }, + }; + if record.volume != volume { + return Err(RecoveryError::Invalid( + "vault recovery key belongs to another volume; refusing replacement", + )); + } + if record.key.len() != 128 + || !record + .key + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(RecoveryError::Invalid( + "recovery key must be 128 lowercase hex characters", + )); + } + if created || !record.verified { + store.save(path, &record)?; + } + let remote = "/var/tmp/skillet-root-recovery.sh"; + guest.upload(helper, remote)?; + if !record.verified { + run_key(guest, remote, "enroll", &record.key)?; + } + run_key(guest, remote, "verify", &record.key)?; + if !record.verified { + record.verified = true; + store.save(path, &record)?; + } + Ok(()) +} + +fn text( + guest: &impl GuestTransport, + program: &str, + arguments: &[&str], +) -> Result { + let out = guest.execute(&GuestCommand { program, arguments }, None)?; + if !out.status.success() { + return Err(RecoveryError::Invalid("guest preflight failed")); + } + String::from_utf8(out.stdout) + .map(|s| s.trim().to_owned()) + .map_err(|_| RecoveryError::Invalid("invalid guest preflight output")) +} +fn run_key( + guest: &impl GuestTransport, + script: &str, + action: &str, + key: &str, +) -> Result<(), RecoveryError> { + let out = guest.execute( + &GuestCommand { + program: "sudo", + arguments: &["-n", "bash", script, action], + }, + Some(key.as_bytes()), + )?; + if !out.status.success() { + return Err(RecoveryError::Invalid( + "guest enrollment/verification failed; key remains in vault for retry", + )); + } + Ok(()) +} +#[cfg(test)] +#[path = "root_recovery/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/root_recovery/tests.rs b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs new file mode 100644 index 00000000..ae8f0f34 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs @@ -0,0 +1,194 @@ +use super::*; +use std::{cell::RefCell, os::unix::process::ExitStatusExt, process::Output}; +const UUID: &str = "12345678-1234-1234-1234-123456789abc"; +#[derive(Default)] +struct Store { + record: Option, + fail: bool, +} +impl RecoveryStore for Store { + fn load(&self, _: &str) -> Result, RecoveryError> { + Ok(self.record.as_ref().map(|r| Record { + key: Zeroizing::new(r.key.to_string()), + volume: r.volume.clone(), + verified: r.verified, + })) + } + fn save(&mut self, _: &str, record: &Record) -> Result<(), RecoveryError> { + if self.fail { + return Err(RecoveryError::Invalid("save failed")); + } + self.record = Some(Record { + key: Zeroizing::new(record.key.to_string()), + volume: record.volume.clone(), + verified: record.verified, + }); + Ok(()) + } +} +#[derive(Default)] +struct Guest { + calls: RefCell>, + fail: bool, +} +impl GuestTransport for Guest { + fn upload(&self, _: &Path, _: &str) -> skillet_vm::Result<()> { + self.calls.borrow_mut().push("upload".into()); + Ok(()) + } + fn execute( + &self, + command: &GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + let action = command.arguments.last().copied().unwrap_or(""); + self.calls.borrow_mut().push(action.into()); + if let Some(input) = input { + assert_eq!(input.len(), 128); + assert!(!command.arguments.iter().any(|a| a.len() == 128)); + } + let output = if command.program == "readlink" { + "/dev/sda4" + } else if command.program == "cat" { + "clamps" + } else if command.program == "findmnt" { + "/dev/mapper/root[/var]" + } else if command.arguments.contains(&"status") { + "type: LUKS2\n device: /dev/sda4" + } else if command.arguments.contains(&"luksUUID") { + UUID + } else { + "" + }; + Ok(Output { + status: std::process::ExitStatus::from_raw( + i32::from(self.fail && action == "enroll") * 256, + ), + stdout: output.as_bytes().to_vec(), + stderr: vec![], + }) + } +} +fn run(store: &mut Store, guest: &Guest) -> Result<(), RecoveryError> { + provision("clamps", "path", Path::new("helper"), store, guest, || { + Ok("a".repeat(128)) + }) +} +#[test] +fn new_key_is_saved_verified_and_repeat_is_verify_only() { + let mut store = Store::default(); + let guest = Guest::default(); + run(&mut store, &guest).unwrap(); + assert!(store.record.as_ref().unwrap().verified); + guest.calls.borrow_mut().clear(); + run(&mut store, &guest).unwrap(); + assert!(!guest.calls.borrow().iter().any(|s| s == "enroll")); +} +#[test] +fn interrupted_enrollment_reuses_pending_key() { + let mut store = Store::default(); + assert!(run( + &mut store, + &Guest { + fail: true, + ..Guest::default() + } + ) + .is_err()); + assert!(!store.record.as_ref().unwrap().verified); + provision( + "clamps", + "path", + Path::new("helper"), + &mut store, + &Guest::default(), + || panic!("must reuse saved key"), + ) + .unwrap(); + assert!(store.record.unwrap().verified); +} +#[test] +fn failed_persistence_and_volume_mismatch_never_enroll() { + let mut store = Store { + fail: true, + ..Store::default() + }; + let guest = Guest::default(); + assert!(run(&mut store, &guest).is_err()); + assert!(!guest.calls.borrow().iter().any(|s| s == "upload")); + store.fail = false; + store.record = Some(Record { + key: Zeroizing::new("a".repeat(128)), + volume: "another".into(), + verified: true, + }); + assert!(run(&mut store, &guest).is_err()); + assert!(!guest.calls.borrow().iter().any(|s| s == "upload")); +} + +#[test] +fn wrong_host_never_saves() { + let mut store = Store::default(); + assert!(provision( + "beezelbot", + "path", + Path::new("helper"), + &mut store, + &Guest::default(), + || panic!("must not generate") + ) + .is_err()); + assert!(store.record.is_none()); +} + +#[test] +fn printable_generated_key_has_full_entropy_and_no_newline() { + let a = generate_key().unwrap(); + let b = generate_key().unwrap(); + assert_eq!(a.len(), 128); + assert!(a + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))); + assert_ne!(a, b); +} + +#[test] +fn verified_key_is_not_silently_reenrolled_on_verification_failure() { + struct FailedVerify(Guest); + impl GuestTransport for FailedVerify { + fn upload(&self, source: &Path, target: &str) -> skillet_vm::Result<()> { + self.0.upload(source, target) + } + fn execute( + &self, + command: &GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + let mut output = self.0.execute(command, input)?; + if command.arguments.last() == Some(&"verify") { + output.status = std::process::ExitStatus::from_raw(256); + } + Ok(output) + } + } + let guest = FailedVerify(Guest::default()); + let mut store = Store { + record: Some(Record { + key: Zeroizing::new("a".repeat(128)), + volume: UUID.into(), + verified: true, + }), + fail: false, + }; + assert!(provision( + "clamps", + "path", + Path::new("helper"), + &mut store, + &guest, + || panic!("must reuse") + ) + .is_err()); + assert!(store.record.unwrap().verified); + assert!(!guest.0.calls.borrow().iter().any(|s| s == "enroll")); +} diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json index 981d76d1..30219a8f 100644 --- a/ublue/skillet/crates/workstation/src/secret-requirements.json +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -62,9 +62,7 @@ } ] }, - {"module":"Storage (planned)","required_by":[],"description":"No entry required today. Keep recovery material off the encrypted host. [Encryption plan](ublue/plan/TPM-ENCRYPTED-ROOT.md).","entries":[ - {"path":"skillet/hosts//storage/root-recovery-key","status":"planned","help":"LUKS recovery key; prepare through the future TPM/LUKS workflow."} - ]}, + {"module":"Storage","required_by":["storage"],"description":"Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet/README.md#root-recovery-keys). Password contains the printable passphrase; custom luks-uuid and recovery-state fields are managed by Skillet.","entries":[{"path":"skillet/hosts//storage/root-recovery-key","status":"generated","help":"Run skillet secrets root-recovery-key before finishing production provisioning. The command unlocks the vault, saves before enrollment, and verifies the key against this volume.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]},{"path":"skillet/environments/test/hosts//instances//storage/root-recovery-key","status":"generated","help":"Run skillet test vm recovery-key HOST INSTANCE. Archived test keys remain in the vault after VM disposal; delete the instance entry before reusing its name for a new disk.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]}]}, {"module":"Syncthing","required_by":["syncthing"],"description":"No vault entries currently required; Skillet manages the private UI configuration.","entries":[]}, {"module":"Tailscale","required_by":["tailscale"],"description":"Create an OAuth client in Tailscale Trust credentials with Auth Keys Write and Devices Core Read/Write, restricted to the provisioner tag. [Tag and scope guide](ublue/skillet/README.md#tailscale-setup-for-skillet). Skillet mints enrollment keys; do not create per-host auth-key entries.","entries":[ {"path":"skillet/tailscale/provisioner-client-id","status":"required","help":"Copy the OAuth Client ID from the creation page; use Password, not Username."}, diff --git a/ublue/skillet/crates/workstation/src/secrets.rs b/ublue/skillet/crates/workstation/src/secrets.rs index 9fd84140..ddf0437b 100644 --- a/ublue/skillet/crates/workstation/src/secrets.rs +++ b/ublue/skillet/crates/workstation/src/secrets.rs @@ -188,16 +188,29 @@ pub fn unused_paths( } Ok(stored .iter() - .filter(|path| path.starts_with("skillet/") && !used.contains(*path)) + .filter(|path| { + path.starts_with("skillet/") && !used.iter().any(|pattern| matches_entry(pattern, path)) + }) .cloned() .collect::>() .into_iter() .collect()) } +fn matches_entry(pattern: &str, path: &str) -> bool { + let Some((prefix, suffix)) = pattern.split_once("") else { + return pattern == path; + }; + path.strip_prefix(prefix) + .and_then(|v| v.strip_suffix(suffix)) + .is_some_and(|instance| skillet_vm::RunIdentity::new("fixture", instance).is_ok()) +} + fn selected(module: &RequirementGroup, profile: &HostProfile) -> bool { module.required_by.iter().any(|service| { - if service == "ui" { + if service == "storage" { + true + } else if service == "ui" { !profile.ui_services().is_empty() } else { profile.supports_service(service) diff --git a/ublue/skillet/crates/workstation/src/secrets/tests.rs b/ublue/skillet/crates/workstation/src/secrets/tests.rs index a5f6fb3c..20576cd4 100644 --- a/ublue/skillet/crates/workstation/src/secrets/tests.rs +++ b/ublue/skillet/crates/workstation/src/secrets/tests.rs @@ -259,3 +259,21 @@ fn capture_environment_does_not_require_or_read_smtp_provider_fields() { .iter() .all(|entry| entry.path != "skillet/smtp")); } + +#[test] +fn recovery_inventory_recognizes_only_declared_hosts_and_valid_instances() { + let profiles = skillet_hosts::profile::declared_profiles(); + let paths: Vec = [ + "skillet/hosts/clamps/storage/root-recovery-key", + "skillet/environments/test/hosts/clamps/instances/smoke/storage/root-recovery-key", + "skillet/environments/test/hosts/unknown/instances/smoke/storage/root-recovery-key", + "skillet/environments/test/hosts/clamps/instances/a/b/storage/root-recovery-key", + ] + .into_iter() + .map(String::from) + .collect(); + assert_eq!( + unused_paths(&profiles, &paths).unwrap(), + vec![paths[3].clone(), paths[2].clone()] + ); +} diff --git a/ublue/skillet/crates/workstation/src/vault.rs b/ublue/skillet/crates/workstation/src/vault.rs index d254b64f..154bf166 100644 --- a/ublue/skillet/crates/workstation/src/vault.rs +++ b/ublue/skillet/crates/workstation/src/vault.rs @@ -131,6 +131,19 @@ impl Vault { create_entry(&mut self.database, path, secret) } + /// Add metadata to an existing exact entry before an atomic verified save. + pub fn set_field(&mut self, path: &str, field: &str, value: &str) -> Result<(), VaultError> { + let id = find_entry(&self.database, path)? + .ok_or_else(|| VaultError::Invalid("vault metadata entry is absent".into()))? + .id(); + let mut entry = self + .database + .entry_mut(id) + .ok_or_else(|| VaultError::Invalid("vault entry disappeared".into()))?; + entry.set_protected(field, value); + Ok(()) + } + pub fn ensure_unchanged(&self) -> Result<(), VaultError> { if fs::read(&self.path).map_err(|error| source("rechecking vault before write", error))? != self.original diff --git a/ublue/skillet/crates/workstation/src/vault/tests.rs b/ublue/skillet/crates/workstation/src/vault/tests.rs index 48ad99dd..b84d93f8 100644 --- a/ublue/skillet/crates/workstation/src/vault/tests.rs +++ b/ublue/skillet/crates/workstation/src/vault/tests.rs @@ -131,6 +131,12 @@ fn verified_save_updates_symlink_target_and_keeps_recovery_copy() { }; let entry_path = "skillet/hosts/clamps/pihole/web-password"; vault.insert(entry_path, "generated test value").unwrap(); + vault + .set_field(entry_path, "luks-uuid", "fixture-volume") + .unwrap(); + vault + .set_field(entry_path, "recovery-state", "pending") + .unwrap(); vault .save_verified(None, entry_path, "generated test value") .unwrap(); @@ -145,6 +151,18 @@ fn verified_save_updates_symlink_target_and_keeps_recovery_copy() { lookup(&reopened, entry_path).unwrap().as_deref(), Some("generated test value") ); + assert_eq!( + super::lookup_field(&reopened, entry_path, "luks-uuid") + .unwrap() + .as_deref(), + Some("fixture-volume") + ); + assert_eq!( + super::lookup_field(&reopened, entry_path, "recovery-state") + .unwrap() + .as_deref(), + Some("pending") + ); assert!(std::fs::read_dir(dir.path()).unwrap().any(|entry| entry .unwrap() .file_name() @@ -243,3 +261,65 @@ fn exact_field_lookup_supports_standard_protected_and_custom_fields() { Err(VaultError::Invalid(_)) )); } + +#[test] +fn recovery_store_round_trips_pending_and_verified_metadata_without_rotation() { + use crate::root_recovery::{Record, RecoveryStore, VaultStore}; + use zeroize::Zeroizing; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("fixture.kdbx"); + let database = Database::new(); + let mut bytes = Vec::new(); + database + .save( + &mut bytes, + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let mut vault = Vault { + path: path.clone(), + original: bytes, + database, + password: "fixture unlock".into(), + password_cached: false, + }; + let mut store = VaultStore { + vault: &mut vault, + key_file: None, + }; + let entry = "skillet/hosts/clamps/storage/root-recovery-key"; + let mut record = Record { + key: Zeroizing::new("b".repeat(128)), + volume: "12345678-1234-1234-1234-123456789abc".into(), + verified: false, + }; + store.save(entry, &record).unwrap(); + assert!(!store.load(entry).unwrap().unwrap().verified); + record.verified = true; + store.save(entry, &record).unwrap(); + let encrypted = std::fs::read(path).unwrap(); + let reopened = Database::open( + &mut encrypted.as_slice(), + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + assert_eq!( + super::lookup_field(&reopened, entry, "Password") + .unwrap() + .unwrap(), + *record.key + ); + assert_eq!( + super::lookup_field(&reopened, entry, "recovery-state") + .unwrap() + .as_deref(), + Some("verified") + ); + assert_eq!( + super::lookup_field(&reopened, entry, "luks-uuid") + .unwrap() + .unwrap(), + record.volume + ); +} diff --git a/ublue/skillet/scripts/root-recovery.sh b/ublue/skillet/scripts/root-recovery.sh new file mode 100644 index 00000000..f84ba99a --- /dev/null +++ b/ublue/skillet/scripts/root-recovery.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Focused guest enrollment: the workstation owns vault persistence and transport. +set -euo pipefail +umask 077 +device=/dev/disk/by-partlabel/root +key="$(mktemp /run/skillet-root-recovery.XXXXXXXX)" +trap 'rm -f -- "$key"' EXIT +cat > "$key" +[[ "$(stat -c %s "$key")" == 128 ]] +LC_ALL=C grep -Eq '^[0-9a-f]{128}$' "$key" +case "${1:-}" in + enroll) + if ! cryptsetup open --test-passphrase --key-file "$key" "$device"; then + slot="$(clevis luks list -d "$device" | awk 'NR == 1 {sub(/:$/, "", $1); print $1}')" + [[ "$slot" =~ ^[0-9]+$ ]] + clevis luks pass -d "$device" -s "$slot" | + cryptsetup luksAddKey --key-file=- "$device" "$key" + fi + ;; + verify) cryptsetup open --test-passphrase --key-file "$key" "$device" ;; + *) exit 2 ;; +esac From bfc7ed7f355edd5abd46c98c55ccce8a3dc086d3 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sat, 10 Oct 2026 20:16:35 +0200 Subject: [PATCH 237/252] docs(storage): record live vault recovery acceptance --- ublue/butane/ACCEPTANCE.md | 28 ++++++++++++++++++++++++++++ ublue/design/storage.md | 2 ++ ublue/plan/TPM-ENCRYPTED-ROOT.md | 9 +++++++-- ublue/skillet/README.md | 7 ++++++- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 945bc196..b1c250d4 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,31 @@ +## Live vault-backed root recovery — 2026-10-10 + +- Fresh native `clamps / vault-recovery`, SSH port 2202, explicitly selected + `--root-profile tpm`. Native Ignition created LUKS2/Btrfs; both bootstrap + rebases unlocked unattended and signed-uCore/base/user readiness passed. + Creation printed the expected `test vm recovery-key` follow-up. +- The first enrollment attempt failed while creating the vault write lock: the + synced parent directory was read-only for the operator despite a writable + database file. An owner-checked guest probe confirmed only the original TPM + slot remained. After the operator granted a narrow directory ACL, enrollment + succeeded and the test-scoped vault record was saved with verified state and + the matching LUKS UUID. No production recovery entry was created. +- Repeated `test vm recovery-key clamps vault-recovery` succeeded without + changing encrypted vault bytes. Guest inspection confirmed exactly two slots: + TPM plus independent printable recovery passphrase. +- A temporary Rust acceptance driver used the per-run lock, manifest/backend + ownership validation and guarded SSH transport. It flushed a Btrfs data marker, + checked a complete cold stop/start, then normal `test vm ready` passed with + unattended unlock. The boot ID changed; volume UUID, two slots and data marker + survived. Direct cryptsetup verification accepted the vault passphrase and + rejected an invalid passphrase. No transient recovery file remained in `/run`. +- Lifecycle disposal removed the test domain and its owned run artifacts; + encrypted vault bytes stayed unchanged, preserving the test archive. The + retained `clamps / smoke` VM remained running on port 2201. +- This establishes the live test-scoped vault enrollment/retry/cold-boot slice. + It does not establish physical production enrollment, header export or recovery + after simulated TPM loss; those remain pending. No workstation packages installed. + ## Recovery-key vault commands — 2026-10-10 - Implemented `secrets root-recovery-key HOST` for production and lifecycle-owned diff --git a/ublue/design/storage.md b/ublue/design/storage.md index 7905cf45..2bf2bc15 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -75,6 +75,8 @@ backup under the private run directory, then checks recovery without TPM and cold-boots through the lifecycle owner. Only TPM and recovery keyslots are accepted. These artifacts are deleted on disposal. Production vault enrollment is implemented with write-ahead persistence and volume identity checks. +Live VM vault enrollment/retry and cold-boot persistence passed on 2026-10-10; +invalid-passphrase rejection confirms the independent passphrase path. Physical-disk selection, header export and physical recovery acceptance remain planned. Skillet convergence never formats an installed filesystem. QEMU functional tests and physical acceptance are specified in the diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md index 2e5e1a7c..8efdaea1 100644 --- a/ublue/plan/TPM-ENCRYPTED-ROOT.md +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -19,8 +19,9 @@ repeatable independent-key/header/cold-boot acceptance command. Routine CI covers rendering, prerequisites, staging and policy rejection without a vault. Production recovery-key vault persistence is implemented with atomic pending/verified state, volume identity validation, and a separate VM archive command. Live -acceptance of this new vault workflow, production header export and physical -installation remain pending. +VM vault enrollment, unchanged retry, independent-passphrase verification and +unattended cold-boot persistence passed on 2026-10-10. Production header export, +simulated TPM-loss recovery and physical installation remain pending. ## Scope and decisions @@ -139,6 +140,10 @@ status. No workstation installation is performed by the agent. ## 4. Establish recovery before destructive tests +Status: independent key enrollment and vault persistence/retry are implemented +and verified in a native VM, including cold boot and invalid-key rejection. +Production header export and simulated TPM-loss recovery remain unfinished. + - Add an independent high-entropy recovery key while the valid TPM path works. Persist production keys through the atomic Rust KDBX writer at `skillet/hosts//storage/root-recovery-key`. Reuse an existing value; diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 1a13bba3..53b8be51 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -600,7 +600,7 @@ are saved privately in the run's `serial.log` before SSH is available. the independent key, cold-boots the owned VM and checks data persistence. Reruns reuse the recovery key. Disposal removes these artifacts. No vault or provider credentials are required. Keep production recovery material separately; -physical installation and its vault-backed recovery workflow are still pending. +physical installation and production header export are still pending. See the [storage design](../design/storage.md) and [encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md) for policy and remaining @@ -619,6 +619,11 @@ cargo run --release -p skillet -- secrets root-recovery-key clamps \ ``` This opens the normal vault unlock prompt unless its password is cached. +The vault file and its parent directory must be writable: atomic saves create a +lock, encrypted replacement and backup in that directory. A group-writable file +in a read-only directory is insufficient. For synced stores, preserve service +ownership and grant the operator a narrow directory ACL when needed. + Optional `--database` and `--key-file` select another vault. SSH host keys must already be recorded. The Password at `skillet/hosts/clamps/storage/root-recovery-key` is the passphrase to enter at From 3f0d75dba2f768adfdd7865acdfdfe9804bc46ec Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 00:07:32 +0200 Subject: [PATCH 238/252] feat(storage): archive verified LUKS headers in KeePassXC --- SECRETS.md | 2 +- ublue/skillet/crates/cli/src/main.rs | 4 +- ublue/skillet/crates/cli/src/root_recovery.rs | 4 +- .../crates/workstation/src/root_recovery.rs | 48 +++++++++++- .../workstation/src/root_recovery/tests.rs | 76 ++++++++++++++++++- .../workstation/src/secret-requirements.json | 2 +- ublue/skillet/crates/workstation/src/vault.rs | 64 ++++++++++++++++ .../crates/workstation/src/vault/tests.rs | 26 +++++++ ublue/skillet/scripts/root-recovery.sh | 11 ++- 9 files changed, 228 insertions(+), 9 deletions(-) diff --git a/SECRETS.md b/SECRETS.md index 484db1f2..6204877b 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -71,7 +71,7 @@ Production-only native Postfix relay credentials; smoke machines use a local sin ## Storage -Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet/README.md#root-recovery-keys). Password contains the printable passphrase; custom luks-uuid and recovery-state fields are managed by Skillet. +Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet/README.md#root-recovery-keys). Password contains the printable passphrase; custom luks-uuid and recovery-state fields and the protected root-luks.header attachment are managed by Skillet. | Entry | Status | Fields / how to obtain them | | --- | --- | --- | diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 85bbf29d..6e0578e0 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -67,7 +67,7 @@ enum SecretCommands { Unlock(SecretUnlockArgs), /// Remove the cached vault password from the kernel keyring Lock(SecretLockArgs), - /// Enroll and save a production recovery passphrase + /// Enroll a production recovery passphrase and archive its LUKS header RootRecoveryKey(RootRecoveryArgs), } @@ -174,7 +174,7 @@ enum TestCommands { enum VmCommands { /// Verify encrypted root, save disposable recovery artifacts and cold-boot it EncryptionCheck(VmTargetArgs), - /// Save this instance recovery passphrase in `KeePassXC` + /// Save this instance recovery passphrase and LUKS header in `KeePassXC` RecoveryKey(VmRecoveryArgs), /// Provision a disposable host VM and wait for it to become ready Create(VmCreateArgs), diff --git a/ublue/skillet/crates/cli/src/root_recovery.rs b/ublue/skillet/crates/cli/src/root_recovery.rs index 44829789..7d02d44a 100644 --- a/ublue/skillet/crates/cli/src/root_recovery.rs +++ b/ublue/skillet/crates/cli/src/root_recovery.rs @@ -87,6 +87,8 @@ fn save( guest, root_recovery::generate_key, )?; - println!("Saved and verified recovery passphrase in KeePassXC: {path}"); + println!( + "Saved and verified recovery passphrase and encrypted LUKS header in KeePassXC: {path}" + ); Ok(()) } diff --git a/ublue/skillet/crates/workstation/src/root_recovery.rs b/ublue/skillet/crates/workstation/src/root_recovery.rs index d0338eee..c8956e00 100644 --- a/ublue/skillet/crates/workstation/src/root_recovery.rs +++ b/ublue/skillet/crates/workstation/src/root_recovery.rs @@ -17,10 +17,13 @@ pub enum RecoveryError { Randomness(#[from] std::io::Error), } +pub const HEADER_ATTACHMENT: &str = "root-luks.header"; + pub struct Record { pub key: Zeroizing, pub volume: String, pub verified: bool, + pub header: Option>>, } /// Cohesive persistence of a key and its enrollment/recovery identity. @@ -55,6 +58,7 @@ impl RecoveryStore for VaultStore<'_> { key: Zeroizing::new(key), volume, verified: state == "verified", + header: self.vault.get_attachment(path, HEADER_ATTACHMENT)?, })) } fn save(&mut self, path: &str, record: &Record) -> Result<(), RecoveryError> { @@ -72,7 +76,18 @@ impl RecoveryStore for VaultStore<'_> { "pending" }, )?; - self.vault.save_verified(self.key_file, path, &record.key)?; + if let Some(header) = &record.header { + self.vault.set_attachment(path, HEADER_ATTACHMENT, header)?; + self.vault.save_verified_attachment( + self.key_file, + path, + &record.key, + HEADER_ATTACHMENT, + header, + )?; + } else { + self.vault.save_verified(self.key_file, path, &record.key)?; + } Ok(()) } } @@ -144,6 +159,7 @@ pub fn provision( key: Zeroizing::new(generate()?), volume: volume.clone(), verified: false, + header: None, }, }; if record.volume != volume { @@ -170,13 +186,41 @@ pub fn provision( run_key(guest, remote, "enroll", &record.key)?; } run_key(guest, remote, "verify", &record.key)?; - if !record.verified { + let header = capture_header(guest, remote, &record.key)?; + if !record.verified || record.header.as_deref() != Some(&*header) { + record.header = Some(header); record.verified = true; store.save(path, &record)?; } Ok(()) } +fn capture_header( + guest: &impl GuestTransport, + remote: &str, + key: &str, +) -> Result>, RecoveryError> { + let output = guest.execute( + &GuestCommand { + program: "sudo", + arguments: &["-n", "bash", remote, "header"], + }, + Some(key.as_bytes()), + )?; + if !output.status.success() { + return Err(RecoveryError::Invalid( + "header backup verification failed; recovery key remains in vault", + )); + } + let header = Zeroizing::new(output.stdout); + if !(4096..=64 * 1024 * 1024).contains(&header.len()) + || !header.starts_with(b"LUKS\xba\xbe\x00\x02") + { + return Err(RecoveryError::Invalid("invalid LUKS2 header backup")); + } + Ok(header) +} + fn text( guest: &impl GuestTransport, program: &str, diff --git a/ublue/skillet/crates/workstation/src/root_recovery/tests.rs b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs index ae8f0f34..9a3d5f39 100644 --- a/ublue/skillet/crates/workstation/src/root_recovery/tests.rs +++ b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs @@ -12,6 +12,7 @@ impl RecoveryStore for Store { key: Zeroizing::new(r.key.to_string()), volume: r.volume.clone(), verified: r.verified, + header: r.header.clone(), })) } fn save(&mut self, _: &str, record: &Record) -> Result<(), RecoveryError> { @@ -22,6 +23,7 @@ impl RecoveryStore for Store { key: Zeroizing::new(record.key.to_string()), volume: record.volume.clone(), verified: record.verified, + header: record.header.clone(), }); Ok(()) } @@ -64,7 +66,13 @@ impl GuestTransport for Guest { status: std::process::ExitStatus::from_raw( i32::from(self.fail && action == "enroll") * 256, ), - stdout: output.as_bytes().to_vec(), + stdout: if action == "header" { + let mut bytes = vec![0; 4096]; + bytes[..8].copy_from_slice(b"LUKS\xba\xbe\x00\x02"); + bytes + } else { + output.as_bytes().to_vec() + }, stderr: vec![], }) } @@ -121,6 +129,7 @@ fn failed_persistence_and_volume_mismatch_never_enroll() { key: Zeroizing::new("a".repeat(128)), volume: "another".into(), verified: true, + header: None, }); assert!(run(&mut store, &guest).is_err()); assert!(!guest.calls.borrow().iter().any(|s| s == "upload")); @@ -177,6 +186,7 @@ fn verified_key_is_not_silently_reenrolled_on_verification_failure() { key: Zeroizing::new("a".repeat(128)), volume: UUID.into(), verified: true, + header: None, }), fail: false, }; @@ -192,3 +202,67 @@ fn verified_key_is_not_silently_reenrolled_on_verification_failure() { assert!(store.record.unwrap().verified); assert!(!guest.0.calls.borrow().iter().any(|s| s == "enroll")); } + +#[test] +fn missing_header_is_migrated_without_rotating_verified_key() { + let mut store = Store { + record: Some(Record { + key: Zeroizing::new("a".repeat(128)), + volume: UUID.into(), + verified: true, + header: None, + }), + fail: false, + }; + let guest = Guest::default(); + run(&mut store, &guest).unwrap(); + assert_eq!( + store + .record + .as_ref() + .unwrap() + .header + .as_ref() + .unwrap() + .len(), + 4096 + ); + assert!(!guest.calls.borrow().iter().any(|action| action == "enroll")); + store.fail = true; + run(&mut store, &guest).unwrap(); // unchanged header must not rewrite the vault +} + +#[test] +fn invalid_header_keeps_pending_recovery_for_retry() { + struct BadHeader(Guest); + impl GuestTransport for BadHeader { + fn upload(&self, source: &Path, target: &str) -> skillet_vm::Result<()> { + self.0.upload(source, target) + } + fn execute( + &self, + command: &GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + let mut output = self.0.execute(command, input)?; + if command.arguments.last() == Some(&"header") { + output.stdout.clear(); + } + Ok(output) + } + } + let mut store = Store::default(); + assert!(provision( + "clamps", + "path", + Path::new("helper"), + &mut store, + &BadHeader(Guest::default()), + || Ok("a".repeat(128)) + ) + .is_err()); + assert!(!store.record.as_ref().unwrap().verified); + assert!(store.record.as_ref().unwrap().header.is_none()); + run(&mut store, &Guest::default()).unwrap(); + assert!(store.record.unwrap().verified); +} diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json index 30219a8f..27e7602f 100644 --- a/ublue/skillet/crates/workstation/src/secret-requirements.json +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -62,7 +62,7 @@ } ] }, - {"module":"Storage","required_by":["storage"],"description":"Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet/README.md#root-recovery-keys). Password contains the printable passphrase; custom luks-uuid and recovery-state fields are managed by Skillet.","entries":[{"path":"skillet/hosts//storage/root-recovery-key","status":"generated","help":"Run skillet secrets root-recovery-key before finishing production provisioning. The command unlocks the vault, saves before enrollment, and verifies the key against this volume.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]},{"path":"skillet/environments/test/hosts//instances//storage/root-recovery-key","status":"generated","help":"Run skillet test vm recovery-key HOST INSTANCE. Archived test keys remain in the vault after VM disposal; delete the instance entry before reusing its name for a new disk.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]}]}, + {"module":"Storage","required_by":["storage"],"description":"Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet/README.md#root-recovery-keys). Password contains the printable passphrase; custom luks-uuid and recovery-state fields and the protected root-luks.header attachment are managed by Skillet.","entries":[{"path":"skillet/hosts//storage/root-recovery-key","status":"generated","help":"Run skillet secrets root-recovery-key before finishing production provisioning. The command unlocks the vault, saves before enrollment, and verifies the key against this volume.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]},{"path":"skillet/environments/test/hosts//instances//storage/root-recovery-key","status":"generated","help":"Run skillet test vm recovery-key HOST INSTANCE. Archived test keys remain in the vault after VM disposal; delete the instance entry before reusing its name for a new disk.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]}]}, {"module":"Syncthing","required_by":["syncthing"],"description":"No vault entries currently required; Skillet manages the private UI configuration.","entries":[]}, {"module":"Tailscale","required_by":["tailscale"],"description":"Create an OAuth client in Tailscale Trust credentials with Auth Keys Write and Devices Core Read/Write, restricted to the provisioner tag. [Tag and scope guide](ublue/skillet/README.md#tailscale-setup-for-skillet). Skillet mints enrollment keys; do not create per-host auth-key entries.","entries":[ {"path":"skillet/tailscale/provisioner-client-id","status":"required","help":"Copy the OAuth Client ID from the creation page; use Password, not Username."}, diff --git a/ublue/skillet/crates/workstation/src/vault.rs b/ublue/skillet/crates/workstation/src/vault.rs index 154bf166..a7351a5a 100644 --- a/ublue/skillet/crates/workstation/src/vault.rs +++ b/ublue/skillet/crates/workstation/src/vault.rs @@ -144,6 +144,35 @@ impl Vault { Ok(()) } + /// Encrypted binary material attached to an exact vault entry. + pub fn get_attachment( + &self, + path: &str, + name: &str, + ) -> Result>>, VaultError> { + Ok(find_entry(&self.database, path)?.and_then(|entry| { + entry + .attachment_by_name(name) + .map(|attachment| zeroize::Zeroizing::new(attachment.data.to_vec())) + })) + } + + pub fn set_attachment( + &mut self, + path: &str, + name: &str, + bytes: &[u8], + ) -> Result<(), VaultError> { + let id = find_entry(&self.database, path)? + .ok_or_else(|| VaultError::Invalid("vault attachment entry is absent".into()))? + .id(); + self.database + .entry_mut(id) + .ok_or_else(|| VaultError::Invalid("vault entry disappeared".into()))? + .add_attachment(name, keepass::db::Value::protected(bytes.to_vec())); + Ok(()) + } + pub fn ensure_unchanged(&self) -> Result<(), VaultError> { if fs::read(&self.path).map_err(|error| source("rechecking vault before write", error))? != self.original @@ -160,6 +189,28 @@ impl Vault { key_file: Option<&Path>, entry_path: &str, secret: &str, + ) -> Result<(), VaultError> { + self.save_checked(key_file, entry_path, secret, None) + } + + /// Reopen and verify both credential and attachment before atomic publication. + pub fn save_verified_attachment( + &mut self, + key_file: Option<&Path>, + entry_path: &str, + secret: &str, + name: &str, + bytes: &[u8], + ) -> Result<(), VaultError> { + self.save_checked(key_file, entry_path, secret, Some((name, bytes))) + } + + fn save_checked( + &mut self, + key_file: Option<&Path>, + entry_path: &str, + secret: &str, + attachment: Option<(&str, &[u8])>, ) -> Result<(), VaultError> { let parent = self .path @@ -210,6 +261,19 @@ impl Vault { )); } + if let Some((name, bytes)) = attachment { + let retained = find_entry(&reopened, entry_path)?.and_then(|entry| { + entry + .attachment_by_name(name) + .map(|item| zeroize::Zeroizing::new(item.data.to_vec())) + }); + if retained.as_deref().map(Vec::as_slice) != Some(bytes) { + return Err(VaultError::Invalid( + "saved vault did not retain the recovery attachment".into(), + )); + } + } + let stamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|error| source("reading system time", error))? diff --git a/ublue/skillet/crates/workstation/src/vault/tests.rs b/ublue/skillet/crates/workstation/src/vault/tests.rs index b84d93f8..ea5f4dc5 100644 --- a/ublue/skillet/crates/workstation/src/vault/tests.rs +++ b/ublue/skillet/crates/workstation/src/vault/tests.rs @@ -293,12 +293,38 @@ fn recovery_store_round_trips_pending_and_verified_metadata_without_rotation() { key: Zeroizing::new("b".repeat(128)), volume: "12345678-1234-1234-1234-123456789abc".into(), verified: false, + header: None, }; store.save(entry, &record).unwrap(); assert!(!store.load(entry).unwrap().unwrap().verified); record.verified = true; + record.header = Some(Zeroizing::new(b"sensitive binary header fixture".to_vec())); store.save(entry, &record).unwrap(); + assert_eq!(store.load(entry).unwrap().unwrap().header, record.header); + let before = std::fs::read(&path).unwrap(); + store + .vault + .set_attachment( + entry, + crate::root_recovery::HEADER_ATTACHMENT, + b"wrong attachment", + ) + .unwrap(); + assert!(store + .vault + .save_verified_attachment( + None, + entry, + &record.key, + crate::root_recovery::HEADER_ATTACHMENT, + record.header.as_ref().unwrap() + ) + .is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); let encrypted = std::fs::read(path).unwrap(); + assert!(!encrypted + .windows(b"sensitive binary header fixture".len()) + .any(|chunk| chunk == b"sensitive binary header fixture")); let reopened = Database::open( &mut encrypted.as_slice(), DatabaseKey::new().with_password("fixture unlock"), diff --git a/ublue/skillet/scripts/root-recovery.sh b/ublue/skillet/scripts/root-recovery.sh index f84ba99a..389d9a2f 100644 --- a/ublue/skillet/scripts/root-recovery.sh +++ b/ublue/skillet/scripts/root-recovery.sh @@ -4,7 +4,8 @@ set -euo pipefail umask 077 device=/dev/disk/by-partlabel/root key="$(mktemp /run/skillet-root-recovery.XXXXXXXX)" -trap 'rm -f -- "$key"' EXIT +work="" +trap 'rm -f -- "$key"; if [[ -n "$work" ]]; then rm -rf -- "$work"; fi' EXIT cat > "$key" [[ "$(stat -c %s "$key")" == 128 ]] LC_ALL=C grep -Eq '^[0-9a-f]{128}$' "$key" @@ -17,6 +18,14 @@ case "${1:-}" in cryptsetup luksAddKey --key-file=- "$device" "$key" fi ;; + header) + work="$(mktemp -d /run/skillet-root-header.XXXXXXXX)" + header="$work/luks.header" + cryptsetup luksHeaderBackup "$device" --header-backup-file "$header" + [[ "$(cryptsetup luksUUID --header "$header" "$device")" == "$(cryptsetup luksUUID "$device")" ]] + cryptsetup open --test-passphrase --header "$header" --key-file "$key" "$device" + cat "$header" + ;; verify) cryptsetup open --test-passphrase --key-file "$key" "$device" ;; *) exit 2 ;; esac From ec8ac14422a2a4e2e9af0bfa0505d76c280c90d7 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 00:22:54 +0200 Subject: [PATCH 239/252] feat(vm): add isolated TPM-loss recovery acceptance --- ublue/AGENTS.md | 4 + ublue/design/secrets.md | 11 + ublue/design/smoke-vms.md | 8 + ublue/design/storage.md | 16 +- ublue/plan/TPM-ENCRYPTED-ROOT.md | 13 +- ublue/skillet/Cargo.lock | 2 + ublue/skillet/README.md | 40 +- ublue/skillet/crates/vm/src/backend.rs | 58 ++ ublue/skillet/crates/vm/src/backend/tests.rs | 49 ++ ublue/skillet/crates/vm/src/domain_xml.rs | 29 +- .../skillet/crates/vm/src/domain_xml_tests.rs | 9 + ublue/skillet/crates/workstation/Cargo.toml | 4 + .../workstation/examples/tpm_recovery.rs | 674 ++++++++++++++++++ .../examples/tpm_recovery/tests.rs | 43 ++ .../integration_tests/root-header-restore.sh | 13 + ublue/skillet/scripts/root-tpm-rebind.sh | 42 ++ 16 files changed, 1005 insertions(+), 10 deletions(-) create mode 100644 ublue/skillet/crates/workstation/examples/tpm_recovery.rs create mode 100644 ublue/skillet/crates/workstation/examples/tpm_recovery/tests.rs create mode 100755 ublue/skillet/integration_tests/root-header-restore.sh create mode 100755 ublue/skillet/scripts/root-tpm-rebind.sh diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 46fd74f4..8a5f8e26 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -113,6 +113,10 @@ - File and directory mutations use the same typed ownership contract for named and numeric UID/GID values. Numeric ownership must not require a host account lookup; preserve omitted metadata fields on existing paths. +- Recovery acceptance preserves the original unlock path and uses independently + owned disposable state for destructive tests. Disable diagnostic capture on + credential-capable boot consoles; keep recovery backups outside the protected + resource and verify their bytes after encrypted persistence. - Persist independently usable recovery credentials before enrolling them. Bind recovery records to the resource identity, retain pending state for retries, and refuse silent replacement or re-enrollment of verified credentials. diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md index ba37f013..9be938aa 100644 --- a/ublue/design/secrets.md +++ b/ublue/design/secrets.md @@ -205,3 +205,14 @@ VM disposal retains vault archives deliberately, so new disks require an explici archive removal or a fresh instance name. Keys travel only through verified SSH stdin and a restricted guest `/run` file removed after use. This workflow requires provisioning-time noninteractive sudo and precedes access finalization. + +LUKS headers are protected binary attachments named `root-luks.header` beside +their volume-bound recovery passphrase. The atomic KDBX writer reopens and +compares both passphrase and attachment before publishing. Plaintext header +capture stays in guest tmpfs and workstation memory; the portable backup is +the encrypted vault. Verified legacy entries without an attachment are upgraded +by the same recovery-key command. Re-running refreshes changed enrollment +metadata. Disk-clone acceptance retains separate source and replacement archives. + +Historical headers retain their keyslots, so encrypted vault backups remain +sensitive after a key is retired. diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md index 59880a42..37ad92f7 100644 --- a/ublue/design/smoke-vms.md +++ b/ublue/design/smoke-vms.md @@ -184,3 +184,11 @@ in an instance-scoped vault entry through the same lock and guarded SSH transpor It never writes production entries. Archives survive disposal; reusing an instance name for a new volume fails until its old entry is explicitly removed. This is separate from `encryption-check`'s private disposable two-slot acceptance fixture. + +Native TPM-loss acceptance is an explicit test driver using the shared manifest, +locks, backend and guarded transport. It clones only a stopped base-only +encrypted VM, records ancestry before mutation, and defines independent TPM +and NVRAM state. It reuses the cloned guest SSH identity with the already-known +server key at the new local port. The recovery console is unlogged. Source +firmware/TPM and vault archives remain intact; normal disposal owns each VM's +artifacts. A failed recovery retains manifests and ancestry for inspection. diff --git a/ublue/design/storage.md b/ublue/design/storage.md index 2bf2bc15..2594244e 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -77,8 +77,12 @@ accepted. These artifacts are deleted on disposal. Production vault enrollment is implemented with write-ahead persistence and volume identity checks. Live VM vault enrollment/retry and cold-boot persistence passed on 2026-10-10; invalid-passphrase rejection confirms the independent passphrase path. -Physical-disk selection, header export and physical recovery acceptance remain -planned. Skillet convergence never formats an installed filesystem. +Header export is implemented: the recovery-key command archives a verified +LUKS2 header as the protected `root-luks.header` attachment on the same KDBX +entry. It refreshes after enrollment changes without rotating the passphrase. +The encrypted vault, backed up independently, carries both recovery artifacts. +A header backup is not a data backup. Physical-disk selection and physical +recovery acceptance remain planned. Skillet convergence never formats an installed filesystem. QEMU functional tests and physical acceptance are specified in the [TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). @@ -119,3 +123,11 @@ firmware compatibility. Sources: [FCOS encrypted root](https://github.com/coreos/fedora-coreos-docs/blob/main/modules/ROOT/pages/storage.adoc#encrypted-storage-luks), [Clevis TPM threat model](https://github.com/latchset/clevis/blob/master/src/pins/tpm2/clevis-encrypt-tpm2.1.adoc#threat-model), [systemd PCR policies](https://github.com/systemd/systemd/blob/main/man/systemd-cryptenroll.xml). + +Recovery acceptance uses a stopped, base-only disk clone with independent TPM +and NVRAM. It preserves the source TPM and archive; provider-provisioned VMs +are excluded to prevent impersonation. Recovery consoles have no serial log. +New TPM enrollment is explicit, verifies the new binding before removing the +old one, and archives the refreshed clone header separately. Routine convergence +never replaces TPM bindings. Native header restoration, TPM-loss recovery/re-enrollment, completed retry and +unattended cold boot passed on 2026-10-11; evidence is in Butane's record. diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md index 8efdaea1..45a5a276 100644 --- a/ublue/plan/TPM-ENCRYPTED-ROOT.md +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -20,8 +20,10 @@ covers rendering, prerequisites, staging and policy rejection without a vault. Production recovery-key vault persistence is implemented with atomic pending/verified state, volume identity validation, and a separate VM archive command. Live VM vault enrollment, unchanged retry, independent-passphrase verification and -unattended cold-boot persistence passed on 2026-10-10. Production header export, -simulated TPM-loss recovery and physical installation remain pending. +unattended cold-boot persistence passed on 2026-10-10. Protected header export +is implemented as a verified KDBX attachment. Native simulated TPM-loss +recovery/re-enrollment, header restoration and completed-retry acceptance passed +on 2026-10-11. Physical installation remains pending. ## Scope and decisions @@ -142,7 +144,12 @@ status. No workstation installation is performed by the agent. Status: independent key enrollment and vault persistence/retry are implemented and verified in a native VM, including cold boot and invalid-key rejection. -Production header export and simulated TPM-loss recovery remain unfinished. +Protected header export is implemented in both recovery-key commands, with +legacy-entry upgrade, refresh and encrypted attachment readback verification. +The native TPM-loss driver uses a fresh base-only stopped disk clone and +unlogged console. Native live acceptance passed on 2026-10-11: restored header +fixture, fresh TPM rejection, vault-assisted boot, verified re-enrollment, two +unattended cold boots, unchanged completed retry, and original TPM preservation. - Add an independent high-entropy recovery key while the valid TPM path works. Persist production keys through the atomic Rust KDBX writer at diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 5ae0fc69..21da50e3 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2552,6 +2552,7 @@ dependencies = [ "hex", "keepass", "keyutils", + "nix", "reqwest", "rpassword", "serde", @@ -2565,6 +2566,7 @@ dependencies = [ "skillet_vm", "tempfile", "thiserror 1.0.69", + "uuid", "zeroize", ] diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 53b8be51..ae41774f 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -600,7 +600,7 @@ are saved privately in the run's `serial.log` before SSH is available. the independent key, cold-boots the owned VM and checks data persistence. Reruns reuse the recovery key. Disposal removes these artifacts. No vault or provider credentials are required. Keep production recovery material separately; -physical installation and production header export are still pending. +physical installation is still pending. See the [storage design](../design/storage.md) and [encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md) for policy and remaining @@ -628,7 +628,9 @@ Optional `--database` and `--key-file` select another vault. SSH host keys must already be recorded. The Password at `skillet/hosts/clamps/storage/root-recovery-key` is the passphrase to enter at LUKS recovery. Skillet generates it, saves it before enrollment, and verifies -it against the running root volume. Repeat runs verify the existing key; +it against the running root volume. The verified LUKS header is stored as the encrypted `root-luks.header` +attachment on the same entry. Repeat runs verify the existing key and refresh +the header after enrollment changes; a different volume is rejected. No key is printed or written to workstation plaintext files. Back up the vault off the host before relying on it. @@ -649,3 +651,37 @@ own disposable key and enforces exactly two slots; use a fresh instance for that acceptance scenario rather than combining both enrollment workflows. Header backup/export and physical recovery acceptance remain pending. See [storage decisions](../design/storage.md). + +### Native TPM-loss recovery acceptance + +Use fresh instance names and free ports. Start from a base-only VM with no +provider credentials; keep another smoke VM on its own port. From `skillet/`: + +```bash +cargo run --release -p skillet -- test vm create clamps header-recovery --root-profile tpm --port 2202 +cargo run --release -p skillet -- test vm recovery-key clamps header-recovery +cargo run --release -p skillet_workstation --example tpm_recovery -- ../butane clamps header-recovery tpm-loss 2203 clone +cargo run --release -p skillet_workstation --example tpm_recovery -- ../butane clamps header-recovery tpm-loss 2203 unlock +cargo run --release -p skillet_workstation --example tpm_recovery -- ../butane clamps header-recovery tpm-loss 2203 rebind +cargo run --release -p skillet_workstation --example tpm_recovery -- ../butane clamps header-recovery tpm-loss 2203 verify +cargo run --release -p skillet -- test vm destroy clamps tpm-loss +cargo run --release -p skillet -- test vm destroy clamps header-recovery +``` + +The driver uses the normal vault cache. `clone` tests header restoration on a +fresh tmpfs regular-file fixture and creates a stopped disk clone with a fresh +TPM. `unlock` proves automatic unlock fails, then sends the vault passphrase +through an unlogged console. `rebind` verifies the new TPM before removing the +old binding, saves a separate replacement header archive and starts a cold boot. +`verify` checks unattended boot, volume identity, data sentinel, keyslots and +the original source TPM unlock path. +No passwords, headers or console transcripts are printed. Source and clone +archives survive VM disposal; remove an old instance's entry before reusing +its name for a new disk. Interrupted tests retain owned artifacts. To return +the restarted source to Ready, use `test vm ready HOST SOURCE`. + +For physical recovery, retrieve the Password and attachment from the backed-up +vault on an independent machine. Enter the Password at the LUKS boot prompt. +Header restoration is a separate destructive rescue operation: verify the +volume/disk identity before restoring it, and never restore a header onto a +running root volume. Physical TPM replacement acceptance remains pending. diff --git a/ublue/skillet/crates/vm/src/backend.rs b/ublue/skillet/crates/vm/src/backend.rs index dd4eb6fe..3042599f 100644 --- a/ublue/skillet/crates/vm/src/backend.rs +++ b/ublue/skillet/crates/vm/src/backend.rs @@ -374,6 +374,24 @@ impl VirshBackend { self.invoke(&["--version"]) } + /// Resolve an unlogged native recovery PTY with ownership checked around inspection. + pub fn recovery_console(&self, run: &VmRun) -> Result { + self.validate_connection(run)?; + if run.connection.backend != Backend::Native { + return Err(Error::Invalid( + "recovery console acceptance currently requires native libvirt".into(), + )); + } + self.inspect(run)? + .ok_or_else(|| Error::Invalid("recovery VM is absent".into()))? + .validate_owned(run)?; + let xml = self.invoke(&["dumpxml", &run.uuid.to_string()])?; + self.inspect(run)? + .ok_or_else(|| Error::Invalid("recovery VM disappeared".into()))? + .validate_owned(run)?; + parse_recovery_console(&xml) + } + pub fn verify_tpm_support(&self) -> Result<()> { crate::firmware::verify_capabilities( &self.invoke(&["domcapabilities", "--machine", "q35"])?, @@ -482,7 +500,47 @@ struct DevicesXml { #[serde(rename = "disk", default)] disks: Vec, tpm: Option, + #[serde(rename = "serial", default)] + serials: Vec, +} +#[derive(Deserialize)] +struct SerialXml { + #[serde(rename = "@type")] + kind: String, + source: Option, + log: Option, } +#[derive(Deserialize)] +struct SerialSourceXml { + #[serde(rename = "@path")] + path: PathBuf, +} +fn parse_recovery_console(xml: &str) -> Result { + let domain: DomainXml = quick_xml::de::from_str(xml)?; + let serials = domain.devices.serials; + let [serial] = serials.as_slice() else { + return Err(Error::Invalid( + "recovery requires a single serial console".into(), + )); + }; + if serial.kind != "pty" || serial.log.is_some() { + return Err(Error::Invalid( + "recovery requires an unlogged PTY console".into(), + )); + } + let path = serial + .source + .as_ref() + .ok_or_else(|| Error::Invalid("recovery PTY source is absent".into()))? + .path + .clone(); + let suffix = path.strip_prefix("/dev/pts").ok().and_then(|p| p.to_str()); + if !suffix.is_some_and(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())) { + return Err(Error::Invalid("invalid recovery PTY path".into())); + } + Ok(path) +} + #[derive(Deserialize)] struct DiskXml { #[serde(rename = "@device")] diff --git a/ublue/skillet/crates/vm/src/backend/tests.rs b/ublue/skillet/crates/vm/src/backend/tests.rs index 125c7a9e..2756013a 100644 --- a/ublue/skillet/crates/vm/src/backend/tests.rs +++ b/ublue/skillet/crates/vm/src/backend/tests.rs @@ -299,3 +299,52 @@ fn subprocess_arguments_are_literal_and_timeout_is_bounded() { assert!(matches!(executor.execute(&invocation), Err(Error::Timeout))); assert!(start.elapsed() < Duration::from_secs(2)); } + +#[test] +fn recovery_console_rejects_logging_ambiguous_sources_and_non_ptys() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let base = xml(&run); + let serial = ""; + let with = |serial: &str| base.replace("", &format!("{serial}")); + assert_eq!( + parse_recovery_console(&with(serial)).unwrap(), + PathBuf::from("/dev/pts/7") + ); + assert!(parse_recovery_console(&base).is_err()); + assert!(parse_recovery_console(&with(&serial.repeat(2))).is_err()); + assert!(parse_recovery_console(&with( + &serial.replace("", "") + )) + .is_err()); + assert!(parse_recovery_console(&with(&serial.replace("/dev/pts/7", "/tmp/console"))).is_err()); + assert!(parse_recovery_console(&with(&serial.replace("type='pty'", "type='file'"))).is_err()); +} + +#[test] +fn recovery_console_stops_when_ownership_changes_after_inspection() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let good = xml(&run).replace( + "", + "", + ); + let bad = good.replace(&run.disk.display().to_string(), "/unowned/disk"); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![ + output(&run.uuid.to_string()), + output(&good), + output("running"), + output(&good), + output(&run.uuid.to_string()), + output(&bad), + output("running"), + ]), + ) + .unwrap(); + assert!(backend.recovery_console(&run).is_err()); + assert_eq!(backend.executor.calls.borrow().len(), 7); +} diff --git a/ublue/skillet/crates/vm/src/domain_xml.rs b/ublue/skillet/crates/vm/src/domain_xml.rs index 61fffea1..9944e664 100644 --- a/ublue/skillet/crates/vm/src/domain_xml.rs +++ b/ublue/skillet/crates/vm/src/domain_xml.rs @@ -14,6 +14,24 @@ use std::{ /// Write the KVM definition beside its owned disk and Ignition files. /// The output is atomically replaced and never follows a symlink. pub fn write_domain_xml(store: &ManifestStore, run: &VmRun, emulator: &Path) -> Result { + write_with_console_log(store, run, emulator, true) +} + +/// Recovery acceptance must disable serial logging before injecting a passphrase. +pub fn write_recovery_domain_xml( + store: &ManifestStore, + run: &VmRun, + emulator: &Path, +) -> Result { + write_with_console_log(store, run, emulator, false) +} + +fn write_with_console_log( + store: &ManifestStore, + run: &VmRun, + emulator: &Path, + console_log: bool, +) -> Result { store.validate(run, &run.identity)?; if !matches!(run.phase, Phase::Preparing | Phase::Defined) || !emulator.is_absolute() @@ -38,7 +56,7 @@ pub fn write_domain_xml(store: &ManifestStore, run: &VmRun, emulator: &Path) -> crate::manifest::reject_symlinks(dir)?; let path = dir.join("domain.xml"); crate::manifest::reject_symlinks(&path)?; - if run.root_profile == crate::install::RootProfile::Tpm { + if console_log && run.root_profile == crate::install::RootProfile::Tpm { let serial = dir.join("serial.log"); crate::manifest::reject_symlinks(&serial)?; let log = fs::OpenOptions::new() @@ -47,7 +65,7 @@ pub fn write_domain_xml(store: &ManifestStore, run: &VmRun, emulator: &Path) -> .open(serial)?; log.set_permissions(fs::Permissions::from_mode(0o600))?; } - let xml = render(run, emulator)?; + let xml = render_with_console_log(run, emulator, console_log)?; let mut output = tempfile::NamedTempFile::new_in(dir)?; output .as_file() @@ -62,7 +80,12 @@ pub fn write_domain_xml(store: &ManifestStore, run: &VmRun, emulator: &Path) -> Ok(path) } +#[cfg(test)] fn render(run: &VmRun, emulator: &Path) -> Result { + render_with_console_log(run, emulator, true) +} + +fn render_with_console_log(run: &VmRun, emulator: &Path, console_log: bool) -> Result { let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2); writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; start(&mut writer, "domain", &[("type", "kvm".into())])?; @@ -150,7 +173,7 @@ fn render(run: &VmRun, emulator: &Path) -> Result { end(&mut writer, "portForward")?; empty(&mut writer, "model", &[("type", "virtio".into())])?; end(&mut writer, "interface")?; - render_console(&mut writer, run, encrypted)?; + render_console(&mut writer, run, encrypted && console_log)?; end(&mut writer, "devices")?; end(&mut writer, "domain")?; String::from_utf8(writer.into_inner()) diff --git a/ublue/skillet/crates/vm/src/domain_xml_tests.rs b/ublue/skillet/crates/vm/src/domain_xml_tests.rs index c9d09915..054ccaa3 100644 --- a/ublue/skillet/crates/vm/src/domain_xml_tests.rs +++ b/ublue/skillet/crates/vm/src/domain_xml_tests.rs @@ -75,6 +75,15 @@ fn encrypted_xml_owns_firmware_and_tpm_and_refuses_other_state() { & 0o777, 0o600 ); + let recovery_path = + write_recovery_domain_xml(&store, &run, Path::new("/usr/bin/true")).unwrap(); + let recovery_xml = fs::read_to_string(recovery_path).unwrap(); + assert!(!recovery_xml.contains(" = std::result::Result>; +#[derive(serde::Serialize, serde::Deserialize)] +struct Ancestry { + source: RunIdentity, + source_uuid: uuid::Uuid, + clone_uuid: uuid::Uuid, + volume: String, + original_slot: String, + source_header_hash: String, +} +fn path(id: &RunIdentity) -> String { + format!( + "skillet/environments/test/hosts/{}/instances/{}/storage/root-recovery-key", + id.host(), + id.instance() + ) +} +fn main() -> Result<()> { + let args: Vec<_> = std::env::args().collect(); + if args.len() != 7 { + return Err( + "usage: tpm_recovery BUTANE HOST SOURCE CLONE PORT clone|unlock|rebind|verify".into(), + ); + } + let butane = fs::canonicalize(&args[1])?; + let source_id = RunIdentity::new(&args[2], &args[3])?; + let clone_id = RunIdentity::new(&args[2], &args[4])?; + if source_id == clone_id { + return Err("source and recovery instance must differ".into()); + } + let port: u16 = args[5].parse()?; + let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _source_lock = store.lock(&source_id)?; + let source = store.load(&source_id)?; + let _clone_lock = if args[6] == "clone" { + None + } else { + Some(store.lock(&clone_id)?) + }; + if source.connection.backend != Backend::Native + || source.root_profile != skillet_vm::install::RootProfile::Tpm + { + return Err("requires a native TPM source VM".into()); + } + let backend = VirshBackend::for_run(&source, &butane.join("bin/virsh"))?; + owned(&backend, &source)?; + let mut vault = Vault::open(&Vault::default_path()?, None)?; + let mut recovery = VaultStore { + vault: &mut vault, + key_file: None, + }; + let record = recovery + .load(&path(&source_id))? + .ok_or("source recovery is absent")?; + if !record.verified || record.header.is_none() { + return Err("source recovery and header must be verified first".into()); + } + if args[6] == "clone" { + if recovery.load(&path(&clone_id))?.is_some() { + return Err("clone archive already exists; use another instance".into()); + } + return clone_vm(&butane, &store, &backend, &source, &clone_id, port, &record); + } + let clone = store.load(&clone_id)?; + let ancestry: Ancestry = serde_json::from_slice(&fs::read( + store.run_dir(&clone_id).join("recovery-source.json"), + )?)?; + if ancestry.source != source_id + || ancestry.source_uuid != source.uuid + || ancestry.clone_uuid != clone.uuid + || ancestry.volume != record.volume + || ancestry.source_header_hash + != hex::encode(Sha256::digest( + record.header.as_ref().ok_or("source header absent")?, + )) + { + return Err("recovery ancestry mismatch".into()); + } + if clone.ssh.port != port { + return Err("recorded recovery port differs".into()); + } + owned(&backend, &clone)?; + match args[6].as_str() { + "unlock" => unlock(&backend, &clone, &record)?, + "rebind" => rebind( + &butane, + &store, + &backend, + clone, + &record, + &ancestry, + &mut recovery, + )?, + "verify" => { + verify(&backend, &clone, &record, &recovery)?; + verify_source(&backend, &source, &record, &ancestry.original_slot)?; + } + _ => return Err("unknown recovery action".into()), + } + Ok(()) +} +fn verify( + backend: &VirshBackend, + clone: &VmRun, + record: &Record, + recovery: &VaultStore<'_>, +) -> Result<()> { + let ssh = SshTransport::new(clone.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, clone); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + wait_ssh(&guest)?; + assert_volume(&guest, record)?; + require_base_only(&guest)?; + let metadata = execute( + &guest, + "sudo", + &[ + "-n", + "cryptsetup", + "luksDump", + "--dump-json-metadata", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + let metadata: serde_json::Value = serde_json::from_slice(&metadata)?; + if metadata["keyslots"].as_object().map(serde_json::Map::len) != Some(2) { + return Err("expected exactly new TPM plus recovery slots".into()); + } + let marker = execute( + &guest, + "sudo", + &["-n", "cat", "/var/lib/data/.skillet-tpm-loss-sentinel"], + None, + )?; + if marker != b"TPM replacement preserves encrypted data\n" { + return Err("data sentinel changed".into()); + } + if recovery.load(&path(&clone.identity))?.is_none_or(|r| { + !r.verified || r.header.is_none() || r.volume != record.volume || *r.key != *record.key + }) { + return Err("clone archive incomplete".into()); + } + println!("PASS: unattended cold boot with fresh TPM, same volume/data, exactly two slots and verified recovery header"); + Ok(()) +} + +fn verify_source( + backend: &VirshBackend, + source: &VmRun, + record: &Record, + slot: &str, +) -> Result<()> { + let ssh = SshTransport::new(source.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, source); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + wait_ssh(&guest)?; + assert_volume(&guest, record)?; + require_base_only(&guest)?; + let pass = Zeroizing::new(execute( + &guest, + "sudo", + &[ + "-n", + "clevis", + "luks", + "pass", + "-d", + "/dev/disk/by-partlabel/root", + "-s", + slot, + ], + None, + )?); + execute( + &guest, + "sudo", + &[ + "-n", + "cryptsetup", + "open", + "--test-passphrase", + "--key-file=-", + "/dev/disk/by-partlabel/root", + ], + Some(&pass), + )?; + println!("PASS: original source TPM still unlocks its unchanged volume and original header archive is preserved"); + Ok(()) +} + +fn rebind( + butane: &Path, + store: &ManifestStore, + backend: &VirshBackend, + mut clone: VmRun, + record: &Record, + ancestry: &Ancestry, + recovery: &mut VaultStore<'_>, +) -> Result<()> { + let ssh = SshTransport::new(clone.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, &clone); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + assert_volume(&guest, record)?; + guest.upload( + &butane.join("../skillet/scripts/root-tpm-rebind.sh"), + "/var/tmp/skillet-root-tpm-rebind.sh", + )?; + execute( + &guest, + "sudo", + &[ + "-n", + "bash", + "/var/tmp/skillet-root-tpm-rebind.sh", + &ancestry.original_slot, + ], + Some(record.key.as_bytes()), + )?; + execute( + &guest, + "sudo", + &["-n", "hostnamectl", "set-hostname", &clone.guest_hostname], + None, + )?; + // Separate archive: do not replace the source's original TPM header. + if recovery.load(&path(&clone.identity))?.is_none() { + recovery.save( + &path(&clone.identity), + &Record { + key: Zeroizing::new(record.key.to_string()), + volume: record.volume.clone(), + verified: true, + header: None, + }, + )?; + } + skillet_workstation::root_recovery::provision( + clone.identity.host(), + &path(&clone.identity), + &butane.join("../skillet/scripts/root-recovery.sh"), + recovery, + &guest, + || { + Err(skillet_workstation::root_recovery::RecoveryError::Invalid( + "clone must reuse its recorded recovery key", + )) + }, + )?; + + clone.phase = Phase::Started; + store.save(&clone)?; + backend.stop(&clone)?; + owned(backend, &clone)?; + backend.start(&clone)?; + owned(backend, &clone)?; + println!("PASS: new TPM verified, original binding removed, refreshed clone header archived; cold boot started"); + Ok(()) +} + +fn owned(backend: &VirshBackend, run: &VmRun) -> skillet_vm::Result<()> { + backend + .inspect(run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned recovery VM absent".into()))? + .validate_owned(run) +} +fn execute( + guest: &impl GuestTransport, + program: &str, + arguments: &[&str], + input: Option<&[u8]>, +) -> Result> { + let out = guest.execute(&GuestCommand { program, arguments }, input)?; + if !out.status.success() { + return Err("recovery guest assertion failed; sensitive output suppressed".into()); + } + Ok(out.stdout) +} +fn assert_volume(guest: &impl GuestTransport, record: &Record) -> Result<()> { + let volume = execute( + guest, + "sudo", + &[ + "-n", + "cryptsetup", + "luksUUID", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + if std::str::from_utf8(&volume)?.trim() != record.volume { + return Err("recovered volume differs".into()); + } + execute( + guest, + "sudo", + &[ + "-n", + "cryptsetup", + "open", + "--test-passphrase", + "--key-file=-", + "/dev/disk/by-partlabel/root", + ], + Some(record.key.as_bytes()), + )?; + Ok(()) +} +fn wait_ssh(guest: &impl GuestTransport) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(180); + while Instant::now() < deadline { + if guest + .execute( + &GuestCommand { + program: "true", + arguments: &[], + }, + None, + ) + .is_ok_and(|out| out.status.success()) + { + return Ok(()); + } + std::thread::sleep(Duration::from_secs(2)); + } + Err("recovery SSH deadline exceeded".into()) +} +fn private_copy(source: &Path, destination: &Path) -> Result<()> { + if fs::symlink_metadata(source)?.file_type().is_symlink() { + return Err("refusing linked source artifact".into()); + } + if source.is_dir() { + fs::create_dir_all(destination)?; + fs::set_permissions(destination, fs::Permissions::from_mode(0o700))?; + for entry in fs::read_dir(source)? { + let entry = entry?; + private_copy(&entry.path(), &destination.join(entry.file_name()))?; + } + } else if source.is_file() { + fs::copy(source, destination)?; + fs::set_permissions(destination, fs::Permissions::from_mode(0o600))?; + } else { + return Err("refusing non-regular clone artifact".into()); + } + Ok(()) +} +fn refuse_provider_journals(source_dir: &Path) -> Result<()> { + // Provider delivery would make a disk clone impersonate live services. + for entry in fs::read_dir(source_dir)? { + let name = entry?.file_name(); + let name = name.to_string_lossy(); + if name.ends_with(".json") && !matches!(name.as_ref(), "vm.json" | "final-status.json") { + return Err( + "source has additional journals; use a fresh base-only encryption VM".into(), + ); + } + } + Ok(()) +} + +fn clone_vm( + butane: &Path, + store: &ManifestStore, + backend: &VirshBackend, + source: &VmRun, + id: &RunIdentity, + port: u16, + record: &Record, +) -> Result<()> { + if source.phase != Phase::Ready || store.run_dir(id).join("vm.json").exists() { + return Err("requires ready source and unused clone instance".into()); + } + skillet_vm::provisioning::validate_ssh_port(port)?; + let source_dir = store.run_dir(&source.identity); + refuse_provider_journals(&source_dir)?; + if backend.contains_name(&id.domain_name())? { + return Err("clone domain already exists".into()); + } + let intent = store.prepare_intent_with_profile( + id, + source.connection.clone(), + port, + &source.source_commit, + source.root_profile, + )?; + let _lock = store.lock(id)?; + let slot = prepare_source(butane, backend, source, record)?; + let dir = store.run_dir(id); + let mut clone = source.clone(); + clone.identity = id.clone(); + clone.uuid = intent.uuid; + clone.guest_hostname = id.domain_name(); + clone.disk = dir.join(format!("{}.qcow2", id.domain_name())); + clone.ignition = dir.join("ignition").join(format!("{}.ign", id.host())); + clone.ssh.port = port; + clone.ssh.identity = dir.join("ssh/id_ed25519"); + clone.ssh.known_hosts = dir.join("ssh/known_hosts"); + clone.phase = Phase::Preparing; + store.save(&clone)?; + fs::write( + dir.join("recovery-source.json"), + serde_json::to_vec_pretty(&Ancestry { + source: source.identity.clone(), + source_uuid: source.uuid, + clone_uuid: clone.uuid, + volume: record.volume.clone(), + original_slot: slot, + source_header_hash: hex::encode(Sha256::digest( + record.header.as_ref().ok_or("source header absent")?, + )), + })?, + )?; + fs::set_permissions( + dir.join("recovery-source.json"), + fs::Permissions::from_mode(0o600), + )?; + let mut stopped = source.clone(); + stopped.phase = Phase::Started; + store.save(&stopped)?; + owned(backend, source)?; + backend.stop(source)?; + if backend.inspect(source)?.ok_or("source absent")?.state != "shut off" { + return Err("source is not stopped".into()); + } + owned(backend, source)?; + let copied = (|| -> Result<()> { + let mut command = Command::new("qemu-img"); + command + .args(["convert", "-f", "qcow2", "-O", "qcow2"]) + .arg(&source.disk) + .arg(&clone.disk); + let output = skillet_vm::capture_command(command, Duration::from_secs(600))?; + if !output.status.success() { + return Err("disk clone failed".into()); + } + owned(backend, source)?; + fs::set_permissions(&clone.disk, fs::Permissions::from_mode(0o600))?; + for name in ["ignition", "ssh", "source"] { + private_copy(&source_dir.join(name), &dir.join(name))?; + } + let known = fs::read_to_string(&clone.ssh.known_hosts)?.replace( + &format!("[127.0.0.1]:{}", source.ssh.port), + &format!("[127.0.0.1]:{port}"), + ); + fs::write(&clone.ssh.known_hosts, known)?; + Ok(()) + })(); + owned(backend, source)?; + backend.start(source)?; + owned(backend, source)?; + copied?; + let xml = skillet_vm::domain_xml::write_recovery_domain_xml( + store, + &clone, + &backend.x86_64_emulator(&clone)?, + )?; + if backend.inspect(&clone)?.is_some() { + return Err("clone domain appeared during preparation".into()); + } + VmBackend::define(backend, &clone, &xml)?; + clone.phase = Phase::Defined; + store.save(&clone)?; + owned(backend, &clone)?; + println!("PASS: archived header restored on tmpfs fixture; stopped disk cloned; independent TPM/NVRAM and unlogged console defined"); + Ok(()) +} +fn require_base_only(guest: &impl GuestTransport) -> Result<()> { + for (program, arguments) in [ + ("sudo", vec!["-n", "podman", "ps", "-aq"]), + ( + "sudo", + vec!["-n", "podman", "secret", "ls", "--format", "{{.Name}}"], + ), + ( + "sudo", + vec![ + "-n", + "find", + "/etc", + "-maxdepth", + "3", + "-name", + "skillet*.cred", + ], + ), + ] { + if !execute(guest, program, &arguments, None)?.is_empty() { + return Err( + "source contains containers or delivered credentials; use a fresh base-only VM" + .into(), + ); + } + } + Ok(()) +} + +fn prepare_source( + butane: &Path, + backend: &VirshBackend, + source: &VmRun, + record: &Record, +) -> Result { + let ssh = SshTransport::new(source.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, source); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + assert_volume(&guest, record)?; + require_base_only(&guest)?; + let metadata = execute( + &guest, + "sudo", + &[ + "-n", + "cryptsetup", + "luksDump", + "--dump-json-metadata", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + let metadata: serde_json::Value = serde_json::from_slice(&metadata)?; + if metadata["keyslots"].as_object().map(serde_json::Map::len) != Some(2) { + return Err("source must have only TPM and independent recovery slots".into()); + } + let bindings = execute( + &guest, + "sudo", + &[ + "-n", + "clevis", + "luks", + "list", + "-d", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + let bindings = std::str::from_utf8(&bindings)?; + if bindings.lines().count() != 1 { + return Err("expected one source TPM binding".into()); + } + let slot = bindings.split_once(':').ok_or("source slot absent")?.0; + if !slot.bytes().all(|b| b.is_ascii_digit()) { + return Err("invalid source slot".into()); + } + guest.upload( + &butane.join("../skillet/integration_tests/root-header-restore.sh"), + "/var/tmp/skillet-header-restore.sh", + )?; + let mut payload = Zeroizing::new(record.key.as_bytes().to_vec()); + payload.extend_from_slice(record.header.as_ref().ok_or("header absent")?); + execute( + &guest, + "sudo", + &["-n", "bash", "/var/tmp/skillet-header-restore.sh"], + Some(&payload), + )?; + execute( + &guest, + "sudo", + &["-n", "tee", "/var/lib/data/.skillet-tpm-loss-sentinel"], + Some(b"TPM replacement preserves encrypted data\n"), + )?; + execute( + &guest, + "sudo", + &[ + "-n", + "sync", + "-f", + "/var/lib/data/.skillet-tpm-loss-sentinel", + ], + None, + )?; + Ok(slot.to_owned()) +} + +fn unlock(backend: &VirshBackend, run: &VmRun, record: &Record) -> Result<()> { + if backend.inspect(run)?.ok_or("clone absent")?.state != "shut off" { + return Err("unlock requires a stopped recovery clone".into()); + } + owned(backend, run)?; + backend.start(run)?; + owned(backend, run)?; + let tty = backend.recovery_console(run)?; + owned(backend, run)?; + let mut terminal = fs::OpenOptions::new() + .read(true) + .write(true) + .custom_flags(nix::libc::O_NONBLOCK | nix::libc::O_NOCTTY) + .open(tty)?; + let mut settings = nix::sys::termios::tcgetattr(&terminal)?; + nix::sys::termios::cfmakeraw(&mut settings); + nix::sys::termios::tcsetattr(&terminal, nix::sys::termios::SetArg::TCSANOW, &settings)?; + let deadline = Instant::now() + Duration::from_secs(180); + let mut output = Zeroizing::new(Vec::new()); + let mut bytes = Zeroizing::new([0u8; 4096]); + let mut prompted = false; + while Instant::now() < deadline { + owned(backend, run)?; + match terminal.read(&mut *bytes) { + Ok(count) => { + output.extend_from_slice(&bytes[..count]); + if output.len() > 65536 { + output.drain(..32768); + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => (), + Err(error) => return Err(error.into()), + } + let text = String::from_utf8_lossy(&output); + if text.contains("Please enter passphrase") || text.contains("Enter passphrase for") { + prompted = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + if !prompted { + return Err("fresh TPM did not reach the recovery prompt within deadline".into()); + } + let ssh = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, run); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + if guest + .execute( + &GuestCommand { + program: "true", + arguments: &[], + }, + None, + ) + .is_ok_and(|out| out.status.success()) + { + return Err("fresh TPM unexpectedly unlocked".into()); + } + owned(backend, run)?; + terminal.write_all(record.key.as_bytes())?; + terminal.write_all(b"\r")?; + // Stop collecting console output before credential transfer; never print it. + drop(terminal); + drop(output); + drop(bytes); + owned(backend, run)?; + wait_ssh(&guest)?; + assert_volume(&guest, record)?; + println!("PASS: fresh TPM blocked unattended boot; vault recovery passphrase booted the same encrypted volume"); + Ok(()) +} + +#[cfg(test)] +#[path = "tpm_recovery/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/examples/tpm_recovery/tests.rs b/ublue/skillet/crates/workstation/examples/tpm_recovery/tests.rs new file mode 100644 index 00000000..4d613dc3 --- /dev/null +++ b/ublue/skillet/crates/workstation/examples/tpm_recovery/tests.rs @@ -0,0 +1,43 @@ +use super::*; +use std::{os::unix::process::ExitStatusExt as _, process::Output}; +struct Guest { + blocked: &'static str, +} +impl GuestTransport for Guest { + fn upload(&self, _: &Path, _: &str) -> skillet_vm::Result<()> { + panic!("preflight must not upload") + } + fn execute( + &self, + command: &GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + assert!(input.is_none()); + let blocked = !self.blocked.is_empty() && command.arguments.contains(&self.blocked); + Ok(Output { + status: std::process::ExitStatus::from_raw(0), + stdout: if blocked { + b"existing resource\n".to_vec() + } else { + vec![] + }, + stderr: vec![], + }) + } +} +#[test] +fn base_only_preflight_rejects_containers_and_delivered_credentials() { + require_base_only(&Guest { blocked: "" }).unwrap(); + for blocked in ["ps", "secret", "find"] { + assert!(require_base_only(&Guest { blocked }).is_err()); + } +} +#[test] +fn readiness_evidence_is_not_provider_ownership() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("vm.json"), "manifest").unwrap(); + fs::write(dir.path().join("final-status.json"), "readiness").unwrap(); + refuse_provider_journals(dir.path()).unwrap(); + fs::write(dir.path().join("cloudflare.json"), "ownership").unwrap(); + assert!(refuse_provider_journals(dir.path()).is_err()); +} diff --git a/ublue/skillet/integration_tests/root-header-restore.sh b/ublue/skillet/integration_tests/root-header-restore.sh new file mode 100755 index 00000000..bd41c634 --- /dev/null +++ b/ublue/skillet/integration_tests/root-header-restore.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Destructive only to a fresh tmpfs regular-file fixture, never a block device. +set -euo pipefail +umask 077 +work="$(mktemp -d /run/skillet-header-restore.XXXXXXXX)" +trap 'rm -rf -- "$work"' EXIT +dd bs=128 count=1 iflag=fullblock status=none of="$work/key" +[[ "$(stat -c %s "$work/key")" == 128 ]] +LC_ALL=C grep -Eq '^[0-9a-f]{128}$' "$work/key" +cat > "$work/header" +truncate -s 64M "$work/fixture" +cryptsetup luksHeaderRestore --batch-mode "$work/fixture" --header-backup-file "$work/header" +cryptsetup open --test-passphrase --key-file "$work/key" "$work/fixture" diff --git a/ublue/skillet/scripts/root-tpm-rebind.sh b/ublue/skillet/scripts/root-tpm-rebind.sh new file mode 100755 index 00000000..0fc7bf74 --- /dev/null +++ b/ublue/skillet/scripts/root-tpm-rebind.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Explicit recovery after TPM replacement; caller records the original slot. +set -euo pipefail +umask 077 +device=/dev/disk/by-partlabel/root +old="${1:-}" +[[ "$old" =~ ^[0-9]+$ ]] +key="$(mktemp /run/skillet-root-rebind.XXXXXXXX)" +trap 'rm -f -- "$key"' EXIT +cat > "$key" +[[ "$(stat -c %s "$key")" == 128 ]] +LC_ALL=C grep -Eq '^[0-9a-f]{128}$' "$key" +cryptsetup open --test-passphrase --key-file "$key" "$device" +mapfile -t slots < <(clevis luks list -d "$device" | awk '{sub(/:$/, "", $1); print $1}') +[[ "${#slots[@]}" -ge 1 && "${#slots[@]}" -le 2 ]] +new="" +old_present=false +for slot in "${slots[@]}"; do + [[ "$slot" =~ ^[0-9]+$ ]] + if [[ "$slot" == "$old" ]]; then old_present=true; else new="$slot"; fi +done +if "$old_present"; then + if clevis luks pass -d "$device" -s "$old" 2>/dev/null | + cryptsetup open --test-passphrase --key-file=- "$device" 2>/dev/null; then + echo 'Original TPM still unlocks; refusing replacement' >&2 + exit 1 + fi + if [[ -z "$new" ]]; then + clevis luks bind -y -d "$device" -k "$key" tpm2 '{"pcr_bank":"sha256","pcr_ids":"7"}' + mapfile -t slots < <(clevis luks list -d "$device" | awk '{sub(/:$/, "", $1); print $1}') + [[ "${#slots[@]}" == 2 ]] + for slot in "${slots[@]}"; do + [[ "$slot" =~ ^[0-9]+$ ]] + if [[ "$slot" != "$old" ]]; then new="$slot"; fi + done + fi +fi +[[ "$new" =~ ^[0-9]+$ ]] +clevis luks pass -d "$device" -s "$new" | + cryptsetup open --test-passphrase --key-file=- "$device" +if "$old_present"; then clevis luks unbind -f -d "$device" -s "$old"; fi +cryptsetup open --test-passphrase --key-file "$key" "$device" From 83725c2f9e82ff59acc8ff1101782b614656dc4b Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 00:25:23 +0200 Subject: [PATCH 240/252] docs(storage): record TPM-loss recovery acceptance --- ublue/butane/ACCEPTANCE.md | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index b1c250d4..f337318d 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2310,3 +2310,46 @@ host tool; it still uses normal command approvals. UEFI/Secure Boot/MOK startup and encrypted rebase/rollback are unverified. - Updated storage design and TPM plan. No encrypted profile or VM was created; current install templates and both repositories' runtime code are unchanged. + +## 2026-10-11 — protected header backup and native TPM-loss recovery + +Native `qemu:///session`, fresh `clamps / header-recovery` (SSH 2202), recovery +clone `clamps / tpm-loss` (2203). The existing `clamps / smoke` VM on 2201 was +preserved. No workstation packages or physical disks/TPMs were changed. + +Passed: + +- Fresh TPM-profile install reached signed uCore and base/user readiness. +- `test vm recovery-key clamps header-recovery` persisted the independent key, + volume UUID and verified `root-luks.header` attachment in the encrypted vault. + Repeating it left the encrypted vault bytes unchanged. +- The archived header was restored onto a new 64 MiB tmpfs regular-file fixture; + the archived passphrase unlocked it. The fixture and plaintext inputs were removed. +- A stopped disk copy received independent TPM/NVRAM and an unlogged console. + The fresh TPM failed automatic unlock and reached the passphrase prompt; + the vault passphrase booted the same encrypted volume. +- Explicit re-enrollment verified the new TPM before deleting the old binding, + retained the independent recovery slot, and saved a separate clone header. +- Two unattended cold boots preserved volume identity and the flushed data + sentinel, with exactly two keyslots. Repeating completed recovery left vault + bytes unchanged and introduced no additional slots. +- The original TPM still unsealed its original key and unlocked its unchanged + volume. Its header archive matched the pre-clone hash throughout recovery. +- Both owned recovery VMs, disks, TPM/NVRAM and run artifacts were disposed. + Vault archives remained unchanged; the original smoke VM remained running. + +Driver: `cargo run --release -p skillet_workstation --example tpm_recovery -- +../butane clamps header-recovery tpm-loss 2203 ACTION`, with `clone`, `unlock`, +`rebind`, `verify` in order. Usage is in the Skillet README. Clone preflight +allows known readiness evidence but rejects provider journals, containers, +Podman secrets and delivered encrypted credentials. + +Local checks passed: formatting, pedantic workspace Clippy, all 363 workspace +and example tests, ShellCheck for the three recovery helpers, and Fedora +`test run beezelbot --phase base --image fedora:latest` (both applies succeeded; +repeat apply issued no service start/restart). Routine CI uses fixture KDBX and +fake transports; this live run used the cached vault unlock. + +Still unverified: physical TPM/firmware compatibility and rescue installation, +Flatpak encrypted-VM acceptance, offline-network and remaining boot-policy / +update / rollback matrix cases. A header backup is not a service-data backup. From 3b8028a3f25462c2bafeb1c9a6a2799c1204e8ef Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 00:59:50 +0200 Subject: [PATCH 241/252] feat(installation): prepare guarded physical USB install media --- .github/workflows/ci.yml | 7 +- ublue/AGENTS.md | 7 + ublue/butane/HARDWARE.md | 159 ++++++++ ublue/butane/README.md | 3 + ublue/butane/bin/install-hardware | 55 +++ ublue/butane/tests/hardware.sh | 26 ++ ublue/design/storage.md | 16 + ublue/plan/CLAMPS-MIGRATION.md | 10 +- ublue/plan/TPM-ENCRYPTED-ROOT.md | 3 +- ublue/skillet/Cargo.lock | 2 + ublue/skillet/README.md | 10 + ublue/skillet/crates/cli/src/installation.rs | 59 +++ .../crates/cli/src/installation/tests.rs | 46 +++ ublue/skillet/crates/cli/src/main.rs | 7 + ublue/skillet/crates/vm/src/install.rs | 68 ++++ ublue/skillet/crates/vm/src/staging.rs | 31 +- ublue/skillet/crates/workstation/Cargo.toml | 2 + .../crates/workstation/src/installation.rs | 378 ++++++++++++++++++ .../workstation/src/installation/tests.rs | 134 +++++++ ublue/skillet/crates/workstation/src/lib.rs | 2 + 20 files changed, 989 insertions(+), 36 deletions(-) create mode 100644 ublue/butane/HARDWARE.md create mode 100755 ublue/butane/bin/install-hardware create mode 100755 ublue/butane/tests/hardware.sh create mode 100644 ublue/skillet/crates/cli/src/installation.rs create mode 100644 ublue/skillet/crates/cli/src/installation/tests.rs create mode 100644 ublue/skillet/crates/workstation/src/installation.rs create mode 100644 ublue/skillet/crates/workstation/src/installation/tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b0f7d86..6ab26e5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,7 @@ jobs: find . -type f \ \( -name '*.sh' -o -name run -o -name butane -o -name flatpak-virt \ -o -name test-vm -o -name test-vm-ready -o -name virsh -o -name virt-install \ + -o -name install-hardware \ -o -name setup-llama-server -o -name update-llama \) \ -not -path './.git/*' -not -path './.venv/*' -not -path './berks-cookbooks/*' \ -not -path '*/target/*' -not -path './ublue/llama/files/update-llama' \ @@ -106,6 +107,8 @@ jobs: cd ublue/skillet cargo run --bin skillet -- test run beezelbot --phase base --image fedora:latest - - name: Run bootstrap state regressions + - name: Run installation and bootstrap regressions if: steps.filter.outputs.rust == 'true' - run: bash ublue/butane/tests/bootstrap.sh + run: | + bash ublue/butane/tests/bootstrap.sh + bash ublue/butane/tests/hardware.sh diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 8a5f8e26..6d9a4e11 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -88,6 +88,13 @@ - Removing an optional generated configuration section must tolerate its absence, while rejecting malformed present sections. Cover fresh inputs and retained compatibility inputs through the actual staging/compiler path. +- Physical installation media requires explicit destination disk identity and + private bootstrap access. Keep media generation separate from block-device + writes; validate hardware prerequisites and confirm destruction on the target. + Respect installer metadata size limits; bind separately delivered installation + payloads to the prepared media and verify them before destructive operations. + Preserve virtual installation support without leaking virtual disk assumptions + into physical profiles. Never describe generated media as hardware acceptance. - Installation profiles are explicit and independent of host identity. Preserve compatible defaults and reject missing prerequisites rather than silently weakening a requested security policy. Convergence never reformats live data. diff --git a/ublue/butane/HARDWARE.md b/ublue/butane/HARDWARE.md new file mode 100644 index 00000000..3f9f6848 --- /dev/null +++ b/ublue/butane/HARDWARE.md @@ -0,0 +1,159 @@ +# Physical installation + +Status: media generation is implemented. Beelink S13 installation, physical TPM, +MOK enrollment, and LAN acceptance remain unverified. VM commands continue to +use `clamps.bu` with `/dev/vda`; never compile that template directly for hardware. +See [storage policy](../design/storage.md) and [remaining encryption checks](../plan/TPM-ENCRYPTED-ROOT.md). + +## Prepare + +1. Boot a verified Fedora CoreOS **x86_64 live ISO** on the server to inspect it. + Attach Ethernet, a keyboard and a monitor. Identify the stock SSD: + + ```sh + lsblk -o NAME,SIZE,MODEL,SERIAL,TYPE,MOUNTPOINTS + ls -l /dev/disk/by-id/ + ls /sys/class/tpm + mokutil --sb-state + ``` + + Record the SSD's whole-disk `/dev/disk/by-id/...` path, not a `-partN` link. + The path belongs to the server; it need not exist on the workstation. + Keep rupik and its production SSD running until the scheduled cutover. + +2. Prepare private operator inputs outside the checkout: an Ed25519 public key + and a **fresh bootstrap password hash** (yescrypt or SHA-256/SHA-512 crypt). + Use an existing password-hashing tool that prompts, keeping the plaintext + password off command lines and logs. Save the hash in a mode-0600 file. + For example, if OpenSSL is already available, this prompts for the password: + + ```sh + umask 077 + openssl passwd -6 > /absolute/private/path/bootstrap.hash + ``` + + Know that password: access finalization later requires it. Media generation + replaces the inherited public keys and committed bootstrap hash; it does not + generate or store an operator's SSH private key. + +3. For TPM, obtain the trusted ublue DER signing certificate using the + [uCore Secure Boot instructions](https://github.com/ublue-os/ucore#readme). + TPM requires UEFI, TPM 2 and Secure Boot with that MOK enrolled. Supply the + certificate for unencrypted installation too if Secure Boot is enabled. + Verify the live ISO's signature using Fedora's published verification flow. + `--iso` is an already verified local ISO; Skillet does not verify its signature. + +4. From `ublue/skillet`, prepare a **new private output directory**: + + ```sh + export PATH=/opt/x86_64-linux-musl-cross/bin:$PATH + cargo run --release -p skillet -- install prepare clamps \ + --disk /dev/disk/by-id/WHOLE_DISK_ID \ + --iso /absolute/path/fedora-coreos-live.x86_64.iso \ + --output /absolute/private/path/clamps-install \ + --ssh-public-key /absolute/path/operator_ed25519.pub \ + --password-hash-file /absolute/private/path/bootstrap.hash \ + --root-profile tpm \ + --mok-certificate /absolute/path/ublue.der + ``` + + Use `--root-profile unencrypted` for a machine without TPM; this is the + default. A requested TPM profile never silently falls back. Preparing TPM + media does not prove that the destination hardware supports it. + The existing Podman/Butane/CoreOS installer containers and musl toolchain + are required; no workstation packages are installed. + + The bundle includes `install.iso`, destination `clamps.ign`, live `live.ign`, + and staged source/binaries. It contains the private password hash: keep the + whole bundle outside Git, protect the USB, and delete it after acceptance. + Existing output directories are refused. No workstation block device is opened. + Failed tooling leaves a private diagnostic file named in the error; treat it + as sensitive and remove it after inspection. + +## Write and install + +5. Identify the **USB device on the workstation** with `lsblk`; it is separate + from the destination SSD declared above. Write the generated `install.iso` + using an existing image writer. If using `dd`, check the whole USB identity + before running this destructive command: + + ```sh + sudo dd if=/absolute/private/path/clamps-install/install.iso \ + of=/dev/disk/by-id/WHOLE_USB_ID bs=4M conv=fsync status=progress + ``` + +6. Boot that USB in UEFI mode. It starts a live environment and **does not + automatically install**. Find its temporary DHCP address with `ip -brief address`. + The supplied operator key also grants SSH access to the live `core` account. + Verify its SSH host-key fingerprint at the console before accepting it: + + ```sh + sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub + ``` + + From the workstation, transfer the prepared destination Ignition: + + ```sh + scp -i /absolute/path/operator_ed25519 \ + /absolute/private/path/clamps-install/clamps.ign core@LIVE_ADDRESS:clamps.ign + ``` + + On the server's live console or an interactive SSH session, run: + + ```sh + sudo /var/usrlocal/bin/skillet-install /home/core/clamps.ign + ``` + + The helper copies the supplied file into root-only live tmpfs and checks its + SHA-256 against the digest embedded in this USB. A payload from another bundle + is refused. CoreOS's 256 KiB embedded-config area cannot hold our binaries; + the destination Ignition remains in the private bundle and travels over SSH. + An offline alternative is a separate data USB: mount it and pass the Ignition + path to the same helper. + + The helper checks the prepared disk exists and is a whole disk. TPM media + additionally checks UEFI, TPM 2 and Secure Boot. With Secure Boot enabled, + it requires the supplied ublue certificate to be enrolled. If needed: + + ```sh + sudo mokutil --import /etc/skillet-install/ublue.der + ``` + + Reboot, complete the firmware MOK enrollment, then boot this USB again. + Retry the helper after the final Secure Boot/MOK policy is established. + It displays the disk model/serial/size and requires typing its complete + prepared identifier before running `coreos-installer`. Installation erases + that disk. A hardware mismatch stops installation; regenerate the bundle + instead of editing its target ad hoc. + +7. Remove the USB and reboot. Allow FCOS-to-uCore signed bootstrap and its + intermediate reboots to complete. Connect using the supplied SSH key; + verify the host key independently. Check signed boot, Skillet base apply, + Btrfs `/var/lib/data`, DNS, user environment and enforcing SELinux. + +## Provision and cut over + +8. Unlock KeePassXC and use the existing production `secrets deliver` commands + in [Skillet's README](../skillet/README.md). Check the production secret audit + first. Service delivery may activate the full host configuration: keep the + new controller isolated from production devices until the planned migration. + +9. For encrypted root, run the documented `secrets root-recovery-key clamps` + command **before finalizing sudo**. Back up the resulting vault, including + its verified recovery key and `root-luks.header` attachment, off the server. + Test cold boot, offline unlock and an approved OS update on the physical box. + +10. Follow the [migration plan](../plan/CLAMPS-MIGRATION.md): test Pi-hole from + LAN clients; production HTTPS and outside-tailnet denial; transfer service + data/identities; stop the old UniFi controller before restoring/enabling the + new one; switch DNS/DDNS with one writer and retain rupik for rollback. + Establish off-machine data backups; local snapshots are not a disk backup. + +11. Finish with `sudo /var/usrlocal/bin/skillet access finalize --user giacomo`. + Keep the current session open, change the password in a second interactive + login and confirm sudo asks for it. Automated credential delivery currently + assumes temporary passwordless sudo, so complete it before this step. + +PXE is an alternative transport for the same destination Ignition, but this +command currently produces USB ISO media only. Firmware PXE support and the +DHCP/boot-file server setup remain separate work. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 99f4824c..16a967e2 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -1,5 +1,8 @@ # Clamps VM bootstrap +For real servers, use the separate [physical installation flow](HARDWARE.md). +VM creation keeps its `/dev/vda` template and independent root-profile choice. + A clean disposable VM create passed signed boot and readiness on 2026-09-26. The Flatpak backend on Bazzite passed create, status, and readiness on 2026-09-27. See [ACCEPTANCE.md](ACCEPTANCE.md) for the checks performed and remaining work. diff --git a/ublue/butane/bin/install-hardware b/ublue/butane/bin/install-hardware new file mode 100755 index 00000000..5d97e5e8 --- /dev/null +++ b/ublue/butane/bin/install-hardware @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Runs only in the prepared Fedora CoreOS live environment, never during apply. +set -euo pipefail +[[ $EUID -eq 0 ]] || { echo 'Run with sudo.' >&2; exit 1; } +[[ $# -le 1 ]] || { echo 'Usage: skillet-install /path/to/destination.ign' >&2; exit 2; } +config="${1:-/etc/skillet-install/config.ign}" +[[ -f "$config" ]] || { echo 'Transfer the prepared destination Ignition file and pass its path.' >&2; exit 1; } +umask 077 +staged_config="$(mktemp /run/skillet-install.XXXXXXXX.ign)" +trap 'rm -f -- "$staged_config"' EXIT +cp -- "$config" "$staged_config" +actual_hash="$(sha256sum "$staged_config" | cut -d ' ' -f 1)" +[[ "$actual_hash" == "$(cat /etc/skillet-install/ignition.sha256)" ]] || { + echo 'Ignition checksum does not match this prepared USB; refusing installation.' >&2; exit 1; +} +disk="$(cat /etc/skillet-install/disk)" +profile="$(cat /etc/skillet-install/profile)" +[[ "$disk" == /dev/disk/by-id/* && "$disk" != *-part* && -b "$disk" ]] || { + echo 'Prepared whole-disk identifier is absent; stop and inspect hardware.' >&2; exit 1; +} +[[ "$(lsblk -dn -o TYPE "$disk")" == disk ]] || { echo 'Target is not a whole disk.' >&2; exit 1; } +[[ "$profile" == tpm || "$profile" == unencrypted ]] || { echo 'Invalid root profile.' >&2; exit 1; } +secure_boot=disabled +if [[ -d /sys/firmware/efi ]]; then + secure_boot="$(mokutil --sb-state)" || { echo 'Cannot establish Secure Boot state.' >&2; exit 1; } +fi +case "$secure_boot" in + 'SecureBoot enabled'|'SecureBoot disabled'|disabled) ;; + *) echo 'Unrecognized Secure Boot state; stop and inspect firmware.' >&2; exit 1 ;; +esac +if [[ "$profile" == tpm ]]; then + [[ -d /sys/firmware/efi && "$(cat /sys/class/tpm/tpm0/tpm_version_major)" == 2 ]] || { + echo 'TPM profile requires UEFI and TPM 2.' >&2; exit 1; + } + [[ "$secure_boot" == 'SecureBoot enabled' ]] || { + echo 'Enable Secure Boot before installation; no unencrypted fallback.' >&2; exit 1; + } +fi +if [[ "$secure_boot" == 'SecureBoot enabled' ]]; then + if [[ ! -f /etc/skillet-install/ublue.der ]] || ! mokutil --test-key /etc/skillet-install/ublue.der; then + echo 'Enroll the supplied ublue MOK, reboot back into this USB, then retry.' >&2 + echo 'Enrollment command: sudo mokutil --import /etc/skillet-install/ublue.der' >&2 + exit 1 + fi +fi +lsblk -o NAME,SIZE,MODEL,SERIAL,TYPE,MOUNTPOINTS "$disk" +if lsblk -nr -o MOUNTPOINTS "$disk" | grep -q '[^[:space:]]'; then + echo 'Target has mounted filesystems; stop and inspect before installation.' >&2; exit 1 +fi +echo "Installation will erase $disk. Root profile: $profile" +printf 'Type the complete disk identifier to install: ' +read -r confirmation &2; exit 1; } +coreos-installer install "$disk" --ignition-file "$staged_config" +echo 'Installed. Remove the USB before booting the installed system.' diff --git a/ublue/butane/tests/hardware.sh b/ublue/butane/tests/hardware.sh new file mode 100755 index 00000000..3bbdbc18 --- /dev/null +++ b/ublue/butane/tests/hardware.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Exercise refusal paths in an isolated container with no host block devices. +set -euo pipefail +butane_dir="$(realpath "$(dirname "${BASH_SOURCE[0]}")/..")" +work="$(mktemp -d)" +trap 'rm -rf -- "$work"' EXIT +printf '{"ignition":{"version":"3.5.0"}}\n' > "$work/config.ign" +printf /dev/disk/by-id/skillet-nonexistent-test-disk > "$work/disk" +printf unencrypted > "$work/profile" +run_case() { + local expected="$1" + local status=0 + podman run --rm --network none --security-opt label=disable \ + --volume "$work:/etc/skillet-install:ro" \ + --volume "$butane_dir/bin/install-hardware:/helper:ro" \ + registry.fedoraproject.org/fedora:latest bash /helper /etc/skillet-install/config.ign \ + > "$work/output" 2>&1 || status=$? + if [[ "$status" != 1 ]] || ! grep -Fq "$expected" "$work/output"; then + cat "$work/output" >&2; echo "FAIL expected refusal: $expected" >&2; exit 1 + fi + echo "PASS $expected" +} +printf wrong-digest > "$work/ignition.sha256" +run_case 'Ignition checksum does not match' +sha256sum "$work/config.ign" | cut -d ' ' -f 1 > "$work/ignition.sha256" +run_case 'Prepared whole-disk identifier is absent' diff --git a/ublue/design/storage.md b/ublue/design/storage.md index 2594244e..1787fce9 100644 --- a/ublue/design/storage.md +++ b/ublue/design/storage.md @@ -131,3 +131,19 @@ New TPM enrollment is explicit, verifies the new binding before removing the old one, and archives the refreshed clone header separately. Routine convergence never replaces TPM bindings. Native header restoration, TPM-loss recovery/re-enrollment, completed retry and unattended cold boot passed on 2026-10-11; evidence is in Butane's record. + +## Physical installation media + +Physical installation uses an explicit destination whole-disk by-id path and +reuses the VM-tested root-profile transform. Workstation media generation never +opens a block device. A separate live helper checks the actual server and asks +for disk confirmation before installation; TPM media requires the final Secure +Boot/MOK policy before first encrypted boot. VM staging retains `/dev/vda`. +The live ISO contains a small bootstrap configuration, operator public key and +expected destination-Ignition digest. CoreOS's 256 KiB embedding limit excludes +our application binaries, so the full destination payload is transferred over +verified SSH (or separate data media), then checked in root-only live tmpfs. +Production media requires private bootstrap-access inputs instead of inheriting +committed keys/password hashes. USB media generation is implemented; physical +boot and installation acceptance remain unverified. See the +[hardware runbook](../butane/HARDWARE.md). diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index daaa6a6c..db1bf00f 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -66,8 +66,9 @@ configuration are available; record each result in The smoke VM uses libvirt user-mode networking and cannot satisfy item 1; use a bridged test VM or the physical clamps host on an isolated/test network. -Planned before physical cutover: [TPM-encrypted Btrfs root](TPM-ENCRYPTED-ROOT.md), -including QEMU TPM2 tests, recovery and stock-SSD hardware acceptance. Tang is +Native [TPM-encrypted Btrfs root](TPM-ENCRYPTED-ROOT.md), QEMU TPM2 tests +and recovery are implemented and accepted. Physical media generation is +implemented; stock-SSD hardware acceptance remains required before cutover. Tang is deferred; remote hosts will first be assessed for usable TPM support. ## Boundaries @@ -87,7 +88,10 @@ deferred; remote hosts will first be assessed for usable TPM support. - Exercise interrupted bootstrap and capture resolver continuity across intermediate boots. - Reconcile the acceptance log's repeatability status with its recorded second fresh VM; do not infer unrecorded checks passed. - Confirm automatic base apply before readiness explicitly restarts the unit, and collision refusal for unrelated domains/disks. -- Prepare physical disk selection and hardware/Secure Boot/MOK enrollment separately from the VM-only `/dev/vda` template; validate on the stock SSD. +- Physical USB media generation now selects a whole-disk by-id identifier and + reuses the shared root-profile renderer; VM creation retains `/dev/vda`. Follow + [the hardware runbook](../butane/HARDWARE.md). Actual disk selection, firmware + checks, MOK enrollment and stock-SSD installation remain unverified. ## Milestone 3: storage and credentials for the first application diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md index 45a5a276..8622ae90 100644 --- a/ublue/plan/TPM-ENCRYPTED-ROOT.md +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -23,7 +23,8 @@ VM vault enrollment, unchanged retry, independent-passphrase verification and unattended cold-boot persistence passed on 2026-10-10. Protected header export is implemented as a verified KDBX attachment. Native simulated TPM-loss recovery/re-enrollment, header restoration and completed-retry acceptance passed -on 2026-10-11. Physical installation remains pending. +on 2026-10-11. Physical USB media generation is implemented; installation on real hardware +remains pending. See [the hardware runbook](../butane/HARDWARE.md). ## Scope and decisions diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 21da50e3..a43faa38 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2548,6 +2548,7 @@ dependencies = [ name = "skillet_workstation" version = "0.1.0" dependencies = [ + "base64 0.22.1", "cloudflare", "hex", "keepass", @@ -2557,6 +2558,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "serde_yml", "sha2 0.10.9", "skillet_caddy", "skillet_datadog", diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index ae41774f..8561f575 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -685,3 +685,13 @@ vault on an independent machine. Enter the Password at the LUKS boot prompt. Header restoration is a separate destructive rescue operation: verify the volume/disk identity before restoring it, and never restore a header onto a running root volume. Physical TPM replacement acceptance remains pending. + +## Physical installation media + +`skillet install prepare HOST --disk /dev/disk/by-id/WHOLE_DISK_ID --iso LIVE_ISO +--output NEW_PRIVATE_DIRECTORY --ssh-public-key PUBLIC_KEY --password-hash-file +PRIVATE_HASH_FILE [--root-profile unencrypted|tpm] [--mok-certificate UBLUE_DER]` +builds a private USB-ready ISO without writing a block device. Follow +[the hardware runbook](../butane/HARDWARE.md) for inputs, USB writing, live checks, +MOK enrollment and cutover. Installation requires confirmation on the server; +TPM prerequisites are checked there. The VM `/dev/vda` path remains supported. diff --git a/ublue/skillet/crates/cli/src/installation.rs b/ublue/skillet/crates/cli/src/installation.rs new file mode 100644 index 00000000..81ca6508 --- /dev/null +++ b/ublue/skillet/crates/cli/src/installation.rs @@ -0,0 +1,59 @@ +//! Thin physical-media command adapter. +use super::RootProfileName; +use anyhow::Result; +use std::path::PathBuf; + +#[derive(clap::Subcommand, Debug)] +pub(super) enum Commands { + /// Build a USB-ready live ISO; installation requires confirmation on the server + Prepare(PrepareArgs), +} +#[derive(clap::Args, Debug)] +pub(super) struct PrepareArgs { + host: String, + /// Stable whole-disk path as seen on the destination server + #[arg(long)] + disk: String, + /// Verified Fedora `CoreOS` `x86_64` live ISO + #[arg(long)] + iso: PathBuf, + /// New private bundle directory, outside version control + #[arg(long)] + output: PathBuf, + #[arg(long, value_enum, default_value_t = RootProfileName::Unencrypted)] + root_profile: RootProfileName, + /// Operator's Ed25519 public key, replacing inherited authorized keys + #[arg(long)] + ssh_public_key: PathBuf, + /// Private file containing a fresh crypt-format bootstrap password hash + #[arg(long)] + password_hash_file: PathBuf, + /// Trusted ublue DER signing certificate; mandatory for TPM media + #[arg(long)] + mok_certificate: Option, +} +pub(super) fn dispatch(command: Commands) -> Result<()> { + let Commands::Prepare(args) = command; + let butane = super::butane_root()?; + let workspace = super::workspace_root()?; + let output = std::path::absolute(&args.output)?; + let iso = + skillet_workstation::installation::prepare(&skillet_workstation::installation::Request { + host: &args.host, + disk: &args.disk, + root_profile: args.root_profile.policy(), + iso: &args.iso, + output: &output, + public_key: &args.ssh_public_key, + password_hash: &args.password_hash_file, + mok_certificate: args.mok_certificate.as_deref(), + butane: &butane, + workspace: &workspace, + })?; + println!("Prepared {}\nTransfer the destination Ignition over SSH to the live server, then run: sudo /var/usrlocal/bin/skillet-install /path/to/destination.ign\nNo block device was written.", iso.display()); + Ok(()) +} + +#[cfg(test)] +#[path = "installation/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/cli/src/installation/tests.rs b/ublue/skillet/crates/cli/src/installation/tests.rs new file mode 100644 index 00000000..afefb56b --- /dev/null +++ b/ublue/skillet/crates/cli/src/installation/tests.rs @@ -0,0 +1,46 @@ +use super::super::{Args, Commands as RootCommands}; +use super::*; +use clap::Parser as _; + +#[test] +fn physical_command_requires_explicit_inputs_and_defaults_to_plain_root() { + assert!(Args::try_parse_from(["skillet", "install", "prepare", "clamps"]).is_err()); + let args = Args::try_parse_from([ + "skillet", + "install", + "prepare", + "clamps", + "--disk", + "/dev/disk/by-id/ata-example", + "--iso", + "/private/live.iso", + "--output", + "/private/bundle", + "--ssh-public-key", + "/private/key.pub", + "--password-hash-file", + "/private/password.hash", + ]) + .unwrap(); + let RootCommands::Install { + command: Commands::Prepare(request), + } = args.command + else { + panic!("wrong command dispatch"); + }; + assert_eq!(request.host, "clamps"); + assert!(matches!(request.root_profile, RootProfileName::Unencrypted)); +} + +#[test] +fn physical_and_vm_help_remain_available() { + for args in [ + vec!["skillet", "install", "prepare", "--help"], + vec!["skillet", "test", "vm", "create", "--help"], + ] { + assert_eq!( + Args::try_parse_from(args).unwrap_err().kind(), + clap::error::ErrorKind::DisplayHelp + ); + } +} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 6e0578e0..f14ecdc9 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -5,6 +5,7 @@ use std::{fs, io::Write as _, path::PathBuf, process::Command}; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; +mod installation; mod root_recovery; mod secret_delivery; mod secret_output; @@ -22,6 +23,11 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { + /// Prepare physical installation media without writing a block device + Install { + #[command(subcommand)] + command: installation::Commands, + }, /// Finalize interactive administrative access on this guest Access { #[command(subcommand)] @@ -344,6 +350,7 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { + Commands::Install { command } => installation::dispatch(command)?, Commands::Access { command } => skillet_cli_common::access::dispatch(command)?, Commands::Smtp { command: skillet_cli_common::SmtpCommands::Prepare, diff --git a/ublue/skillet/crates/vm/src/install.rs b/ublue/skillet/crates/vm/src/install.rs index c4622f79..d355cd0e 100644 --- a/ublue/skillet/crates/vm/src/install.rs +++ b/ublue/skillet/crates/vm/src/install.rs @@ -82,6 +82,35 @@ pub fn configure_root(config: &mut Value, profile: RootProfile) -> Result<()> { Ok(()) } +/// Butane requires file modes to be numeric. `serde_yml` parses YAML values such +/// as `0644` as strings, so convert octal mode strings before writing staged +/// configs back out. +pub fn normalize_butane_modes(value: &mut Value) -> Result<()> { + match value { + Value::Mapping(mapping) => { + for (key, value) in mapping.iter_mut() { + if key.as_str() == Some("mode") { + if let Some(mode) = value.as_str() { + let mode = u64::from_str_radix(mode, 8).map_err(|_| { + Error::Invalid(format!("invalid octal Butane file mode: {mode}")) + })?; + *value = Value::Number(mode.into()); + } + } else { + normalize_butane_modes(value)?; + } + } + } + Value::Sequence(values) => { + for value in values { + normalize_butane_modes(value)?; + } + } + _ => {} + } + Ok(()) +} + pub fn nvram_path(run: &crate::VmRun) -> Result { Ok(run .disk @@ -100,3 +129,42 @@ pub fn tpm_path(run: &crate::VmRun) -> Result { #[cfg(test)] #[path = "install/tests.rs"] mod tests; + +/// Select the whole disk for a fresh physical installation. VM templates remain unchanged. +pub fn configure_disk(config: &mut Value, disk: &str) -> Result<()> { + validate_physical_disk(disk)?; + let disks = config + .get_mut("storage") + .and_then(|v| v.get_mut("disks")) + .and_then(Value::as_sequence_mut) + .ok_or_else(|| { + Error::Invalid("installation requires an explicit disk declaration".into()) + })?; + if disks.len() != 1 || disks[0].get("device").and_then(Value::as_str) != Some("/dev/vda") { + return Err(Error::Invalid( + "expected one VM template disk; refusing ambiguous disk selection".into(), + )); + } + disks[0]["device"] = Value::String(disk.into()); + Ok(()) +} + +/// Require a stable whole-disk identifier, not an ambient workstation block device. +pub fn validate_physical_disk(disk: &str) -> Result<()> { + let Some(name) = disk.strip_prefix("/dev/disk/by-id/") else { + return Err(Error::Invalid( + "physical disk must use /dev/disk/by-id/".into(), + )); + }; + if name.is_empty() + || name.contains("-part") + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b)) + || name == "." + || name == ".." + { + return Err(Error::Invalid("invalid whole-disk identifier".into())); + } + Ok(()) +} diff --git a/ublue/skillet/crates/vm/src/staging.rs b/ublue/skillet/crates/vm/src/staging.rs index 6c7730c2..cb8aeb5b 100644 --- a/ublue/skillet/crates/vm/src/staging.rs +++ b/ublue/skillet/crates/vm/src/staging.rs @@ -211,39 +211,10 @@ fn parse_yaml(path: &Path) -> Result { let contents = fs::read_to_string(path)?; let mut value: Value = serde_yml::from_str(&contents).map_err(|error| Error::Preparation(error.to_string()))?; - normalize_butane_modes(&mut value)?; + crate::install::normalize_butane_modes(&mut value)?; Ok(value) } -/// Butane requires file modes to be numeric. `serde_yml` parses YAML values such -/// as `0644` as strings, so convert octal mode strings before writing staged -/// configs back out. -fn normalize_butane_modes(value: &mut Value) -> Result<()> { - match value { - Value::Mapping(mapping) => { - for (key, value) in mapping.iter_mut() { - if key.as_str() == Some("mode") { - if let Some(mode) = value.as_str() { - let mode = u64::from_str_radix(mode, 8).map_err(|_| { - Error::Invalid(format!("invalid octal Butane file mode: {mode}")) - })?; - *value = Value::Number(mode.into()); - } - } else { - normalize_butane_modes(value)?; - } - } - } - Value::Sequence(values) => { - for value in values { - normalize_butane_modes(value)?; - } - } - _ => {} - } - Ok(()) -} - fn write_yaml_if_changed(path: &Path, value: &Value) -> Result<()> { let contents = serde_yml::to_string(value).map_err(|error| Error::Preparation(error.to_string()))?; diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml index e1fb8f20..e4e1a510 100644 --- a/ublue/skillet/crates/workstation/Cargo.toml +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" workspace = true [dependencies] +base64 = "0.22" +serde_yml.workspace = true skillet_smtp.workspace = true skillet_datadog.workspace = true cloudflare.workspace = true diff --git a/ublue/skillet/crates/workstation/src/installation.rs b/ublue/skillet/crates/workstation/src/installation.rs new file mode 100644 index 00000000..2eb1b1e2 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/installation.rs @@ -0,0 +1,378 @@ +//! Physical install media preparation. Never opens or writes a host block device. +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde_yml::Value; +use sha2::{Digest as _, Sha256}; +use skillet_vm::install::RootProfile; +use std::{ + fs, + io::Write as _, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::Command, + time::Duration, +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid installation request: {0}")] + Invalid(String), + #[error("installation artifact I/O: {0}")] + Io(#[from] std::io::Error), + #[error("installation YAML: {0}")] + Yaml(#[from] serde_yml::Error), + #[error("installation tooling: {0}")] + Tool(#[from] skillet_vm::Error), +} +pub type Result = std::result::Result; + +pub struct Request<'a> { + pub host: &'a str, + pub disk: &'a str, + pub root_profile: RootProfile, + pub iso: &'a Path, + pub output: &'a Path, + pub public_key: &'a Path, + pub password_hash: &'a Path, + pub mok_certificate: Option<&'a Path>, + pub butane: &'a Path, + pub workspace: &'a Path, +} + +/// Prepare an explicitly new private bundle. Existing bundles are never overwritten. +pub fn prepare(request: &Request<'_>) -> Result { + validate_request(request)?; + let key = fs::read_to_string(request.public_key)?; + let hash = fs::read_to_string(request.password_hash)?; + let source = request.butane.join(format!("{}.bu", request.host)); + let mut config: Value = serde_yml::from_str(&fs::read_to_string(source)?)?; + skillet_vm::install::normalize_butane_modes(&mut config)?; + specialize(&mut config, request.disk, request.root_profile)?; + let mut passwd: Value = serde_yml::from_str(&fs::read_to_string( + request.butane.join("includes/passwd.bu"), + )?)?; + configure_access(&mut passwd, &key, &hash)?; + skillet_vm::install::normalize_butane_modes(&mut passwd)?; + let iso = fs::canonicalize(request.iso)?; + if !iso.is_file() { + return Err(Error::Invalid("ISO must be a regular file".into())); + } + // Complete validation before building or publishing any artifacts. + let package = format!("skillet-{}", request.host); + let binaries = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace: request.workspace, + packages: &["skillet", &package], + target: "x86_64-unknown-linux-musl", + profile: "release", + })?; + let parent = request + .output + .parent() + .ok_or_else(|| Error::Invalid("output needs a parent directory".into()))?; + fs::create_dir_all(parent)?; + let staging = tempfile::Builder::new() + .prefix(".skillet-install-") + .tempdir_in(parent)?; + fs::set_permissions(staging.path(), fs::Permissions::from_mode(0o700))?; + let source_dir = stage_source(request, staging.path(), &config, &passwd, &binaries)?; + let mut compiler = Command::new(request.butane.join("bin/butane")); + compiler + .args(["-s", "-o"]) + .arg(staging.path()) + .arg(source_dir.join(format!("{}.bu", request.host))); + run(compiler, "Butane compilation")?; + let ignition_path = staging.path().join(format!("{}.ign", request.host)); + fs::set_permissions(&ignition_path, fs::Permissions::from_mode(0o600))?; + let ignition = fs::read(ignition_path)?; + let helper = fs::read(request.butane.join("bin/install-hardware"))?; + let certificate = request.mok_certificate.map(fs::read).transpose()?; + let live = live_ignition( + request.disk, + request.root_profile, + &ignition, + &helper, + key.trim(), + certificate.as_deref(), + ); + write_private( + &staging.path().join("live.ign"), + &serde_json::to_vec_pretty(&live).map_err(skillet_vm::Error::from)?, + )?; + customize_iso(staging.path(), &iso)?; + // No-clobber directory publication, including concurrent preparations. + fs::create_dir(request.output)?; + fs::set_permissions(request.output, fs::Permissions::from_mode(0o700))?; + for entry in fs::read_dir(staging.path())? { + let entry = entry?; + fs::rename(entry.path(), request.output.join(entry.file_name()))?; + } + Ok(request.output.join("install.iso")) +} + +fn run(command: Command, operation: &str) -> Result<()> { + let output = skillet_vm::capture_command(command, Duration::from_mins(30))?; + if !output.status.success() { + let mut diagnostic = tempfile::Builder::new() + .prefix("skillet-install-diagnostic-") + .suffix(".log") + .tempfile()?; + diagnostic.write_all(&output.stdout)?; + diagnostic.write_all(&output.stderr)?; + let (_, path) = diagnostic.keep().map_err(|error| error.error)?; + return Err(Error::Invalid(format!( + "{operation} failed (exit {:?}); inspect private diagnostic {}. Partial staging was removed", + output.status.code(), path.display() + ))); + } + Ok(()) +} + +fn write_private(path: &Path, data: &[u8]) -> Result<()> { + fs::write(path, data)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +fn specialize(config: &mut Value, disk: &str, profile: RootProfile) -> Result<()> { + skillet_vm::install::configure_disk(config, disk)?; + skillet_vm::install::configure_root(config, profile)?; + Ok(()) +} + +fn configure_access(passwd: &mut Value, public_key: &str, hash: &str) -> Result<()> { + let key = public_key.trim(); + let hash = hash.trim(); + let mut parts = key.split_whitespace(); + if parts.next() != Some("ssh-ed25519") + || parts.next().is_none_or(|part| { + STANDARD.decode(part).map_or(true, |decoded| { + decoded.len() != 51 || !decoded.starts_with(b"\0\0\0\x0bssh-ed25519\0\0\0\x20") + }) + }) + || key.contains(['\n', '\r']) + { + return Err(Error::Invalid( + "provide one OpenSSH Ed25519 public key".into(), + )); + } + if !valid_password_hash(hash) { + return Err(Error::Invalid( + "provide a private crypt-format bootstrap password hash file".into(), + )); + } + let users = passwd + .get_mut("passwd") + .and_then(|v| v.get_mut("users")) + .and_then(Value::as_sequence_mut) + .ok_or_else(|| Error::Invalid("missing production user declarations".into()))?; + let mut found = false; + for user in users { + user["ssh_authorized_keys"] = serde_yml::to_value([key])?; + if user["name"].as_str() == Some("giacomo") { + user["password_hash"] = Value::String(hash.into()); + found = true; + } + } + if !found { + return Err(Error::Invalid("missing provisioning account".into())); + } + Ok(()) +} + +fn live_ignition( + disk: &str, + profile: RootProfile, + ignition: &[u8], + helper: &[u8], + public_key: &str, + certificate: Option<&[u8]>, +) -> serde_json::Value { + let mut files = vec![ + live_file( + "/etc/skillet-install/ignition.sha256", + 0o600, + hex::encode(Sha256::digest(ignition)).as_bytes(), + ), + live_file("/etc/skillet-install/disk", 0o600, disk.as_bytes()), + live_file( + "/etc/skillet-install/profile", + 0o600, + if profile == RootProfile::Tpm { + b"tpm" + } else { + b"unencrypted" + }, + ), + live_file("/var/usrlocal/bin/skillet-install", 0o700, helper), + ]; + if let Some(cert) = certificate { + files.push(live_file("/etc/skillet-install/ublue.der", 0o600, cert)); + } + serde_json::json!({"ignition": {"version": "3.5.0"}, "storage": {"files": files}, "passwd": {"users": [{"name": "core", "sshAuthorizedKeys": [public_key]}]}}) +} +fn live_file(path: &str, mode: u32, contents: &[u8]) -> serde_json::Value { + serde_json::json!({"path": path, "mode": mode, "contents": {"source": format!("data:;base64,{}", STANDARD.encode(contents))}}) +} + +#[cfg(test)] +#[path = "installation/tests.rs"] +mod tests; + +fn reject_symlinks(path: &Path) -> Result<()> { + for component in path.ancestors() { + match fs::symlink_metadata(component) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(Error::Invalid( + "symlink in installation output or source includes".into(), + )) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +fn stage_source( + request: &Request<'_>, + staging: &Path, + config: &Value, + passwd: &Value, + binaries: &std::collections::BTreeMap, +) -> Result { + let package = format!("skillet-{}", request.host); + let source_dir = staging.join("source"); + fs::create_dir_all(source_dir.join("includes"))?; + fs::create_dir(source_dir.join("files"))?; + for entry in fs::read_dir(request.butane.join("includes"))? { + let entry = entry?; + if entry.path().extension().is_some_and(|ext| ext == "bu") { + reject_symlinks(&entry.path())?; + fs::copy( + entry.path(), + source_dir.join("includes").join(entry.file_name()), + )?; + } + } + write_private( + &source_dir.join(format!("{}.bu", request.host)), + serde_yml::to_string(&config)?.as_bytes(), + )?; + write_private( + &source_dir.join("includes/passwd.bu"), + serde_yml::to_string(&passwd)?.as_bytes(), + )?; + for name in ["skillet", package.as_str()] { + let binary = binaries + .get(name) + .ok_or_else(|| Error::Invalid("missing build artifact".into()))?; + let target = source_dir.join("files").join(name); + fs::copy(binary, &target)?; + fs::set_permissions(target, fs::Permissions::from_mode(0o755))?; + } + Ok(source_dir) +} + +fn customize_iso(staging: &Path, iso: &Path) -> Result<()> { + let mut customize = Command::new("podman"); + customize + .args(["run", "--rm", "--security-opt", "label=disable", "--volume"]) + .arg(format!("{}:/work", staging.display())) + .arg("--volume") + .arg(format!("{}:/input.iso:ro", iso.display())) + .args([ + "quay.io/coreos/coreos-installer:release", + "iso", + "customize", + "--live-ignition", + "/work/live.ign", + "--output", + "/work/install.iso", + "/input.iso", + ]); + run(customize, "CoreOS ISO customization")?; + fs::set_permissions( + staging.join("install.iso"), + fs::Permissions::from_mode(0o600), + )?; + Ok(()) +} + +fn validate_request(request: &Request<'_>) -> Result<()> { + skillet_vm::install::validate_physical_disk(request.disk)?; + if !request + .host + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + || request.host.is_empty() + || skillet_hosts::profile_for_name(request.host).is_none() + || !request + .workspace + .join("crates/hosts") + .join(request.host) + .join("Cargo.toml") + .is_file() + { + return Err(Error::Invalid("unknown host profile".into())); + } + if request.root_profile == RootProfile::Tpm && request.mok_certificate.is_none() { + return Err(Error::Invalid( + "TPM media requires --mok-certificate; enroll it before installation".into(), + )); + } + if !request.output.is_absolute() + || request + .output + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(Error::Invalid( + "output must be an absolute path without parent traversal".into(), + )); + } + let workspace = fs::canonicalize(request.workspace)?; + let repository = workspace + .parent() + .and_then(Path::parent) + .ok_or_else(|| Error::Invalid("workspace must belong to a repository".into()))?; + if request.output.starts_with(repository) { + return Err(Error::Invalid( + "private install bundle must be outside the repository".into(), + )); + } + reject_symlinks(request.output)?; + if request.output.exists() { + return Err(Error::Invalid( + "output already exists; choose a new bundle directory".into(), + )); + } + Ok(()) +} + +fn valid_password_hash(hash: &str) -> bool { + fn crypt_text(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"./".contains(&b)) + } + let fields: Vec<_> = hash.split('$').collect(); + match fields.as_slice() { + ["", algorithm @ ("5" | "6"), salt, digest] => { + crypt_text(salt) + && salt.len() <= 16 + && crypt_text(digest) + && digest.len() == if *algorithm == "6" { 86 } else { 43 } + } + ["", algorithm @ ("5" | "6"), rounds, salt, digest] => { + rounds + .strip_prefix("rounds=") + .is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) + && valid_password_hash(&format!("${algorithm}${salt}${digest}")) + } + ["", "y", parameters, salt, digest] => { + crypt_text(parameters) && crypt_text(salt) && crypt_text(digest) && digest.len() == 43 + } + _ => false, + } +} diff --git a/ublue/skillet/crates/workstation/src/installation/tests.rs b/ublue/skillet/crates/workstation/src/installation/tests.rs new file mode 100644 index 00000000..4c8491ab --- /dev/null +++ b/ublue/skillet/crates/workstation/src/installation/tests.rs @@ -0,0 +1,134 @@ +use super::*; +fn host() -> Value { + serde_yml::from_str(include_str!("../../../../../butane/clamps.bu")).unwrap() +} +fn passwd() -> Value { + serde_yml::from_str(include_str!("../../../../../butane/includes/passwd.bu")).unwrap() +} +#[test] +fn physical_profiles_preserve_vm_source() { + let original = host(); + for profile in [RootProfile::Unencrypted, RootProfile::Tpm] { + let mut config = original.clone(); + specialize(&mut config, "/dev/disk/by-id/ata-example", profile).unwrap(); + assert_eq!( + config["storage"]["disks"][0]["device"].as_str(), + Some("/dev/disk/by-id/ata-example") + ); + assert_eq!( + config["storage"]["filesystems"][0]["device"].as_str(), + Some(if profile == RootProfile::Tpm { + "/dev/mapper/root" + } else { + "/dev/disk/by-partlabel/root" + }) + ); + assert_eq!( + config["storage"]["disks"][0]["partitions"][0]["resize"].as_bool(), + Some(true) + ); + } + assert_eq!(host(), original); + assert_eq!( + original["storage"]["disks"][0]["device"].as_str(), + Some("/dev/vda") + ); +} +#[test] +fn disk_selection_rejects_partitions_ambient_devices_and_conflicts() { + for disk in [ + "/dev/sda", + "/dev/vda", + "/dev/disk/by-id/x-part1", + "/dev/disk/by-id/../x", + "/dev/disk/by-id/", + "/dev/disk/by-id/x\n", + ] { + assert!(specialize(&mut host(), disk, RootProfile::Unencrypted).is_err()); + } + let mut config = host(); + config["storage"]["disks"][0]["device"] = Value::String("/dev/sda".into()); + assert!(specialize(&mut config, "/dev/disk/by-id/ata-example", RootProfile::Tpm).is_err()); +} +#[test] +fn production_access_replaces_inherited_keys_and_hash() { + let key = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG5BCzEZkae5DssrD+FFi0a4YGYT55b57LbXW8SveytN operator"; + let mut value = passwd(); + let hash = format!("$6$testSalt${}", "A".repeat(86)); + configure_access(&mut value, key, &hash).unwrap(); + for user in value["passwd"]["users"].as_sequence().unwrap() { + assert_eq!(user["ssh_authorized_keys"].as_sequence().unwrap().len(), 1); + assert_eq!(user["ssh_authorized_keys"][0].as_str(), Some(key)); + } + assert!(value["passwd"]["users"][1]["password_hash"] + .as_str() + .is_some_and(|value| value == hash)); + assert!(configure_access(&mut passwd(), key, "$6$malformed").is_err()); + assert!(value.get("storage").is_none()); + assert!(configure_access(&mut passwd(), "not-a-public-key", "password").is_err()); +} +#[test] +fn numeric_modes_and_live_media_require_manual_installation() { + let mut value = host(); + skillet_vm::install::normalize_butane_modes(&mut value).unwrap(); + assert_eq!(value["storage"]["files"][0]["mode"].as_u64(), Some(420)); + let live = live_ignition( + "/dev/disk/by-id/ata-example", + RootProfile::Tpm, + b"private ignition", + b"helper", + "ssh-ed25519 test-public-key", + Some(b"certificate"), + ); + assert!(live.get("systemd").is_none()); + assert_eq!(live["passwd"]["users"][0]["name"], "core"); + assert_eq!( + live["passwd"]["users"][0]["sshAuthorizedKeys"][0], + "ssh-ed25519 test-public-key" + ); + assert!(serde_json::to_vec(&live).unwrap().len() < 262_144); + assert_eq!(live["storage"]["files"][0]["mode"], 384); + assert_eq!(live["storage"]["files"][3]["mode"], 448); + assert_eq!(live["storage"]["files"].as_array().unwrap().len(), 5); + assert_eq!( + live["storage"]["files"][0]["contents"]["source"], + format!( + "data:;base64,{}", + STANDARD.encode(hex::encode(Sha256::digest(b"private ignition"))) + ) + ); +} + +#[test] +fn requests_reject_missing_tpm_certificate_unknown_hosts_and_public_outputs() { + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let butane = workspace.join("../butane"); + let temporary = tempfile::tempdir().unwrap(); + let output = temporary.path().join("bundle"); + let mut request = Request { + host: "clamps", + disk: "/dev/disk/by-id/ata-example", + root_profile: RootProfile::Unencrypted, + iso: Path::new("missing.iso"), + output: &output, + public_key: Path::new("missing.pub"), + password_hash: Path::new("missing.hash"), + mok_certificate: None, + butane: &butane, + workspace: &workspace, + }; + validate_request(&request).unwrap(); + request.root_profile = RootProfile::Tpm; + assert!(validate_request(&request).is_err()); + request.root_profile = RootProfile::Unencrypted; + request.host = "unknown-profile"; + assert!(validate_request(&request).is_err()); + request.host = "clamps"; + let public_output = fs::canonicalize(&workspace).unwrap().join("install-bundle"); + request.output = &public_output; + assert!(validate_request(&request).is_err()); + request.output = &output; + fs::create_dir(&output).unwrap(); + assert!(validate_request(&request).is_err()); +} diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs index 579454c5..a7736f33 100644 --- a/ublue/skillet/crates/workstation/src/lib.rs +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -15,3 +15,5 @@ pub mod vault; pub mod zone_token; pub mod root_recovery; + +pub mod installation; From 3ca517e73d5b345c112e2123781c60fb915b0de3 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 01:08:12 +0200 Subject: [PATCH 242/252] docs(installation): record USB media and VM regression acceptance --- ublue/butane/ACCEPTANCE.md | 37 ++++++++++++++++++++++++++++++++++ ublue/butane/HARDWARE.md | 4 ++-- ublue/plan/CLAMPS-MIGRATION.md | 4 +++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index f337318d..2e173205 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2353,3 +2353,40 @@ fake transports; this live run used the cached vault unlock. Still unverified: physical TPM/firmware compatibility and rescue installation, Flatpak encrypted-VM acceptance, offline-network and remaining boot-policy / update / rollback matrix cases. A header backup is not a service-data backup. + + +## 2026-10-11 — physical installation media + +- `skillet install prepare HOST` selects an explicit whole-disk by-id identifier, + private bootstrap key/hash, and independent plain/TPM root profile. It leaves + the committed VM `/dev/vda` template unchanged and never writes a block device. +- Real FCOS 44.20260913.3.2 x86_64 live ISO downloaded and signature-verified by + the existing CoreOS installer container. Plain and TPM USB ISOs generated + successfully using disposable access inputs; TPM used a disposable DER + certificate, not an enrolled hardware key. +- Extracted both ISOs with `iso ignition show`, recursively decoded their merge + payloads, and verified the embedded destination-Ignition digests. Destination + storage selected the requested by-id disk; plain retained raw Btrfs and TPM + selected LUKS2 -> `/dev/mapper/root` -> Btrfs with networkless Clevis policy. + Verified private output-directory and Ignition permissions and replaced access + inputs without printing credential values. +- Full destination payload exceeded CoreOS's 256 KiB embedding limit. The + implemented flow embeds only the live helper/policy, operator public key and + payload digest; destination Ignition is transferred over verified SSH or + separate data media and verified in root-only live tmpfs before installation. +- CI/local checks: fmt, pedantic Clippy, 370 workspace/all-target tests, Fedora + base integration (two applies, no repeated service starts), ShellCheck, + all eight bootstrap regressions and isolated hardware-helper refusal tests + passed. CI now lints the live helper and rejects mismatched payloads/absent + target disks without vault/provider access or host block-device exposure. +- Native `clamps / physical-regression` on port 2202 booted the unchanged + `/dev/vda` installation, accepted SSH/binary delivery and reached signed + bootstrap. Initial readiness was paused to inspect slow image-layer downloads; + one retry lost SSH during a bootstrap reboot. The following retry reached + base/user-environment apply and temporarily received SSH connection resets. + After a quiet interval and retry, full readiness passed with recorded phase + `Ready`, including base apply, user environment and artifact verification. + Private evidence was retained before disposing of this regression VM. +- Unverified: booting the new USB ISO itself, actual S13 disk installation, + physical TPM/Secure Boot/MOK enrollment, LAN and production cutover. See + [the physical runbook](HARDWARE.md). Existing smoke VM was not modified. diff --git a/ublue/butane/HARDWARE.md b/ublue/butane/HARDWARE.md index 3f9f6848..d761cb5a 100644 --- a/ublue/butane/HARDWARE.md +++ b/ublue/butane/HARDWARE.md @@ -106,8 +106,8 @@ See [storage policy](../design/storage.md) and [remaining encryption checks](../ The helper copies the supplied file into root-only live tmpfs and checks its SHA-256 against the digest embedded in this USB. A payload from another bundle - is refused. CoreOS's 256 KiB embedded-config area cannot hold our binaries; - the destination Ignition remains in the private bundle and travels over SSH. + is refused. Destination Ignition remains in the private bundle and travels + over SSH. See [the media design](../design/storage.md#physical-installation-media). An offline alternative is a separate data USB: mount it and pass the Ignition path to the same helper. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index db1bf00f..50fb675d 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -91,7 +91,9 @@ deferred; remote hosts will first be assessed for usable TPM support. - Physical USB media generation now selects a whole-disk by-id identifier and reuses the shared root-profile renderer; VM creation retains `/dev/vda`. Follow [the hardware runbook](../butane/HARDWARE.md). Actual disk selection, firmware - checks, MOK enrollment and stock-SSD installation remain unverified. + checks, MOK enrollment and stock-SSD installation remain unverified. Fresh + native `/dev/vda` regression reached signed boot and full readiness after + transient bootstrap/SSH retries; evidence is in the acceptance record. ## Milestone 3: storage and credentials for the first application From 42377552c3b12b76fc158910329f810b5f47c633 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 11:09:59 +0200 Subject: [PATCH 243/252] docs(fleet): reconcile clamps gates and plan boxy replacement --- ublue/design/fleet-hosts.md | 21 ++++++++++++ ublue/plan/BEELZEBOT-MIGRATION.md | 57 +++++++++++++++++++++++++++++++ ublue/plan/CLAMPS-MIGRATION.md | 48 +++++++++++++++++++------- ublue/plan/CLAMPS-PARITY.md | 13 +++---- 4 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 ublue/design/fleet-hosts.md create mode 100644 ublue/plan/BEELZEBOT-MIGRATION.md diff --git a/ublue/design/fleet-hosts.md b/ublue/design/fleet-hosts.md new file mode 100644 index 00000000..e54c6875 --- /dev/null +++ b/ublue/design/fleet-hosts.md @@ -0,0 +1,21 @@ +# Fleet host declarations + +Boxy is replaced by **beelzebot**; rupik by clamps; calculon by bender. +The host profile is the authority for services, UI routes, credentials, boot +expectations and acceptance. Shared recipes own behavior; host callers opt in. +Beelzebot selects Pi-hole, Syncthing, Tailscale, btrbk, DDNS, Datadog and shared +SMTP. UniFi remains selected only where declared. + +Decision, 2026-10-11: correct the old beezelbot spelling with no compatibility, +explicitly authorized by the operator. No manifest or vault entry is silently +renamed. Historical acceptance commands preserve the spelling actually executed. +Current CLI selectors, binary/package names, templates and images use beelzebot. + +Both servers share the Btrfs data layout and credential-gated full-apply unit. +The full unit applies to profiles requiring Pi-hole and Tailscale credentials; +future capability sets must select an appropriate activation unit. Per-host +includes select artifacts/images; generic units read `/etc/skillet/host`. + +Boxy's subnet-routing and private cross-host routes/probes need separate policy +and private inventory. Selecting the current shared modules does not claim full +live source parity. See [the migration plan](../plan/BEELZEBOT-MIGRATION.md). diff --git a/ublue/plan/BEELZEBOT-MIGRATION.md b/ublue/plan/BEELZEBOT-MIGRATION.md new file mode 100644 index 00000000..71a55d7e --- /dev/null +++ b/ublue/plan/BEELZEBOT-MIGRATION.md @@ -0,0 +1,57 @@ +# Boxy → beelzebot + +Status: first implementation slice, 2026-10-11. Review completed against +`roles/boxy.rb` and `site-cookbooks/boxy/recipes/default.rb` and its includes. +Private Chef overrides and live boxy state have not been inventoried. +Clamps remaining work is tracked in [its migration plan](CLAMPS-MIGRATION.md). + +## Source parity + +| Boxy behavior | Target / remaining work | +| --- | --- | +| One Btrfs filesystem, separate data subvolume; rootful/rootless Podman stores | Shared root + `/var/lib/data`; rootful overlay graphroot; retain rootless storage only for workloads that need it | +| Pi-hole; resolved disabled | Selected shared Pi-hole and bridge DNS policy; image/day-zero mask; real private DNS inputs and LAN acceptance pending | +| Syncthing data `/var/lib/data/sync`, dedicated account and ACLs | Shared `/var/lib/data/syncthing` leaf, declared host ownership/user mapping; transfer folder definitions, identity and ACLs explicitly, never copy live identity into smoke | +| btrbk snapshots `sync` only | Caller opts in for `syncthing` only; preserve leaf-only snapshots and settle production retention/off-machine backup | +| nginx/OAuth/ACME, local and cross-host routes | Generic Caddy, declared Pi-hole/Syncthing UIs and service alias policy; Cloudflare credentials and tailnet ingress reused; inventory needed cross-host routes before omitting them | +| Host-network Tailscale, exit node, LAN subnet advertisement, accept routes, SNAT disabled | Shared host-network persistent Tailscale/exit-node selection; **subnet advertisement, route acceptance and SNAT policy are not yet implemented** | +| Cloudflare DDNS | Shared selected updater/template; per-environment, per-host private record-name entry; one writer at production cutover | +| Datadog host/process/network checks and private cross-host probes | Shared selected Agent, production region `ftwo`, test `lab`/`env:test`; private remote probes not yet ported | +| ET and server baseline | Common image + shared hardening/user environment; shared SMTP also selected for every fleet server | + +UniFi is absent from boxy's default recipe; beelzebot does not select it. +No private addresses, domains, keys or effective Chef overrides belong here. + +## Implementation order and exits + +1. **Host and boot slice:** canonical `beelzebot` crate/profile/image, shared + full-apply unit, host Butane with existing `/dev/vda` and Btrfs layout. + Remove the old misspelling without compatibility at operator request. + Profile selection must drive UIs, credentials, monitoring and snapshots; + tests cover those declarations and bootstrap inputs. The matching image + recipe must mask resolved. Validate strict Butane compilation, local CI + and a fresh named VM's signed boot/base readiness. Real application and + provider checks remain separate. +2. **Tailscale routing parity:** add explicit shared caller-selected routing + policy and private delivery for subnet values. Preserve existing enrollment + compatibility, converge preferences after authentication, and force disposable + policy to omit advertisements. Test rendering, validation, repeat convergence + and no production routes on smoke machines. Do not publish the LAN subnet. +3. **Named application acceptance:** unlock with the existing workflow; run + beelzebot full provisioning and capability-selected repeat-apply/reboot checks, + then opted-in UI, DDNS, Datadog and isolated SMTP checks. Use disposable + identities, test DNS and lifecycle-owned cleanup. No production writer/peer + identity during acceptance; record failed and unavailable checks honestly. +4. **Private inventory and cutover:** inspect live boxy storage/subvolumes, + Syncthing identity/folders/ACLs, Pi-hole settings, routing and cross-host + monitoring/UI needs. Select real hardware/disk/root profile, snapshot/backup + retention and rollback. Stage copies with original writers stopped as needed; + do LAN/device and production certificate acceptance before moving addresses. + +Use `skillet test vm create beelzebot INSTANCE --port FREE_PORT`, then +`test vm ready`, `test vm provision`, `test smoke --with-applications` and +`test vm destroy` through the shared Rust lifecycle owner. Provisioning may +need cached vault access; do independent work if unlock would block. Install +nothing on the workstation. Commit focused changes after validation; do not push. + +Evidence: [ACCEPTANCE.md](../butane/ACCEPTANCE.md). diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 50fb675d..31ccb3d7 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -4,12 +4,33 @@ Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) before proceeding with the remaining feature milestones in this plan. Existing acceptance evidence and deferred tasks remain applicable. -Status: reconciled against source and acceptance evidence, 2026-10-06. -Refactor implementation is committed; Syncthing live validation is parked and -remote CI evidence is pending. Parity auditing and DDNS implementation proceeded -while that check is parked. Skillet convergence and disposable VM bootstrap exist. -Runtime evidence and outstanding bootstrap checks are recorded in -[ACCEPTANCE.md](../butane/ACCEPTANCE.md). +Status: reviewed against source and recorded acceptance, 2026-10-11. +Shared services, native TPM recovery and physical USB media preparation are +implemented. Clamps is not production-ready until the physical/cutover gates +below pass. The parked Flatpak/Syncthing and deferred network checks remain +open; previous passing native VM checks do not close them. + +## What remains for clamps + +1. Boot the prepared USB on the S13; inspect firmware/TPM and select its stock + SSD explicitly. Enroll the trusted MOK when using Secure Boot. Validate the + selected plain or TPM profile on hardware, recovery archive and cold boot. +2. Supply private production DNS/DDNS records and keys, migrate service data + and identities from rupik, and decide retained cross-host routes and WOL + targets. Keep the old DNS service and Samsung SSD until the cutover. +3. Validate real LAN IPv4/IPv6 UDP/TCP DNS, peer connectivity, physical SSH/ET, + UniFi devices/cloud access, production ACME/renewal and outside-tailnet denial. + Approve the production exit node; routed-traffic checks remain omitted by + operator decision. Finish automated delivery before interactive access + finalization and first-login password-change acceptance. +4. Verify physical repeat apply, reboot and OS update; take service-data backups, + choose off-machine backup retention, rehearse rollback and agree the cutover. + Finish or explicitly defer the outstanding boot-policy/transport-interruption + tests. Broader OS hardening is still a placeholder, not an accepted port. + +No new clamps service crate is required to start +[boxy → beelzebot](BEELZEBOT-MIGRATION.md). Acceptance details remain in +[ACCEPTANCE.md](../butane/ACCEPTANCE.md) and the service plans. ## Goal and starting point @@ -27,9 +48,10 @@ production/network acceptance, and remaining rebase/bootstrap checks stay open. The [source parity checklist](CLAMPS-PARITY.md) records retained behavior and deliberate changes. [Cloudflare DDNS](CLAMPS-CLOUDFLARE-DDNS.md) has its guest service, production delivery, and disposable ownership/cleanup implementation. -Live disposable DNS acceptance awaits the private test DDNS name entry; production -cutover remains separate. Datadog is retained by user decision; its port and -UniFi backup/restore are the next implementation milestones. +Live disposable DNS publication and cleanup passed; rotation and production +cutover remain separate. Datadog live acceptance, isolated UniFi backup restore +and operator UI inspection passed. Shared SMTP delivery to Gmail passed; +UniFi email setup is parked by operator decision. After the refactoring prerequisite: complete Pi-hole LAN acceptance and configure the actual custom DNS @@ -134,11 +156,11 @@ Each row is a separate implementation and validation step. Adjust order for depe | --- | --- | --- | | 1 | Syncthing (implemented; ownership correction committed; live retry parked) | Data, device identity, folder configuration, UID/GID, required ports; production peer and state transfer remain | | 2 | btrbk (implemented and verified) | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | -| 3 | UniFi (implemented; empty-controller/repeat-apply acceptance passed) | Follow the [UniFi port plan](CLAMPS-UNIFI.md): rootful host-network container, persistent data, and isolated backup restore/version compatibility before cutover. No Caddy route. | +| 3 | UniFi (implemented; isolated restore and operator inspection passed) | Rootful host-network container and persistent data accepted; production devices/cloud access remain cutover checks. See [UniFi port plan](CLAMPS-UNIFI.md). No Caddy route. | | 4 | Tailscale (implemented; smoke enrollment and cleanup accepted) | Host network, state, OAuth enrollment, and cleanup exist. Caller-selected production exit-node advertisement is implemented, disabled for smoke VMs. Existing forwarding policy is retained; approve the production device in tailnet policy. Live routing exercises are omitted by operator decision. UI names use Cloudflare DNS-only records. | | 5 | Private UI access with Caddy + ACME (implemented; staging accepted) | Generic declarations/aliases, encrypted delivery, scoped account tokens, A/AAAA/CNAME reconciliation and cleanup exist. Staging ACME/HTTPS passed. Production renewal, real-client resolution and separate-network denial remain; see [UI plan](GENERIC-PRIVATE-UIS.md). | -| 6 | Cloudflare DDNS | Guest service, production delivery, and journaled disposable lifecycle implemented; live disposable acceptance awaits test config and production one-writer handover remains separate; see [DDNS plan](CLAMPS-CLOUDFLARE-DDNS.md) | -| 7 | Datadog and remaining host baseline | Datadog implementation and live VM acceptance passed. Next audit hardening, users, SSH/sudo, ET and WOL targets against the [parity checklist](CLAMPS-PARITY.md) | +| 6 | Cloudflare DDNS | Guest service, production delivery, and journaled disposable lifecycle implemented; live disposable publication/cleanup passed; explicit rotation and production one-writer handover remain separate; see [DDNS plan](CLAMPS-CLOUDFLARE-DDNS.md) | +| 7 | Datadog and remaining host baseline | Datadog implementation and live VM acceptance passed. Baseline audit and access finalization are implemented; physical checks remain in the [parity checklist](CLAMPS-PARITY.md) | For each service: inspect effective Chef inputs, settle its open decisions, add only needed Skillet support, migrate a copy of state, validate functionality and repeat convergence. Account for ARM-to-x86 application/image and data compatibility. Avoid activating duplicate production identities or DNS writers during testing. @@ -169,7 +191,7 @@ Done when: all retained capabilities and data work on clamps, routine convergenc passed. - Credentials: production TPM/rebase policy and protection of Podman's on-disk copy; portable storage and delivery are decided in the secret design. - Services: actual DNS/DDNS records, retained cross-host routes, exit-node operation, - Nebula Sync, and legacy networking. Datadog retention is decided; its port is pending. + Nebula Sync, and legacy networking. Datadog is retained and its live VM acceptance passed. - Cutover: address/identity transitions, outage window and rollback duration. ## Technical references diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 9fbdcf9e..9add4a3d 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -1,6 +1,6 @@ # Rupik to Clamps parity checklist -Status: source audit updated 2026-10-09; baseline VM observations recorded in +Status: source/plan review updated 2026-10-11; baseline VM observations recorded in `CLAMPS-BASELINE-AUDIT.md`. This compares the repository's Chef include path with current Skillet, Butane, and image definitions. It is not an inventory of effective private Chef attributes or live Rupik state. Never copy production @@ -12,7 +12,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | Chef capability | Clamps implementation / deliberate change | Remaining work | | --- | --- | --- | -| Btrfs `/srv` and system/user Podman stores | Shared `/var/lib/data` subvolume; system graphroot under `containers/system`, overlay driver on uCore | Final physical disk, encrypted backing filesystem, state transfer; rootless store only if a retained workload needs it | +| Btrfs `/srv` and system/user Podman stores | Shared `/var/lib/data` subvolume; system graphroot under `containers/system`, overlay driver on uCore | Native TPM/plain profiles and USB preparation implemented; physical acceptance and state transfer; rootless store only if a retained workload needs it | | Disable resolved; Pi-hole | Mask/resolver setup, dual-stack port 53, persistent service data, encrypted password, shared bridge | Real LAN/firewall acceptance, actual custom DNS configuration, data transfer | | nginx, ACME, OAuth proxy, web pod | Separate bridge containers; generic Caddy routes, Cloudflare DNS-01, tailnet restriction, per-service names and aliases | Production certificate/renewal and external-source denial; inventory any legacy cross-host routes still needed | | Syncthing | Container and leaf data subvolume; peer ports retained; host GUI publication removed; explicit host/container identity mapping | PARKED: live ownership fix acceptance; production peer connectivity and identity/data migration | @@ -44,10 +44,11 @@ exists. Additional private run-list recipes remain an inventory question. delivery to Gmail also passed; UniFi email setup is parked. Retain physical Secure Boot, provider tag read-back and richer monitoring follow-ups in `CLAMPS-DATADOG.md`. -3. Production exit-node configuration is implemented; local policy checks passed. - Next: resolve account/bootstrap policy and reconcile broader OS hardening - using the ordered follow-ups in `CLAMPS-BASELINE-AUDIT.md`; audit completed - without configuration changes. +3. Production exit-node selection, baseline audit and explicit administrative + access finalization are implemented. Native TPM recovery/header archival and + physical USB generation passed disposable acceptance. Actual S13 firmware, + first-login password change and deployment remain unverified; see + `TPM-ENCRYPTED-ROOT.md` and `../butane/HARDWARE.md`. 4. Run the parked VM and deferred network/production acceptance when their required infrastructure is available; transfer state only with a recovery path. From 13961a1d6fa6ed6145ec99cb112ff7f534d8856b Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 11:10:40 +0200 Subject: [PATCH 244/252] feat(beelzebot): compose boxy services and shared VM bootstrap --- .github/workflows/ci.yml | 2 +- ublue/AGENTS.md | 7 +- ublue/butane/README.md | 14 +- ublue/butane/beelzebot.bu | 46 ++++ ublue/butane/clamps.bu | 1 + ublue/butane/includes/skillet-beelzebot.bu | 8 + ublue/butane/includes/skillet-clamps.bu | 23 -- ublue/butane/includes/skillet-full.bu | 27 +++ ublue/butane/includes/ucore-beelzebot.bu | 14 ++ ublue/butane/tests/bootstrap.sh | 3 + ublue/design/syncthing.md | 10 +- ublue/design/tailscale.md | 6 +- ublue/plan/GENERIC-PRIVATE-UIS.md | 6 +- ublue/plan/REFACTOR-02-HOST-PROFILES.md | 2 +- ublue/skillet/AGENTS.md | 2 +- ublue/skillet/Cargo.lock | 3 +- ublue/skillet/Cargo.toml | 2 +- ublue/skillet/README.md | 11 +- ublue/skillet/crates/caddy/src/tests.rs | 16 +- .../cli/src/application_acceptance_tests.rs | 22 +- ublue/skillet/crates/cli/src/main.rs | 2 +- .../crates/cli/src/secret_delivery/tests.rs | 19 +- .../crates/host-profiles/src/hosts_tests.rs | 210 ++++++++++++++---- .../crates/host-profiles/src/profile.rs | 71 ++++-- .../hosts/{beezelbot => beelzebot}/Cargo.toml | 3 +- .../{beezelbot => beelzebot}/src/main.rs | 4 +- .../src/credential_delivery/tests.rs | 4 +- .../src/ddns_provisioning/tests.rs | 2 +- .../workstation/src/root_recovery/tests.rs | 2 +- .../crates/workstation/src/secrets/tests.rs | 16 +- .../workstation/src/ui_provisioning/tests.rs | 10 +- 31 files changed, 417 insertions(+), 151 deletions(-) create mode 100644 ublue/butane/beelzebot.bu create mode 100644 ublue/butane/includes/skillet-beelzebot.bu create mode 100644 ublue/butane/includes/skillet-full.bu create mode 100644 ublue/butane/includes/ucore-beelzebot.bu rename ublue/skillet/crates/hosts/{beezelbot => beelzebot}/Cargo.toml (69%) rename ublue/skillet/crates/hosts/{beezelbot => beelzebot}/src/main.rs (56%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ab26e5f..9e1eeff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,7 @@ jobs: if: steps.filter.outputs.rust == 'true' run: | cd ublue/skillet - cargo run --bin skillet -- test run beezelbot --phase base --image fedora:latest + cargo run --bin skillet -- test run beelzebot --phase base --image fedora:latest - name: Run installation and bootstrap regressions if: steps.filter.outputs.rust == 'true' diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 6d9a4e11..99e8c2d3 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -144,7 +144,7 @@ `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace --all-targets`, and - `cargo run --bin skillet -- test run beezelbot --phase base --image fedora:latest` + `cargo run --bin skillet -- test run beelzebot --phase base --image fedora:latest` from `.github/workflows/ci.yml`. For shell changes, run the workflow's ShellCheck commands; for Python or Chef changes, run the corresponding Ruff/Mypy or Cookstyle commands. @@ -156,3 +156,8 @@ - If a required check cannot run because a tool, runtime, or external service is unavailable, record the exact reason and report the check as unverified; do not describe it as passing or commit as though it passed. + +- Keep canonical host spelling consistent across profile, package, binary, + installation templates and image. Identity renames require an explicit + compatibility decision; never silently rewrite retained manifests or vault + entries. Preserve historical acceptance evidence as recorded. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 16a967e2..2e75719a 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -1,8 +1,20 @@ -# Clamps VM bootstrap +# Fleet VM bootstrap For real servers, use the separate [physical installation flow](HARDWARE.md). VM creation keeps its `/dev/vda` template and independent root-profile choice. +Available VM templates: `clamps.bu` and `beelzebot.bu`. Both retain `/dev/vda` +and the shared Btrfs data layout. Use `beelzebot` for boxy's replacement; the +old misspelled selector/package is removed. [Migration plan](../plan/BEELZEBOT-MIGRATION.md). + +```bash +./bin/test-vm beelzebot create smoke --port 2202 +./bin/test-vm beelzebot ready smoke +./bin/test-vm beelzebot destroy smoke +``` + +Choose a free port if another VM already uses 2202. + A clean disposable VM create passed signed boot and readiness on 2026-09-26. The Flatpak backend on Bazzite passed create, status, and readiness on 2026-09-27. See [ACCEPTANCE.md](ACCEPTANCE.md) for the checks performed and remaining work. diff --git a/ublue/butane/beelzebot.bu b/ublue/butane/beelzebot.bu new file mode 100644 index 00000000..10632dde --- /dev/null +++ b/ublue/butane/beelzebot.bu @@ -0,0 +1,46 @@ +variant: fcos +version: 1.6.0 +# VM-only layout: the Skillet VM provisioner attaches FCOS as virtio /dev/vda. +# A physical installer must use an explicitly selected hardware disk instead. +ignition: + config: + merge: + - local: includes/passwd.bu + - local: includes/zram.bu + - local: includes/resolver.bu + - local: includes/data-storage.bu + - local: includes/brew.bu + - local: includes/skillet.bu + - local: includes/skillet-beelzebot.bu + - local: includes/skillet-full.bu + - local: includes/ucore-bootstrap.bu + - local: includes/ucore-beelzebot.bu +storage: + disks: + - # The link to the block device the OS was booted from. + device: /dev/vda + # We do not want to wipe the partition table since this is the primary + # device. + wipe_table: false + partitions: + - number: 4 + label: root + size_mib: 0 + resize: true + filesystems: + - device: /dev/disk/by-partlabel/root + wipe_filesystem: true + format: btrfs + label: root + links: + - path: /etc/localtime + target: ../usr/share/zoneinfo/Europe/Madrid + files: + - path: /etc/skillet/host + mode: 0644 + contents: + inline: beelzebot + - path: /etc/hostname + mode: 0644 + contents: + inline: beelzebot diff --git a/ublue/butane/clamps.bu b/ublue/butane/clamps.bu index 79a83e4e..208b3378 100644 --- a/ublue/butane/clamps.bu +++ b/ublue/butane/clamps.bu @@ -12,6 +12,7 @@ ignition: - local: includes/brew.bu - local: includes/skillet.bu - local: includes/skillet-clamps.bu + - local: includes/skillet-full.bu - local: includes/ucore-bootstrap.bu - local: includes/ucore-clamps.bu storage: diff --git a/ublue/butane/includes/skillet-beelzebot.bu b/ublue/butane/includes/skillet-beelzebot.bu new file mode 100644 index 00000000..714c814f --- /dev/null +++ b/ublue/butane/includes/skillet-beelzebot.bu @@ -0,0 +1,8 @@ +variant: fcos +version: 1.6.0 +storage: + files: + - path: /var/usrlocal/bin/skillet-beelzebot + mode: 0755 + contents: + local: files/skillet-beelzebot diff --git a/ublue/butane/includes/skillet-clamps.bu b/ublue/butane/includes/skillet-clamps.bu index 0901c657..82dc1302 100644 --- a/ublue/butane/includes/skillet-clamps.bu +++ b/ublue/butane/includes/skillet-clamps.bu @@ -6,26 +6,3 @@ storage: mode: 0755 contents: local: files/skillet-clamps -systemd: - units: - - name: skillet-full-apply.service - enabled: true - contents: | - [Unit] - Description=Apply clamps application configuration - Requires=skillet-apply.service - After=skillet-apply.service - Requires=skillet-data-prepare.service - After=skillet-data-prepare.service - AssertPathIsMountPoint=/var/lib/data - ConditionPathExists=/etc/credstore.encrypted/skillet/pihole_web_password.cred - ConditionPathExists=/etc/credstore.encrypted/skillet/tailscale_auth_key.cred - - [Service] - Type=oneshot - LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred - LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred - ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase full - - [Install] - WantedBy=multi-user.target diff --git a/ublue/butane/includes/skillet-full.bu b/ublue/butane/includes/skillet-full.bu new file mode 100644 index 00000000..f8c61c3d --- /dev/null +++ b/ublue/butane/includes/skillet-full.bu @@ -0,0 +1,27 @@ +# Shared full-apply activation for hosts declaring Pi-hole and Tailscale. +# Optional service phases remain independently credential-gated in skillet.bu. +variant: fcos +version: 1.6.0 +systemd: + units: + - name: skillet-full-apply.service + enabled: true + contents: | + [Unit] + Description=Apply declared Skillet application configuration + Requires=skillet-apply.service + After=skillet-apply.service + Requires=skillet-data-prepare.service + After=skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data + ConditionPathExists=/etc/credstore.encrypted/skillet/pihole_web_password.cred + ConditionPathExists=/etc/credstore.encrypted/skillet/tailscale_auth_key.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred + LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase full + + [Install] + WantedBy=multi-user.target diff --git a/ublue/butane/includes/ucore-beelzebot.bu b/ublue/butane/includes/ucore-beelzebot.bu new file mode 100644 index 00000000..2fbf7c3a --- /dev/null +++ b/ublue/butane/includes/ucore-beelzebot.bu @@ -0,0 +1,14 @@ +# Host-selected signed image and day-zero DNS listener policy. +variant: fcos +version: 1.6.0 +storage: + files: + - path: /etc/ucore-bootstrap-image + mode: 0644 + contents: + inline: | + ghcr.io/gbagnoli/ucore-beelzebot +systemd: + units: + - name: systemd-resolved.service + mask: true diff --git a/ublue/butane/tests/bootstrap.sh b/ublue/butane/tests/bootstrap.sh index 829a7a74..1e1db5a9 100644 --- a/ublue/butane/tests/bootstrap.sh +++ b/ublue/butane/tests/bootstrap.sh @@ -67,6 +67,9 @@ check fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' " check bender_fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ 'ostree-unverified-registry:ghcr.io/gbagnoli/ucore-bender:latest' 1 0 0 '' 0 \ 'ghcr.io/gbagnoli/ucore-bender' +check beelzebot_fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ + 'ostree-unverified-registry:ghcr.io/gbagnoli/ucore-beelzebot:latest' 1 0 0 '' 0 \ + 'ghcr.io/gbagnoli/ucore-beelzebot' check unsigned_with_signed_rollback \ "{\"deployments\":[{\"booted\":true,\"container-image-reference\":\"$unsigned\"},{\"booted\":false,\"container-image-reference\":\"$signed\"}]}" \ "$signed" 1 0 0 diff --git a/ublue/design/syncthing.md b/ublue/design/syncthing.md index ea65e8ca..0d478d53 100644 --- a/ublue/design/syncthing.md +++ b/ublue/design/syncthing.md @@ -1,11 +1,9 @@ # Syncthing service -Run Syncthing as its own Quadlet container on the shared clamps bridge, with +Run Syncthing as its own Quadlet container on the host's declared bridge, with `ContainerName=syncthing`. Caddy can reach its UI at -`http://syncthing:8384`. The current implementation retains rupik's host port -publications for the UI and transfer protocol. Before using tailnet identity as -the UI access gate, remove or locally restrict the GUI publication; preserve -the peer-transfer ports. See the [private UI design](private-ui-access.md). +`http://syncthing:8384`. The GUI port is not published on the host; +peer-transfer ports remain published. Tailnet-restricted Caddy provides UI access. See the [private UI design](private-ui-access.md). Mount `/var/lib/data/syncthing` at `/var/syncthing`. Keep the upstream directory layout there, including `config/` and synced folders, so a planned @@ -14,7 +12,7 @@ directory is a Btrfs subvolume owned by the declared host account. Syncthing runs as container UID/GID 1000; Podman's user namespace maps that container identity to the host account and its primary group. Keep host ownership, container identity, and namespace mapping explicit and separate. Never copy -rupik's live identity into a disposable VM. +a source server's live identity into a disposable VM. The bridged container can limit LAN discovery compared with host networking. This follows the existing Chef port-published container and keeps service-name diff --git a/ublue/design/tailscale.md b/ublue/design/tailscale.md index 968a3842..a318936a 100644 --- a/ublue/design/tailscale.md +++ b/ublue/design/tailscale.md @@ -1,7 +1,7 @@ # Tailscale host routing Run Tailscale rootfully with host networking and persistent state. The canonical -host caller selects `advertise_exit_node`; clamps enables it. Production delivery +host caller selects `advertise_exit_node`; clamps and beelzebot enable it. Production delivery resolves that declaration into a versioned encrypted enrollment payload. Disposable delivery always selects false. Never infer environment from a runtime hostname. Only the decoded enrollment key becomes the Podman secret; policy stays in clear @@ -28,3 +28,7 @@ by operator decision; they are not an additional acceptance gate for this slice. References: [exit-node policy](https://tailscale.com/docs/features/exit-nodes), [container startup](https://github.com/tailscale/tailscale/blob/main/cmd/containerboot/tailscaled.go), [Quadlet readiness](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#notify-defaults-to-false). + +Boxy's LAN subnet advertisement, route acceptance and disabled subnet SNAT are +not supplied by this exit-node switch. Shared private routing policy is the next +[beelzebot slice](../plan/BEELZEBOT-MIGRATION.md); no LAN routes are guessed. diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index 3f00a724..e63691ad 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -90,9 +90,9 @@ bridge upstream (container DNS name and port). Each host caller supplies its enabled services and Podman network through one reusable host definition. Use that same declaration for Caddy and workstation DNS planning; do not maintain two lists that can drift. Clamps declares Syncthing and Pi-hole; -beezelbot declares Syncthing only and now applies that service on its own -bridge network. Future hosts can declare services without acquiring a Pi-hole -dependency. +beelzebot now declares both through the same profile mechanism and its own +bridge. Service-free and injected capability subsets test hosts without a +Pi-hole dependency. See [its migration plan](BEELZEBOT-MIGRATION.md). Declaring future services must not require editing Caddy's renderer. Add a validated environment configuration and one hostname derivation helper. diff --git a/ublue/plan/REFACTOR-02-HOST-PROFILES.md b/ublue/plan/REFACTOR-02-HOST-PROFILES.md index 6a2e84d8..1aa04f2b 100644 --- a/ublue/plan/REFACTOR-02-HOST-PROFILES.md +++ b/ublue/plan/REFACTOR-02-HOST-PROFILES.md @@ -72,7 +72,7 @@ remains independent of deployment names. ## Progress - Implemented: `skillet_hosts` is the canonical profile and composition crate - for clamps, beezelbot, and the explicit generic agent baseline. Profile data + for clamps, beelzebot, and the explicit generic agent baseline. Profile data owns boot image/masks, enabled services, UI exposure, credentials, bridge network, storage requirement, and optional btrbk inputs. - Implemented: generic and per-host apply wrappers, Caddy apply, credential diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index a1052ead..f7c378cc 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -213,7 +213,7 @@ skillet/ ├── cli/ # Workstation and guest CLI entry point └── hosts/ ├── clamps/ # skillet-clamps host entry point - └── beezelbot/ # skillet-beezelbot host entry point + └── beelzebot/ # skillet-beelzebot host entry point ``` ## Module Design diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index a43faa38..fbb65c4b 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2321,12 +2321,11 @@ dependencies = [ ] [[package]] -name = "skillet-beezelbot" +name = "skillet-beelzebot" version = "0.1.0" dependencies = [ "anyhow", "skillet_cli_common", - "skillet_hardening", ] [[package]] diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index dd0e8009..a3cb73fe 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -13,7 +13,7 @@ members = [ "crates/smtp", "crates/podman", "crates/cli", - "crates/hosts/beezelbot", + "crates/hosts/beelzebot", "crates/hosts/clamps", "crates/cli-common", "crates/test-fixture", diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 8561f575..ec777be4 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -81,11 +81,16 @@ to converge only the shared host baseline before credentials are available. ```bash ./target/x86_64-unknown-linux-musl/debug/skillet apply --host clamps --phase base ``` -- **Host-Specific Configuration**: If a host-specific binary has been built (e.g., `skillet-beezelbot`), you can use it to apply specific configurations. +- **Host-Specific Configuration**: If a host-specific binary has been built (e.g., `skillet-beelzebot`), you can use it to apply specific configurations. ## Disposable VM smoke check -The CI command `skillet test run beezelbot --phase base --image fedora:latest` +`beelzebot` replaces boxy and selects Pi-hole, Syncthing, Tailscale, snapshots, +DDNS, Datadog and shared SMTP. Use the [migration plan](../plan/BEELZEBOT-MIGRATION.md) +for remaining routing/production work and [host design](../design/fleet-hosts.md) +for selection decisions. The old misspelled binary/profile has been removed. + +The CI command `skillet test run beelzebot --phase base --image fedora:latest` runs the selected apply phase twice in a uniquely named, temporary Podman container. It builds the selected host binary, checks both applies succeed, and checks the second apply does not start or restart a service. `clamps` is @@ -466,7 +471,7 @@ record/token ownership is cleaned up by `test vm destroy`; do not use direct - Workspace checks: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo test --workspace --all-targets`. -- Container integration: `cargo run --bin skillet -- test run beezelbot +- Container integration: `cargo run --bin skillet -- test run beelzebot --phase base --image fedora:latest` (also run for `clamps` after changing shared container behavior). - Bootstrap state regressions: `bash ublue/butane/tests/bootstrap.sh` from the diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index ca3faf2e..01e59256 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -22,7 +22,7 @@ fn applying_unchanged_caddy_sites_preserves_the_container() { .ensure_podman_secret("cloudflare_acme_token", "dummy-token") .unwrap(); let sites = CaddySites::from_host( - "beezelbot", + "beelzebot", &UiEnvironment { ui_domain: "test.example.invalid".to_string(), acme_staging: true, @@ -30,7 +30,7 @@ fn applying_unchanged_caddy_sites_preserves_the_container() { &syncthing_only(), ) .unwrap(); - super::apply(&system, &files, &sites, "beezelbot").unwrap(); + super::apply(&system, &files, &sites, "beelzebot").unwrap(); let initial_restart_count = system.restart_count.load(Ordering::SeqCst); let initial_config = files .files @@ -47,7 +47,7 @@ fn applying_unchanged_caddy_sites_preserves_the_container() { .unwrap() .contains_key("/etc/skillet/caddy/Caddyfile")); - super::apply(&system, &files, &sites, "beezelbot").unwrap(); + super::apply(&system, &files, &sites, "beelzebot").unwrap(); assert_eq!( system.restart_count.load(Ordering::SeqCst), @@ -71,7 +71,7 @@ fn derives_a_single_non_clamps_service_hostname_for_either_environment() { ("prod.example.invalid", false), ] { let sites = CaddySites::from_host( - "beezelbot", + "beelzebot", &UiEnvironment { ui_domain: domain.to_string(), acme_staging: staging, @@ -82,7 +82,7 @@ fn derives_a_single_non_clamps_service_hostname_for_either_environment() { assert_eq!(sites.services.len(), 1); assert_eq!( sites.services[0].hostname, - format!("syncthing.beezelbot.{domain}") + format!("syncthing.beelzebot.{domain}") ); assert_eq!(sites.acme_staging, staging); } @@ -128,7 +128,7 @@ fn renders_each_declared_service_with_tailnet_filter_and_container_dns() { fn guest_payload_must_match_the_callers_declared_services() { let services = syncthing_only(); let expected = CaddySites::from_host( - "beezelbot", + "beelzebot", &UiEnvironment { ui_domain: "test.example.invalid".to_string(), acme_staging: true, @@ -138,11 +138,11 @@ fn guest_payload_must_match_the_callers_declared_services() { .unwrap(); let payload = serde_json::to_string(&expected).unwrap(); assert_eq!( - CaddySites::parse(&payload, "beezelbot", &services).unwrap(), + CaddySites::parse(&payload, "beelzebot", &services).unwrap(), expected ); assert!(CaddySites::parse(&payload, "clamps", &services).is_err()); - assert!(CaddySites::parse(&payload, "beezelbot", &[]).is_err()); + assert!(CaddySites::parse(&payload, "beelzebot", &[]).is_err()); } #[test] diff --git a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs index 21071396..4c7241d4 100644 --- a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs +++ b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs @@ -1,5 +1,5 @@ use super::{application_snapshot, application_snapshot_with_probe_timeout, require_data_mount}; -use skillet_hosts::profile_for_name; +use skillet_hosts::{profile_for_name, HostAcceptancePlan}; use skillet_vm::transport::{GuestCommand, GuestTransport}; use std::{ os::unix::process::ExitStatusExt as _, @@ -55,17 +55,25 @@ impl GuestTransport for AcceptanceTransport { fn transport() -> AcceptanceTransport { AcceptanceTransport { active: true, - network: "beezelbot".into(), + network: "beelzebot".into(), missing_listener: None, owner: "1042:2047".into(), container_id: "container-id".into(), } } +// These observations exercise the Syncthing/Caddy capability subset only. +// Host selection itself is checked against the complete canonical declaration. +fn syncthing_ui_plan() -> HostAcceptancePlan { + let mut plan = profile_for_name("beelzebot").unwrap().acceptance_plan(); + plan.services + .retain(|service| matches!(service.unit.as_str(), "syncthing.service" | "caddy.service")); + plan +} + #[test] fn application_snapshot_accepts_profile_declared_runtime() { - let profile = profile_for_name("beezelbot").unwrap(); - let plan = profile.acceptance_plan(); + let plan = syncthing_ui_plan(); let observed = application_snapshot(&transport(), &|| Ok(()), &plan).unwrap(); assert!(observed .iter() @@ -79,7 +87,7 @@ fn application_snapshot_accepts_profile_declared_runtime() { fn application_snapshot_rejects_stopped_units() { let mut fake = transport(); fake.active = false; - let plan = profile_for_name("beezelbot").unwrap().acceptance_plan(); + let plan = syncthing_ui_plan(); assert!(application_snapshot_with_probe_timeout( &fake, &|| Ok(()), @@ -93,7 +101,7 @@ fn application_snapshot_rejects_stopped_units() { #[test] fn application_snapshot_rejects_wrong_network_and_missing_listener() { - let plan = profile_for_name("beezelbot").unwrap().acceptance_plan(); + let plan = syncthing_ui_plan(); let mut wrong_network = transport(); wrong_network.network = "unrelated".into(); assert!(application_snapshot(&wrong_network, &|| Ok(()), &plan) @@ -116,7 +124,7 @@ fn application_snapshot_rejects_wrong_network_and_missing_listener() { #[test] fn application_snapshot_rejects_wrong_data_ownership_and_changed_identity() { - let plan = profile_for_name("beezelbot").unwrap().acceptance_plan(); + let plan = syncthing_ui_plan(); let mut wrong_owner = transport(); wrong_owner.owner = "1000:1000".into(); assert!(application_snapshot(&wrong_owner, &|| Ok(()), &plan) diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index f14ecdc9..b54cfbf8 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1395,7 +1395,7 @@ mod tests { "skillet", "test", "run", - "beezelbot", + "beelzebot", "--phase", "base", "--image", diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index a642c344..66bf1ce5 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -81,13 +81,16 @@ fn rejects_vm_without_tailscale_systemd_credential() { #[test] fn delivery_eligibility_uses_declared_service_capabilities() { - assert!(validate_delivery_service("beezelbot", "datadog").is_err()); + assert!(validate_delivery_service("agent", "datadog").is_err()); assert!(validate_delivery_service("clamps", "datadog").is_ok()); - assert!(validate_delivery_service("beezelbot", "pihole").is_err()); - assert!(validate_delivery_service("beezelbot", "tailscale").is_err()); - assert!(validate_delivery_service("beezelbot", "caddy").is_ok()); - assert!(validate_delivery_service("beezelbot", "ddns").is_err()); + assert!(validate_delivery_service("agent", "pihole").is_err()); + assert!(validate_delivery_service("agent", "tailscale").is_err()); + assert!(validate_delivery_service("beelzebot", "caddy").is_ok()); + assert!(validate_delivery_service("agent", "ddns").is_err()); assert!(validate_delivery_service("clamps", "ddns").is_ok()); + for service in ["pihole", "tailscale", "caddy", "ddns", "datadog", "smtp"] { + assert!(validate_delivery_service("beelzebot", service).is_ok()); + } assert!(validate_delivery_service("missing-host", "caddy").is_err()); assert!(validate_delivery_service("clamps", "unknown").is_err()); } @@ -160,19 +163,19 @@ fn smtp_delivery_and_capture_only_cli_are_supported() { "test", "vm", "provision", - "beezelbot", + "beelzebot", "smtp", "--smtp-only" ]) .is_ok()); assert!(super::validate_delivery_service("agent", "smtp").is_err()); - assert!(super::validate_delivery_service("beezelbot", "smtp").is_ok()); + assert!(super::validate_delivery_service("beelzebot", "smtp").is_ok()); } #[test] fn live_smtp_requires_explicit_smtp_only_selection() { use clap::Parser; - let base = ["skillet", "test", "vm", "provision", "beezelbot", "mailjet"]; + let base = ["skillet", "test", "vm", "provision", "beelzebot", "mailjet"]; assert!(super::super::Args::try_parse_from(base.into_iter().chain(["--live-smtp"])).is_err()); let parsed = super::super::Args::try_parse_from(base.into_iter().chain(["--smtp-only", "--live-smtp"])) diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 94da59fa..ec06f739 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -26,7 +26,7 @@ fn datadog_is_optional_and_separately_credential_gated() { assert!(system.podman_secrets.lock().unwrap().is_empty()); let clamps = super::profile_for_name("clamps").unwrap(); assert!(clamps.supports_service("datadog")); - assert!(!super::profile_for_name("beezelbot") + assert!(!super::profile_for_name("agent") .unwrap() .supports_service("datadog")); assert_eq!( @@ -50,8 +50,9 @@ fn boot_expectations_are_profile_inputs_and_unknown_profiles_are_refused() { let clamps = super::boot_policy_for_host("clamps").unwrap(); assert_eq!(clamps.signed_image, "ghcr.io/gbagnoli/ucore-clamps"); assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); - let other = super::boot_policy_for_host("beezelbot").unwrap(); - assert_eq!(other.masked_units, [] as [&str; 0]); + let other = super::boot_policy_for_host("beelzebot").unwrap(); + assert_eq!(other.masked_units, ["systemd-resolved.service"]); + assert_eq!(other.signed_image, "ghcr.io/gbagnoli/ucore-beelzebot"); assert!(super::boot_policy_for_host("clamps-test-smoke").is_none()); assert!(super::boot_policy_for_host("unknown").is_none()); } @@ -142,35 +143,40 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s .options .contains(&"Subnet=fd59:4e23:2950:11f5::/64".to_string())); - let beezelbot = super::profile_for_name("beezelbot").unwrap(); - assert_eq!(beezelbot.services.len(), 2); - assert!(beezelbot.supports_service("syncthing")); - assert!(!beezelbot.supports_service("pihole")); - assert!(beezelbot.btrbk_config().is_none()); + let beelzebot = super::profile_for_name("beelzebot").unwrap(); assert_eq!( - beezelbot.signed_image, - Some("ghcr.io/gbagnoli/ucore-beezelbot") - ); - assert_eq!(beezelbot.masked_units, [] as [&str; 0]); - assert!(beezelbot.requires_data_mount); - assert!(!beezelbot.requires_pihole_dns_listener_policy()); - assert_eq!( - beezelbot.credential_consumers(), + beelzebot + .services + .iter() + .map(HostService::name) + .collect::>(), [ - super::CredentialConsumer { - credential: skillet_smtp::CREDENTIAL, - unit: "postfix.service" - }, - super::CredentialConsumer { - credential: super::CADDY_SITES_CREDENTIAL, - unit: "caddy.service", - }, - super::CredentialConsumer { - credential: super::CLOUDFLARE_ACME_TOKEN_CREDENTIAL, - unit: "caddy.service", - } + "pihole", + "tailscale", + "syncthing", + "ddns", + "datadog", + "btrbk", + "smtp" ] ); + assert!(!beelzebot.supports_service("unifi")); + assert_eq!( + beelzebot.btrbk_config().unwrap().snapshot_subvolumes, + [std::path::PathBuf::from("syncthing")] + ); + assert_eq!( + beelzebot.signed_image, + Some("ghcr.io/gbagnoli/ucore-beelzebot") + ); + assert_eq!(beelzebot.masked_units, ["systemd-resolved.service"]); + assert!(beelzebot.requires_data_mount); + assert!(beelzebot.requires_pihole_dns_listener_policy()); + assert!(beelzebot.tailscale_exit_node()); + assert!(beelzebot + .services + .iter() + .any(|service| matches!(service.config, ServiceConfig::Pihole { custom_dns: [] }))); } #[test] @@ -225,8 +231,11 @@ fn credential_values_are_selected_by_host_and_apply_phase() { ] ); assert_eq!( - super::credentials_for_phase("beezelbot", super::HostApplyPhase::Full).unwrap(), - Vec::<&str>::new() + super::credentials_for_phase("beelzebot", super::HostApplyPhase::Full).unwrap(), + [ + super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + super::TAILSCALE_AUTH_KEY_CREDENTIAL + ] ); assert!(super::credentials_for_phase("unknown", super::HostApplyPhase::Base).is_err()); } @@ -245,7 +254,7 @@ fn ddns_is_caller_selected_and_independently_credential_gated() { ) .is_err()); assert!(super::apply_host_phase( - "beezelbot", + "agent", super::HostApplyPhase::Ddns, &system, &files, @@ -266,7 +275,7 @@ fn ddns_is_caller_selected_and_independently_credential_gated() { .services .iter() .any(|service| service.unit == "cloudflare-ddns.service")); - assert!(!super::profile_for_name("beezelbot") + assert!(!super::profile_for_name("agent") .unwrap() .supports_service("ddns")); } @@ -290,11 +299,12 @@ fn host_ui_declarations_include_only_the_services_each_host_runs() { assert_eq!(clamps.services[1].port, 8384); assert_eq!(clamps.services[1].aliases, ["sync.{host}"]); - let beezelbot = ui_config_for_host("beezelbot").expect("beezelbot UI declaration"); - assert_eq!(beezelbot.services.len(), 1); - assert_eq!(beezelbot.services[0].name, "syncthing"); - assert_eq!(beezelbot.services[0].aliases, ["sync.{host}"]); - assert_eq!(beezelbot.network_name, "beezelbot"); + let beelzebot = ui_config_for_host("beelzebot").expect("beelzebot UI declaration"); + assert_eq!(beelzebot.services.len(), 2); + assert_eq!(beelzebot.services[0].name, "pihole"); + assert_eq!(beelzebot.services[1].name, "syncthing"); + assert_eq!(beelzebot.services[1].aliases, ["sync.{host}"]); + assert_eq!(beelzebot.network_name, "beelzebot"); assert!(ui_config_for_host("unknown-host").is_none()); } @@ -352,19 +362,29 @@ fn application_acceptance_tracks_each_profiles_declared_services() { Some(AcceptanceOwner::Numeric { uid: 999, gid: 999 }) ); - let beezelbot = profile_for_name("beezelbot").unwrap().acceptance_plan(); + let beelzebot = profile_for_name("beelzebot").unwrap().acceptance_plan(); assert_eq!( - beezelbot + beelzebot .services .iter() .map(|service| service.unit.as_str()) .collect::>(), - ["syncthing.service", "caddy.service"] + [ + "pihole.service", + "tailscale.service", + "syncthing.service", + "skillet-btrbk.timer", + "caddy.service" + ] ); - assert!(beezelbot + assert!(beelzebot .services .iter() - .all(|service| service.network_mode.as_deref() == Some("beezelbot"))); + .filter(|service| matches!( + service.container.as_deref(), + Some("pihole" | "syncthing" | "caddy") + )) + .all(|service| service.network_mode.as_deref() == Some("beelzebot"))); } #[test] @@ -463,7 +483,7 @@ fn a_credential_service_remains_eligible_when_its_ui_is_not_exposed() { #[test] fn smtp_is_shared_and_has_an_independent_credential_phase() { - for host in ["clamps", "beezelbot"] { + for host in ["clamps", "beelzebot"] { let profile = super::profile_for_name(host).unwrap(); assert_eq!( profile @@ -490,7 +510,7 @@ fn tailscale_exit_node_is_caller_selected_and_preferences_follow_readiness() { assert!(super::profile_for_name("clamps") .unwrap() .tailscale_exit_node()); - assert!(!super::profile_for_name("beezelbot") + assert!(!super::profile_for_name("agent") .unwrap() .tailscale_exit_node()); for enabled in [false, true] { @@ -510,3 +530,105 @@ fn tailscale_exit_node_is_caller_selected_and_preferences_follow_readiness() { assert!(config.extra_config["Service"].contains(&format!("ExecStartPost=/usr/bin/podman exec tailscale tailscale set --advertise-exit-node={enabled}"))); } } + +#[test] +fn beelzebot_boot_template_and_credential_unit_match_the_profile() { + let template = include_str!("../../../../butane/beelzebot.bu"); + let image = include_str!("../../../../butane/includes/ucore-beelzebot.bu"); + let unit = include_str!("../../../../butane/includes/skillet-full.bu"); + let profile = super::profile_for_name("beelzebot").unwrap(); + assert!(template.contains("device: /dev/vda")); + for include in [ + "data-storage", + "resolver", + "skillet", + "skillet-full", + "skillet-beelzebot", + "ucore-bootstrap", + "ucore-beelzebot", + ] { + assert!(template.contains(&format!("local: includes/{include}.bu"))); + } + assert!(image.contains(profile.signed_image.unwrap())); + assert!(image.contains("name: systemd-resolved.service\n mask: true")); + for credential in + super::credentials_for_phase("beelzebot", super::HostApplyPhase::Full).unwrap() + { + assert!(unit.contains(&format!("LoadCredentialEncrypted={credential}:/etc/credstore.encrypted/skillet/{credential}.cred"))); + } + assert!(unit.contains("apply --host-file /etc/skillet/host --phase full")); + assert!(super::profile_for_name("beezelbot").is_none()); +} + +#[test] +fn beelzebot_full_apply_uses_shared_services_and_is_repeatable() { + use skillet_core::{ + system::{AccountResource, GroupIdentity, UserIdentity}, + test_utils::MockFiles, + }; + use std::{path::Path, sync::atomic::Ordering}; + let system = MockSystem::new(); + system.user_identities.lock().unwrap().insert( + "giacomo".into(), + UserIdentity { + name: "giacomo".into(), + uid: 1042, + primary_gid: 2047, + }, + ); + system.group_identities.lock().unwrap().insert( + "giacomo".into(), + GroupIdentity { + name: "giacomo".into(), + gid: 2047, + }, + ); + system.ensure_group("pihole", Some(2000)).unwrap(); + system + .ensure_user("pihole", Some(2000), Some(2000)) + .unwrap(); + let files = MockFiles::new(); + files.record_btrfs_mount(Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount(Path::new("/var/lib/data"), "/dev/test", "/data", "btrfs"); + for path in ["/etc/subuid", "/etc/subgid"] { + files + .files + .lock() + .unwrap() + .insert(path.into(), b"giacomo:100000:65536\n".to_vec()); + } + let mut credentials = CredentialInputs::default(); + credentials.insert( + super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + "fixture-password".into(), + ); + credentials.insert( + super::TAILSCALE_AUTH_KEY_CREDENTIAL, + super::tailscale::EnrollmentInput::new("fixture-auth-key".into(), false) + .payload() + .unwrap(), + ); + super::apply_host("beelzebot", &system, &files, &credentials).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + let starts = system.start_count.load(Ordering::SeqCst); + let before = files.files.lock().unwrap().clone(); + super::apply_host("beelzebot", &system, &files, &credentials).unwrap(); + assert_eq!(*files.files.lock().unwrap(), before); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + assert_eq!(system.start_count.load(Ordering::SeqCst), starts); + let pihole = + String::from_utf8(before["/etc/containers/systemd/pihole.container"].clone()).unwrap(); + assert!(pihole.contains("Network=beelzebot.network")); + for address in ["0.0.0.0", "[::]"] { + for protocol in ["tcp", "udp"] { + assert!(pihole.contains(&format!("PublishPort={address}:53:53/{protocol}"))); + } + } + let tailscale = + String::from_utf8(before["/etc/containers/systemd/tailscale.container"].clone()).unwrap(); + assert!(tailscale.contains("Network=host")); + assert!(tailscale.contains("--advertise-exit-node=false")); + assert!(!before.contains_key("/etc/containers/systemd/unifi.container")); + assert!(!before.contains_key("/etc/containers/systemd/datadog-agent.container")); + assert!(!before.contains_key("/etc/containers/systemd/cloudflare-ddns.container")); +} diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index d874e807..eda89621 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -434,13 +434,12 @@ fn caddy_acceptance(profile: &str) -> AcceptanceService { } const CLAMPS_DNS: &[(&str, &str)] = &[("192.168.1.100", "my.custom.domain")]; -const CLAMPS_SYNCTHING_UI: UiServiceDeclaration = UiServiceDeclaration { +const SYNCTHING_UI: UiServiceDeclaration = UiServiceDeclaration { name: "syncthing", upstream: "syncthing", port: 8384, aliases: &["sync.{host}"], }; -const BEEZELBOT_SYNCTHING_UI: UiServiceDeclaration = CLAMPS_SYNCTHING_UI; fn clamps() -> HostProfile { HostProfile { @@ -481,7 +480,7 @@ fn clamps() -> HostProfile { container_uid: 1000, container_gid: 1000, }, - ui: Some(CLAMPS_SYNCTHING_UI), + ui: Some(SYNCTHING_UI), }, HostService { config: ServiceConfig::Unifi, @@ -508,11 +507,12 @@ fn clamps() -> HostProfile { } } -fn beezelbot() -> HostProfile { +/// Boxy's replacement. Private DNS and routing inputs are delivered separately. +fn beelzebot() -> HostProfile { HostProfile { - id: HostId::known("beezelbot"), - signed_image: Some("ghcr.io/gbagnoli/ucore-beezelbot"), - masked_units: &[], + id: HostId::known("beelzebot"), + signed_image: Some("ghcr.io/gbagnoli/ucore-beelzebot"), + masked_units: &["systemd-resolved.service"], network: NetworkPolicy { ipv4_subnet: "172.26.26.0/24", ipv4_gateway: "172.26.26.1", @@ -520,16 +520,51 @@ fn beezelbot() -> HostProfile { ipv6_gateway: "fd59:4e23:2950:11f5::1", }, requires_data_mount: true, - services: vec![HostService { - config: ServiceConfig::Syncthing { - data_path: "/var/lib/data/syncthing", - data_owner: "giacomo", - data_group: "giacomo", - container_uid: 1000, - container_gid: 1000, + services: vec![ + HostService { + config: ServiceConfig::Pihole { custom_dns: &[] }, + ui: Some(UiServiceDeclaration { + name: "pihole", + upstream: "pihole", + port: 8088, + aliases: &[], + }), }, - ui: Some(BEEZELBOT_SYNCTHING_UI), - }], + HostService { + config: ServiceConfig::Tailscale { + advertise_exit_node: true, + state_path: "/var/lib/data/tailscale", + }, + ui: None, + }, + HostService { + config: ServiceConfig::Syncthing { + data_path: "/var/lib/data/syncthing", + data_owner: "giacomo", + data_group: "giacomo", + container_uid: 1000, + container_gid: 1000, + }, + ui: Some(SYNCTHING_UI), + }, + HostService { + config: ServiceConfig::Ddns, + ui: None, + }, + HostService { + config: ServiceConfig::Datadog { + production_region: "ftwo", + network_monitoring: true, + }, + ui: None, + }, + HostService { + config: ServiceConfig::Btrbk { + snapshot_subvolumes: &["syncthing"], + }, + ui: None, + }, + ], } } @@ -553,7 +588,7 @@ fn agent_baseline() -> HostProfile { pub fn profile_for_host(host: &HostId) -> Option { match host.as_str() { "clamps" => Some(with_fleet_baseline(clamps())), - "beezelbot" => Some(with_fleet_baseline(beezelbot())), + "beelzebot" => Some(with_fleet_baseline(beelzebot())), "agent" => Some(agent_baseline()), _ => None, } @@ -566,7 +601,7 @@ pub fn profile_for_name(host: &str) -> Option { pub fn declared_profiles() -> Vec { vec![ with_fleet_baseline(clamps()), - with_fleet_baseline(beezelbot()), + with_fleet_baseline(beelzebot()), agent_baseline(), ] } diff --git a/ublue/skillet/crates/hosts/beezelbot/Cargo.toml b/ublue/skillet/crates/hosts/beelzebot/Cargo.toml similarity index 69% rename from ublue/skillet/crates/hosts/beezelbot/Cargo.toml rename to ublue/skillet/crates/hosts/beelzebot/Cargo.toml index 6320f220..f17c0fd0 100644 --- a/ublue/skillet/crates/hosts/beezelbot/Cargo.toml +++ b/ublue/skillet/crates/hosts/beelzebot/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "skillet-beezelbot" +name = "skillet-beelzebot" version = "0.1.0" edition = "2021" @@ -8,5 +8,4 @@ workspace = true [dependencies] skillet_cli_common.workspace = true -skillet_hardening.workspace = true anyhow = "1.0" diff --git a/ublue/skillet/crates/hosts/beezelbot/src/main.rs b/ublue/skillet/crates/hosts/beelzebot/src/main.rs similarity index 56% rename from ublue/skillet/crates/hosts/beezelbot/src/main.rs rename to ublue/skillet/crates/hosts/beelzebot/src/main.rs index 7b788874..73433fb6 100644 --- a/ublue/skillet/crates/hosts/beezelbot/src/main.rs +++ b/ublue/skillet/crates/hosts/beelzebot/src/main.rs @@ -2,8 +2,8 @@ use anyhow::Result; use skillet_cli_common::{hosts, run_host}; fn main() -> Result<()> { - run_host("beezelbot", |phase, system, files, credentials| { - hosts::apply_host_phase("beezelbot", phase, system, files, credentials) + run_host("beelzebot", |phase, system, files, credentials| { + hosts::apply_host_phase("beelzebot", phase, system, files, credentials) .map_err(|e| e.to_string()) })?; Ok(()) diff --git a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs index 568175ef..3ed33e17 100644 --- a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs +++ b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs @@ -55,7 +55,7 @@ fn datadog_delivery_validates_capability_and_sends_only_stdin() { let key = "0123456789abcdef0123456789abcdef"; let payload = serde_json::json!({"version":1,"api_key":key,"site":"datadoghq.eu"}).to_string(); let policy = ProvisioningPolicy::new(Environment::Test); - assert!(deliver_datadog_credential("beezelbot", policy, &payload, &guest).is_err()); + assert!(deliver_datadog_credential("agent", policy, &payload, &guest).is_err()); assert!(deliver_datadog_credential("clamps", policy, "invalid", &guest).is_err()); assert!(guest.calls.lock().unwrap().is_empty()); deliver_datadog_credential("clamps", policy, &payload, &guest).unwrap(); @@ -299,7 +299,7 @@ fn smtp_delivery_is_stdin_only_and_capture_cannot_contain_provider_fields() { }; assert!(crate::smtp_provisioning::deliver("agent", &input, &guest).is_err()); assert_eq!(guest.calls.lock().unwrap().len(), 0); - crate::smtp_provisioning::deliver("beezelbot", &input, &guest).unwrap(); + crate::smtp_provisioning::deliver("beelzebot", &input, &guest).unwrap(); let calls = guest.calls.lock().unwrap(); assert!(calls.iter().all(|(args, _)| args .iter() diff --git a/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs index 32624f0e..d8c48f81 100644 --- a/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs @@ -238,7 +238,7 @@ fn invalid_config_ui_namespace_and_test_policy_never_issue_or_deliver() { request.policy = ProvisioningPolicy::new(Environment::Test); assert!(deliver_persistent_ddns(&request, &mut store, &provider, &guest).is_err()); request.policy = ProvisioningPolicy::new(Environment::Production); - request.host = "beezelbot"; + request.host = "agent"; assert!(deliver_persistent_ddns(&request, &mut store, &provider, &guest).is_err()); assert_eq!(provider.issued.get(), 0); assert!(guest.payloads.borrow().is_empty()); diff --git a/ublue/skillet/crates/workstation/src/root_recovery/tests.rs b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs index 9a3d5f39..92c09bd7 100644 --- a/ublue/skillet/crates/workstation/src/root_recovery/tests.rs +++ b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs @@ -139,7 +139,7 @@ fn failed_persistence_and_volume_mismatch_never_enroll() { fn wrong_host_never_saves() { let mut store = Store::default(); assert!(provision( - "beezelbot", + "beelzebot", "path", Path::new("helper"), &mut store, diff --git a/ublue/skillet/crates/workstation/src/secrets/tests.rs b/ublue/skillet/crates/workstation/src/secrets/tests.rs index 20576cd4..a648793d 100644 --- a/ublue/skillet/crates/workstation/src/secrets/tests.rs +++ b/ublue/skillet/crates/workstation/src/secrets/tests.rs @@ -41,7 +41,7 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { .all(|entry| !entry.path.contains("/prod/") && !entry.path.ends_with("web-password") && !entry.path.ends_with("acme-token"))); - let profile = skillet_hosts::profile_for_name("beezelbot").unwrap(); + let profile = skillet_hosts::profile_for_name("beelzebot").unwrap(); let report = check( &[profile], ProvisioningPolicy::new(Environment::Production), @@ -58,7 +58,7 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { }, ) .unwrap(); - assert_eq!(report.checked, 3); + assert_eq!(report.checked, 7); assert!(report.missing.is_empty()); } @@ -90,26 +90,26 @@ fn unused_audit_keeps_both_environments_hosts_generated_and_legacy_paths() { "skillet/environments/test/datadog/api-key", "skillet/environments/test/datadog/site", "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name", - "skillet/environments/prod/hosts/beezelbot/cloudflare/acme-token", - "skillet/environments/test/hosts/beezelbot/cloudflare/acme-token", + "skillet/environments/prod/hosts/beelzebot/cloudflare/acme-token", + "skillet/environments/test/hosts/beelzebot/cloudflare/acme-token", "skillet/hosts/clamps/cloudflare/acme-token", "skillet/hosts/clamps/pihole/web-password", "personal/not-for-skillet", "skillet/environments/dns/cloudflare-zone-id", "skillet/environments/prod/hosts/unknown/cloudflare/acme-token", - "skillet/environments/test/hosts/beezelbot/cloudflare/ddns-dns-name", + "skillet/environments/test/hosts/beelzebot/cloudflare/ddns-dns-name", ] .map(str::to_string); let unused = unused_paths(&skillet_hosts::profile::declared_profiles(), &stored).unwrap(); - assert_eq!(unused.len(), 5); + assert_eq!(unused.len(), 4); assert!(unused.contains(&"skillet/environments/test/datadog/api-key".into())); assert!(unused.contains(&"skillet/environments/test/datadog/site".into())); assert!(unused.contains(&"skillet/environments/dns/cloudflare-zone-id".into())); assert!( unused.contains(&"skillet/environments/prod/hosts/unknown/cloudflare/acme-token".into()) ); - assert!(unused - .contains(&"skillet/environments/test/hosts/beezelbot/cloudflare/ddns-dns-name".into())); + assert!(!unused + .contains(&"skillet/environments/test/hosts/beelzebot/cloudflare/ddns-dns-name".into())); } #[test] diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index 748730ae..07d4991e 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -338,7 +338,7 @@ fn create_cleanup_fixture(run_directory: &Path, provider: &FakeCloudflare) { #[test] fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { let production = build_ui_provisioning_plan( - "beezelbot", + "beelzebot", ProvisioningPolicy::new(Environment::Production), "example.invalid", Some("ui"), @@ -346,7 +346,7 @@ fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { ) .unwrap(); let test = build_ui_provisioning_plan( - "beezelbot", + "beelzebot", ProvisioningPolicy::new(Environment::Test), "example.invalid", Some("smoke.ui"), @@ -356,11 +356,11 @@ fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { assert_eq!( production.sites.machine_hostname, - "beezelbot.ui.example.invalid" + "beelzebot.ui.example.invalid" ); assert_eq!( test.sites.machine_hostname, - "beezelbot.smoke.ui.example.invalid" + "beelzebot.smoke.ui.example.invalid" ); assert!(!production.sites.acme_staging); assert!(test.sites.acme_staging); @@ -423,7 +423,7 @@ fn profiles_without_ui_and_incomplete_address_families_fail_closed() { let only_v4 = BTreeSet::from(["100.64.0.10".to_string()]); assert!(matches!( build_ui_provisioning_plan( - "beezelbot", + "beelzebot", ProvisioningPolicy::new(Environment::Test), "example.invalid", None, From e9a00c65a1152f52a41e36c5492a2a4e871206d9 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 11:17:10 +0200 Subject: [PATCH 245/252] docs(pihole): track private DNS convergence before cutover --- ublue/plan/BEELZEBOT-MIGRATION.md | 12 +++++++++--- ublue/plan/CLAMPS-MIGRATION.md | 5 ++++- ublue/plan/CLAMPS-PARITY.md | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/ublue/plan/BEELZEBOT-MIGRATION.md b/ublue/plan/BEELZEBOT-MIGRATION.md index 71a55d7e..ee9ddbf8 100644 --- a/ublue/plan/BEELZEBOT-MIGRATION.md +++ b/ublue/plan/BEELZEBOT-MIGRATION.md @@ -10,7 +10,7 @@ Clamps remaining work is tracked in [its migration plan](CLAMPS-MIGRATION.md). | Boxy behavior | Target / remaining work | | --- | --- | | One Btrfs filesystem, separate data subvolume; rootful/rootless Podman stores | Shared root + `/var/lib/data`; rootful overlay graphroot; retain rootless storage only for workloads that need it | -| Pi-hole; resolved disabled | Selected shared Pi-hole and bridge DNS policy; image/day-zero mask; real private DNS inputs and LAN acceptance pending | +| Pi-hole; resolved disabled | Selected shared Pi-hole and bridge DNS policy; image/day-zero mask; private runtime DNS delivery/v6 convergence and LAN acceptance pending | | Syncthing data `/var/lib/data/sync`, dedicated account and ACLs | Shared `/var/lib/data/syncthing` leaf, declared host ownership/user mapping; transfer folder definitions, identity and ACLs explicitly, never copy live identity into smoke | | btrbk snapshots `sync` only | Caller opts in for `syncthing` only; preserve leaf-only snapshots and settle production retention/off-machine backup | | nginx/OAuth/ACME, local and cross-host routes | Generic Caddy, declared Pi-hole/Syncthing UIs and service alias policy; Cloudflare credentials and tailnet ingress reused; inventory needed cross-host routes before omitting them | @@ -37,12 +37,18 @@ No private addresses, domains, keys or effective Chef overrides belong here. compatibility, converge preferences after authentication, and force disposable policy to omit advertisements. Test rendering, validation, repeat convergence and no production routes on smoke machines. Do not publish the LAN subnet. -3. **Named application acceptance:** unlock with the existing workflow; run +3. **Private Pi-hole configuration:** replace static/sample DNS declarations + with private runtime template delivery shared by both hosts. Use Pi-hole v6's + supported configuration API/format; current `custom.list` rendering and the + manual accepted `dns.hosts` test do not prove automatic v6 convergence. Validate + empty/configured records, repeat apply, reboot and private-value redaction. + Settle whether omission preserves restored records before production import. +4. **Named application acceptance:** unlock with the existing workflow; run beelzebot full provisioning and capability-selected repeat-apply/reboot checks, then opted-in UI, DDNS, Datadog and isolated SMTP checks. Use disposable identities, test DNS and lifecycle-owned cleanup. No production writer/peer identity during acceptance; record failed and unavailable checks honestly. -4. **Private inventory and cutover:** inspect live boxy storage/subvolumes, +5. **Private inventory and cutover:** inspect live boxy storage/subvolumes, Syncthing identity/folders/ACLs, Pi-hole settings, routing and cross-host monitoring/UI needs. Select real hardware/disk/root profile, snapshot/backup retention and rollback. Stage copies with original writers stopped as needed; diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 31ccb3d7..be7d7464 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -15,7 +15,10 @@ open; previous passing native VM checks do not close them. 1. Boot the prepared USB on the S13; inspect firmware/TPM and select its stock SSD explicitly. Enroll the trusted MOK when using Secure Boot. Validate the selected plain or TPM profile on hardware, recovery archive and cold boot. -2. Supply private production DNS/DDNS records and keys, migrate service data +2. Implement private runtime Pi-hole DNS configuration delivery and v6 + convergence: current code writes `custom.list` from a static declaration; + the accepted temporary v6 `dns.hosts` test does not validate that recipe path. + Supply production DNS/DDNS records and keys, migrate service data and identities from rupik, and decide retained cross-host routes and WOL targets. Keep the old DNS service and Samsung SSD until the cutover. 3. Validate real LAN IPv4/IPv6 UDP/TCP DNS, peer connectivity, physical SSH/ET, diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md index 9add4a3d..f886e7c5 100644 --- a/ublue/plan/CLAMPS-PARITY.md +++ b/ublue/plan/CLAMPS-PARITY.md @@ -13,7 +13,7 @@ Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. | Chef capability | Clamps implementation / deliberate change | Remaining work | | --- | --- | --- | | Btrfs `/srv` and system/user Podman stores | Shared `/var/lib/data` subvolume; system graphroot under `containers/system`, overlay driver on uCore | Native TPM/plain profiles and USB preparation implemented; physical acceptance and state transfer; rootless store only if a retained workload needs it | -| Disable resolved; Pi-hole | Mask/resolver setup, dual-stack port 53, persistent service data, encrypted password, shared bridge | Real LAN/firewall acceptance, actual custom DNS configuration, data transfer | +| Disable resolved; Pi-hole | Mask/resolver setup, dual-stack port 53, persistent service data, encrypted password, shared bridge | Private runtime custom DNS delivery/v6 convergence (current static `custom.list` path is not validated by the manual v6 test), real LAN/firewall acceptance, data transfer | | nginx, ACME, OAuth proxy, web pod | Separate bridge containers; generic Caddy routes, Cloudflare DNS-01, tailnet restriction, per-service names and aliases | Production certificate/renewal and external-source denial; inventory any legacy cross-host routes still needed | | Syncthing | Container and leaf data subvolume; peer ports retained; host GUI publication removed; explicit host/container identity mapping | PARKED: live ownership fix acceptance; production peer connectivity and identity/data migration | | btrbk | Caller-selected Syncthing snapshots, hourly timer, retention and restore accepted | Off-machine backup policy and physical cutover acceptance; nested source subvolumes unsupported | From bf2230f9edebde46ff778315d07793b7e3e63151 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 11:27:21 +0200 Subject: [PATCH 246/252] docs(beelzebot): record fresh boot and fixture acceptance --- ublue/butane/ACCEPTANCE.md | 41 +++++++++++++++++++++++++++++++ ublue/plan/BEELZEBOT-MIGRATION.md | 7 ++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 2e173205..ee655782 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -2390,3 +2390,44 @@ update / rollback matrix cases. A header backup is not a service-data backup. - Unverified: booting the new USB ISO itself, actual S13 disk installation, physical TPM/Secure Boot/MOK enrollment, LAN and production cutover. See [the physical runbook](HARDWARE.md). Existing smoke VM was not modified. + + +## 2026-10-11 — boxy replacement host/boot slice + +- Source inventory: `roles/boxy.rb` and the default cookbook includes. Shared + services selected by canonical `beelzebot`: Pi-hole, host-network Tailscale, + Syncthing, caller-selected Syncthing-only btrbk, DDNS, Datadog and SMTP; + generic Caddy exposes only the declared UIs. UniFi is not selected. +- Operator explicitly authorized removing the misspelled profile/package without + compatibility. Current commands and CI use `beelzebot`; earlier log entries + retain the command spelling actually executed. No vault or manifest migrated. +- Local: all 372 workspace/all-target tests, formatting, strict pedantic Clippy, + Fedora `test run beelzebot --phase base --image fedora:latest` (two applies, + no repeated starts/restarts), ShellCheck, nine bootstrap regressions and both + guarded hardware-helper refusal cases passed. Routine checks used no vault, + provider or hypervisor credentials. +- ucore-images: YAML parsing, Pi-hole DNS mask and serialized common-before-host + workflow assertions passed. The recipe change is committed; its rebuilt remote + image/CI has not been checked. The new Butane template also masks resolved + at day zero so the older correctly named image can boot with this policy. +- Native named run: `test vm create beelzebot migration --port 2202`, plain root, + `/dev/vda`, existing verified FCOS image; the clamps smoke VM was preserved. + Strict compilation and decoded destination Ignition assertions passed for + canonical host/image, resolved mask, full-apply credentials and disk layout. + Captures were built from the working tree before commit `13961a1`; its manifest + records the previous HEAD. Recorded hashes bind the delivered artifacts. +- Signed boot and binary delivery reached the base/user-environment stage; + full base/user readiness passed. `test smoke beelzebot --instance migration` + passed namespaced fixture convergence/failure/recovery checks, repeat apply, + reboot/persistence and final readiness. This was fixture mode, not full declared + application acceptance. The native run remains `Ready` on SSH port 2202 for + the next slice; final manifest/status/journals and command logs are preserved + privately at `/tmp/beelzebot-migration-evidence`. +- Vault-dependent audit could not unlock: `secrets check --host beelzebot + --environment test` returned `reading database password from terminal: No such + device or address`; the cache was unavailable in this process. No provider + mutation, production identity, DNS publication or real service-data transfer. + Full application/provider acceptance remains pending an operator unlock. +- Remaining parity: private Pi-hole v6 DNS configuration delivery, boxy's subnet + routing/acceptance/SNAT policy, private cross-host UI/probes, source data/ACLs, + physical deployment and cutover. See `../plan/BEELZEBOT-MIGRATION.md`. diff --git a/ublue/plan/BEELZEBOT-MIGRATION.md b/ublue/plan/BEELZEBOT-MIGRATION.md index ee9ddbf8..a922a94b 100644 --- a/ublue/plan/BEELZEBOT-MIGRATION.md +++ b/ublue/plan/BEELZEBOT-MIGRATION.md @@ -1,6 +1,9 @@ # Boxy → beelzebot -Status: first implementation slice, 2026-10-11. Review completed against +Status: host/boot slice implemented; native base readiness and fixture +repeat-apply/reboot accepted, 2026-10-11. Full application/provider acceptance +is pending a vault unlock; the rebuilt image change and remote CI are unverified. +Review completed against `roles/boxy.rb` and `site-cookbooks/boxy/recipes/default.rb` and its includes. Private Chef overrides and live boxy state have not been inventoried. Clamps remaining work is tracked in [its migration plan](CLAMPS-MIGRATION.md). @@ -24,7 +27,7 @@ No private addresses, domains, keys or effective Chef overrides belong here. ## Implementation order and exits -1. **Host and boot slice:** canonical `beelzebot` crate/profile/image, shared +1. **Implemented; native boot/fixture accepted:** canonical `beelzebot` crate/profile/image, shared full-apply unit, host Butane with existing `/dev/vda` and Btrfs layout. Remove the old misspelling without compatibility at operator request. Profile selection must drive UIs, credentials, monitoring and snapshots; From 93cbbdb3bde53a37e2d1dd4d667337d9f9767260 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 16:33:42 +0200 Subject: [PATCH 247/252] docs(home-assistant): plan deployment and encrypted backups --- ublue/design/home-assistant.md | 59 +++++++++++++ ublue/plan/CLAMPS-MIGRATION.md | 8 ++ ublue/plan/HOME-ASSISTANT.md | 156 +++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 ublue/design/home-assistant.md create mode 100644 ublue/plan/HOME-ASSISTANT.md diff --git a/ublue/design/home-assistant.md b/ublue/design/home-assistant.md new file mode 100644 index 00000000..4a65788d --- /dev/null +++ b/ublue/design/home-assistant.md @@ -0,0 +1,59 @@ +# Home Assistant + +Status: decision recorded on 2026-10-11; implementation and acceptance pending. +See the [implementation plan](../plan/HOME-ASSISTANT.md). + +## Deployment and access + +Use Home Assistant Container as an optional shared Skillet service selected by +its host caller. Clamps is the first consumer; other hosts do not inherit it. +Run the official image as a rootful Quadlet with host networking for LAN +multicast discovery. Pin the application release and allow 60 seconds for clean +shutdown. Persist `/config` at `/var/lib/data/homeassistant/config` outside +Syncthing. Map only selected radios/devices and add Bluetooth D-Bus access when +required. Establish the effective capabilities and SELinux policy in runtime +acceptance rather than copying an unrestricted privileged example blindly. + +Container installation has no Supervisor/app store. Companion services such as +MQTT and Zigbee2MQTT require their own declared containers and backup policies. +Skillet manages deployment and explicitly owned initial settings; Home Assistant +owns its accounts, integrations, automations and internal configuration. + +Expose `homeassistant..` through the existing tailnet-restricted +Caddy/Cloudflare DNS-01 path. Add a shared host-upstream representation because +Caddy uses bridge networking and cannot resolve a host-network application as a +bridge container. Validate the Podman host gateway, proxy trust and listener +isolation together. Raw port 8123 must not bypass the private UI policy. +Retain Home Assistant login; tailnet authorization does not create an application +session. Machine A/AAAA records and UI CNAMEs remain unchanged; Pi-hole local +records and MagicDNS are not dependencies of this route. + +## Backup and recovery + +Use Home Assistant's scheduled native backups, with encryption enabled. The +Container local directory is `/config/backups`. Publish completed encrypted +archives to `/var/lib/data/syncthing/backups/homeassistant` using a bounded, +retryable exporter and atomic replacement in the destination filesystem. Keep +unfinished archives outside the synchronized namespace. This separates live +SQLite/configuration writes from replication and prevents partial backups from +becoming advertised recovery copies. + +Explicitly share the backups folder in Syncthing. Prepare typed ownership/ACLs +for its mapped identity; never recursively change the live application tree. +The destination is an ordinary directory within the existing Syncthing leaf +subvolume. Clamps' existing btrbk selection snapshots it without adding the +Home Assistant state tree or all of `/var/lib/data` to snapshot policy. + +Syncthing provides off-machine copies; btrbk provides local history. The latter +shares the source disk's failure domain, and synchronization propagates deletions. +Keep independent retention at the receiving site. Retain the backup encryption +key/emergency kit in the portable KeePassXC vault with an independent recovery +copy. Export original encrypted files, not the UI download that decrypts them. +Prove restoration into an isolated disposable instance before relying on backups. +Never run a restored production automation/integration against live devices. + +References: [Container installation](https://www.home-assistant.io/installation/linux/), +[backup integration](https://www.home-assistant.io/integrations/backup/), +[backup and restore](https://www.home-assistant.io/common-tasks/general/), +[Container backup directory](https://github.com/home-assistant/core/blob/2026.10.1/homeassistant/components/backup/backup.py), +[HTTP/proxy configuration](https://www.home-assistant.io/integrations/http/). diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index be7d7464..9cac84bd 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -197,6 +197,14 @@ Done when: all retained capabilities and data work on clamps, routine convergenc Nebula Sync, and legacy networking. Datadog is retained and its live VM acceptance passed. - Cutover: address/identity transitions, outage window and rollback duration. +## Optional additions + +[Home Assistant](HOME-ASSISTANT.md) is a requested new service, separate from +Chef parity and the rupik retirement gates. Its plan covers an opted-in rootful +host-network container, generic private UI access, native encrypted backups +published into Syncthing, existing btrbk history, and isolated restore acceptance. +All Home Assistant implementation and acceptance remain pending. + ## Technical references - [systemctl waiting semantics](https://raw.githubusercontent.com/systemd/systemd/main/man/systemctl.xml) diff --git a/ublue/plan/HOME-ASSISTANT.md b/ublue/plan/HOME-ASSISTANT.md new file mode 100644 index 00000000..9bf11128 --- /dev/null +++ b/ublue/plan/HOME-ASSISTANT.md @@ -0,0 +1,156 @@ +# Home Assistant on clamps + +Status: planned, 2026-10-11. No Home Assistant deployment or acceptance is +implemented. This is an optional addition, not a Chef parity requirement or an +additional gate for retiring rupik. + +Prerequisite: respect the remaining gates in the +[refactoring roadmap](SKILLET-REFACTOR.md). Implement one slice at a time and +validate it before the next. Follow `../AGENTS.md` and `../skillet/AGENTS.md`. +Read the [Home Assistant decision](../design/home-assistant.md), +[storage](../design/storage.md), [Syncthing](../design/syncthing.md), +[btrbk](../design/btrbk.md), [private UI access](../design/private-ui-access.md), +[secrets](../design/secrets.md), [monitoring](../design/monitoring.md), +and [VM lifecycle](../design/smoke-vms.md). + +## 1. Optional container and persistent state + +- Add a reusable Home Assistant service module using the existing resource and + Podman APIs. Declare it in the canonical clamps host profile; UIs, monitoring + and credentials must consume that same selection. Other profiles stay opted + out unless their caller explicitly selects it. +- Pin a current official `ghcr.io/home-assistant/home-assistant` release and + record its tested digest. Use rootful `Network=host`, restart-on-failure/boot, + and a 60-second container stop timeout with sufficient systemd stop time. + Disable unattended registry auto-update initially; application upgrades need + a completed backup and a tested restore path because database migrations can + prevent rollback by merely reverting the image. +- Require the shared data mount before preparation and startup. Prepare service + storage under `/var/lib/data/homeassistant`; bind its `config` directory to + `/config`. Use typed ownership without recursively rewriting existing files. + Routine apply preserves Home Assistant-managed accounts, integrations, + automations, database and internal `.storage` state. +- Model optional devices explicitly, using stable serial identities where + available. Add D-Bus/Bluetooth permissions only when selected. Validate the + minimum working capabilities and SELinux policy; do not disable host SELinux + or assume the upstream privileged example is a required global policy. +- Add module-owned Datadog process/health declarations when the monitoring + capability is selected. Never require a Datadog key solely to run this service. + +Exit: routine tests cover selection/omission, rendering, missing/wrong mounts, +ownership, retained state, stop timeout and repeat apply. A named disposable VM +starts an empty instance and preserves synthetic state across apply and reboot +with SELinux enforcing. Real radio discovery remains a physical check. + +## 2. Generic private UI access + +- Extend the shared UI upstream model to distinguish bridge service DNS from + a host listener. Preserve existing Pi-hole/Syncthing payloads through an + explicit compatibility path if the credential schema changes. Keep host, + environment, instance and SSH target separate. +- Declare `homeassistant` and derive its hostname/aliases through the existing + generic machinery. Reuse the selected environment's zone, UI prefix, token + issuance, A/AAAA/CNAME reconciliation and owned disposal. +- Keep Caddy on its bridge. Implement and verify a host gateway route, such as + `host.containers.internal:8123`; refuse a missing/unusable gateway. Configure + Home Assistant's forwarded-header trust for the actual proxy source and + restrict raw listener access so LAN or tailnet clients cannot bypass Caddy. + Network discovery must still operate independently of HTTP listener policy. +- Inspect the pinned release's supported initial HTTP/proxy configuration. + Current documentation describes UI-managed settings since 2026.8; do not + repeatedly overwrite `.storage` or assume a legacy YAML block remains the + convergence interface. Document any required one-time onboarding step. +- Preserve Home Assistant authentication. Do not add OAuth proxy, Tailscale + split DNS or Pi-hole UI-zone configuration. + +Exit: CI covers host upstream validation, existing bridge routes, aliases, +payload migration and private-value redaction without provider credentials. +Live test-environment acceptance verifies HTTPS onboarding/login, WebSocket +traffic, declared DNS ownership, and raw-port denial from unauthorized sources. +A request from outside the tailnet is denied. Use the existing bounded lifecycle +and record any unavailable source/network checks as pending. + +## 3. Scheduled backups and safe publication + +- Use Home Assistant native automatic backups, initially daily with seven local + copies and encryption explicitly enabled. Inspect/configure the supported + backup interface for the pinned release; document any one-time UI setup. + Container archives are under `/config/backups`, corresponding to + `/var/lib/data/homeassistant/config/backups` on the host. +- Add a bounded Rust export workflow using existing effect boundaries. Establish + successful completion through the supported backup state/API, not file age, + a guessed sleep, or an archive filename. Read original encrypted archives; + the ordinary UI download decrypts backups and is unsuitable for this export. +- Copy a completed archive to an ignored temporary file in the destination + filesystem, verify bytes, then rename atomically into + `/var/lib/data/syncthing/backups/homeassistant`. Interrupted work must be + safely retryable, duplicate exports must be no-ops, and malformed/incomplete + archives must never be published. Do not embed shell programs in Rust. +- Create the destination as an ordinary directory in the existing Syncthing + leaf. Configure it as a shared folder with an approved receiving peer; do not + assume that a directory under the subvolume is automatically replicated. + Reuse mapped Syncthing identity and typed ownership/ACL helpers. Give the + exporter read access to source archives and Syncthing read access to published + ones without broad access to the application tree. +- Keep btrbk's clamps selection at `syncthing`. Never snapshot the entire data + mount or add nested subvolumes. Define receiving-side history/retention + explicitly so local pruning or synchronization cannot erase every recovery + copy. Export pruning must never delete the last verified recovery copy. +- Retain the native encryption key/emergency kit in KeePassXC and independently + back up the encrypted vault. If exporting requires an application API token, + use supported least-privilege access and existing encrypted delivery, never + command-line credentials. When implementing key/token requirements, update + shared secret-audit metadata and regenerate the root secrets checklist; + distinguish operator-created recovery material from generated test secrets. +- Expose export/backup success and stale-backup failure through module-owned + monitoring, respecting existing test/lab alert exclusions. + +Exit: CI covers completed-only selection, atomic publication, interruption/retry, +identical re-export, corrupt input, missing mount, permissions and retention +using synthetic data. Live acceptance produces an encrypted archive, exports it, +proves a real peer received matching bytes, and includes it in the existing +btrbk snapshot. An unavailable peer is recorded as an open check, not success. + +## 4. Isolated recovery acceptance + +- Create a separately owned, named disposable VM/instance. Use generated test + accounts/integrations and a private test encryption key, never the production + automation identity. Establish persistent network isolation before importing + sensitive state; do not rely on NAT to block outbound device/cloud access. +- Restore a replicated archive into a fresh compatible Container installation + through its supported restore interface. Keep restored test state isolated, + including after reboot. Prove settings, synthetic automation and a known + database marker survive restoration, routine apply and reboot. +- Demonstrate wrong/missing-key refusal and preservation of the source archive. + Restore the archive from a disposable copy of the Syncthing btrbk snapshot + separately to prove historical recovery, without modifying the live leaf. +- Dispose only owned resources through the existing Rust lifecycle, including + test DNS/token/device cleanup. Never export backups or keys into public logs. + +Exit: both replicated-archive and snapshot recovery work; original backup and +independent recovery key remain usable. No restored instance contacts live +home devices or production cloud integrations. Record aggregate evidence and +limitations in `../butane/ACCEPTANCE.md`. + +## 5. Production activation and documentation + +- Confirm the actual device/integration inventory before requesting radios, + Bluetooth permissions or companion services. MQTT/Zigbee2MQTT and similar + separate containers need their own state/backup plans; Home Assistant's + Container backup does not back them up automatically. +- Configure production UI/credentials through the existing vault workflow; + validate real-client DNS, HTTPS, login and device discovery on clamps. + Keep smoke names, identities, archives and destinations separate from prod. +- Confirm daily encrypted backup, last-success monitoring, off-machine receipt, + recovery-key availability and free-space/retention policy. Keep an independent + recovery copy before an application update and test restore compatibility. +- Update designs and operational README instructions, including onboarding, + private URL, backup export, key storage, restore and upgrade rollback. + Use placeholders in the public repo; never commit real domains, identifiers, + archives or credentials. Install nothing on the workstation. + +Exit: run all applicable local CI checks and named-VM application acceptance; +record unavailable hardware/provider checks honestly. Make focused commits after +validation; do not push unless authorized. Update each slice's implemented vs +pending status, and keep physical discovery/production acceptance distinct from +VM acceptance. From ad1a08c1566edd972edd0dea8c01b46dc6f4b053 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 16:34:26 +0200 Subject: [PATCH 248/252] refactor(fleet)!: rename replacement hosts to joey and donbot Update host profiles, Cargo binaries, Butane inputs, CI and plans. Preserve historical acceptance evidence and retained VM/vault identities. No compatibility aliases are provided. --- .github/workflows/ci.yml | 2 +- ublue/AGENTS.md | 2 +- ublue/butane/ACCEPTANCE.md | 21 ++++- ublue/butane/README.md | 12 +-- ublue/butane/includes/skillet-beelzebot.bu | 8 -- ublue/butane/includes/skillet-joey.bu | 8 ++ .../{ucore-beelzebot.bu => ucore-joey.bu} | 2 +- ublue/butane/{beelzebot.bu => joey.bu} | 8 +- ublue/butane/tests/bootstrap.sh | 12 +-- ublue/design/fleet-hosts.md | 18 +++-- ublue/design/pihole-network.md | 4 +- ublue/design/private-ui-access.md | 4 +- ublue/design/tailscale.md | 4 +- ublue/plan/CLAMPS-MIGRATION.md | 2 +- ublue/plan/GENERIC-PRIVATE-UIS.md | 4 +- ...ELZEBOT-MIGRATION.md => JOEY-MIGRATION.md} | 16 ++-- ublue/plan/REFACTOR-02-HOST-PROFILES.md | 2 +- ublue/skillet/AGENTS.md | 2 +- ublue/skillet/Cargo.lock | 12 +-- ublue/skillet/Cargo.toml | 2 +- ublue/skillet/README.md | 10 +-- ublue/skillet/crates/caddy/src/tests.rs | 16 ++-- .../cli/src/application_acceptance_tests.rs | 4 +- ublue/skillet/crates/cli/src/main.rs | 2 +- .../crates/cli/src/secret_delivery/tests.rs | 10 +-- .../crates/host-profiles/src/hosts_tests.rs | 77 +++++++++---------- .../crates/host-profiles/src/profile.rs | 10 +-- .../hosts/{beelzebot => joey}/Cargo.toml | 2 +- .../hosts/{beelzebot => joey}/src/main.rs | 4 +- .../src/credential_delivery/tests.rs | 2 +- .../workstation/src/root_recovery/tests.rs | 2 +- .../crates/workstation/src/secrets/tests.rs | 13 ++-- .../workstation/src/ui_provisioning/tests.rs | 16 ++-- 33 files changed, 163 insertions(+), 150 deletions(-) delete mode 100644 ublue/butane/includes/skillet-beelzebot.bu create mode 100644 ublue/butane/includes/skillet-joey.bu rename ublue/butane/includes/{ucore-beelzebot.bu => ucore-joey.bu} (86%) rename ublue/butane/{beelzebot.bu => joey.bu} (89%) rename ublue/plan/{BEELZEBOT-MIGRATION.md => JOEY-MIGRATION.md} (87%) rename ublue/skillet/crates/hosts/{beelzebot => joey}/Cargo.toml (83%) rename ublue/skillet/crates/hosts/{beelzebot => joey}/src/main.rs (51%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e1eeff4..a29b228b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,7 @@ jobs: if: steps.filter.outputs.rust == 'true' run: | cd ublue/skillet - cargo run --bin skillet -- test run beelzebot --phase base --image fedora:latest + cargo run --bin skillet -- test run joey --phase base --image fedora:latest - name: Run installation and bootstrap regressions if: steps.filter.outputs.rust == 'true' diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 99e8c2d3..933f8fe3 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -144,7 +144,7 @@ `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace --all-targets`, and - `cargo run --bin skillet -- test run beelzebot --phase base --image fedora:latest` + `cargo run --bin skillet -- test run joey --phase base --image fedora:latest` from `.github/workflows/ci.yml`. For shell changes, run the workflow's ShellCheck commands; for Python or Chef changes, run the corresponding Ruff/Mypy or Cookstyle commands. diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index ee655782..44190ec5 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,20 @@ +## Fleet rename — 2026-10-11 + +Current profiles/artifacts use `joey` (formerly beelzebot) and `donbot` +(formerly bender). Dated commands, VM identities and evidence paths below retain +the values actually used; they are not acceptance of freshly renamed machines. +Retained run manifests, provider ownership and vault entries were not mutated. +New image publication and a fresh joey boot/application check remain pending; +see [the joey migration plan](../plan/JOEY-MIGRATION.md). +Rename validation passed: offline locked workspace/all-target compile check, +strict all-target Clippy, formatting and all 372 workspace tests. Nine bootstrap +regressions (including joey/donbot image selection), both hardware refusal cases, +and changed-script ShellCheck passed. The Fedora CI base integration for joey +applied twice, issued no service start/restart on repeat apply and cleaned its +container (exit 0). Document/include links and the image recipe matrix/base-build +dependency resolve. Fresh named-VM acceptance and image builds were not run for +this rename; previous dated results remain historical evidence. + ## Live vault-backed root recovery — 2026-10-10 - Fresh native `clamps / vault-recovery`, SSH port 2202, explicitly selected @@ -2399,7 +2416,7 @@ update / rollback matrix cases. A header backup is not a service-data backup. Syncthing, caller-selected Syncthing-only btrbk, DDNS, Datadog and SMTP; generic Caddy exposes only the declared UIs. UniFi is not selected. - Operator explicitly authorized removing the misspelled profile/package without - compatibility. Current commands and CI use `beelzebot`; earlier log entries + compatibility. At that time commands and CI used `beelzebot`; earlier log entries retain the command spelling actually executed. No vault or manifest migrated. - Local: all 372 workspace/all-target tests, formatting, strict pedantic Clippy, Fedora `test run beelzebot --phase base --image fedora:latest` (two applies, @@ -2430,4 +2447,4 @@ update / rollback matrix cases. A header backup is not a service-data backup. Full application/provider acceptance remains pending an operator unlock. - Remaining parity: private Pi-hole v6 DNS configuration delivery, boxy's subnet routing/acceptance/SNAT policy, private cross-host UI/probes, source data/ACLs, - physical deployment and cutover. See `../plan/BEELZEBOT-MIGRATION.md`. + physical deployment and cutover. See `../plan/JOEY-MIGRATION.md`. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 2e75719a..cd2d65f2 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -3,14 +3,14 @@ For real servers, use the separate [physical installation flow](HARDWARE.md). VM creation keeps its `/dev/vda` template and independent root-profile choice. -Available VM templates: `clamps.bu` and `beelzebot.bu`. Both retain `/dev/vda` -and the shared Btrfs data layout. Use `beelzebot` for boxy's replacement; the -old misspelled selector/package is removed. [Migration plan](../plan/BEELZEBOT-MIGRATION.md). +Available VM templates: `clamps.bu` and `joey.bu`. Both retain `/dev/vda` +and the shared Btrfs data layout. Use `joey` for boxy's replacement; +retained VM manifests and vault entries are not renamed implicitly. [Migration plan](../plan/JOEY-MIGRATION.md). ```bash -./bin/test-vm beelzebot create smoke --port 2202 -./bin/test-vm beelzebot ready smoke -./bin/test-vm beelzebot destroy smoke +./bin/test-vm joey create smoke --port 2202 +./bin/test-vm joey ready smoke +./bin/test-vm joey destroy smoke ``` Choose a free port if another VM already uses 2202. diff --git a/ublue/butane/includes/skillet-beelzebot.bu b/ublue/butane/includes/skillet-beelzebot.bu deleted file mode 100644 index 714c814f..00000000 --- a/ublue/butane/includes/skillet-beelzebot.bu +++ /dev/null @@ -1,8 +0,0 @@ -variant: fcos -version: 1.6.0 -storage: - files: - - path: /var/usrlocal/bin/skillet-beelzebot - mode: 0755 - contents: - local: files/skillet-beelzebot diff --git a/ublue/butane/includes/skillet-joey.bu b/ublue/butane/includes/skillet-joey.bu new file mode 100644 index 00000000..3ddddc6d --- /dev/null +++ b/ublue/butane/includes/skillet-joey.bu @@ -0,0 +1,8 @@ +variant: fcos +version: 1.6.0 +storage: + files: + - path: /var/usrlocal/bin/skillet-joey + mode: 0755 + contents: + local: files/skillet-joey diff --git a/ublue/butane/includes/ucore-beelzebot.bu b/ublue/butane/includes/ucore-joey.bu similarity index 86% rename from ublue/butane/includes/ucore-beelzebot.bu rename to ublue/butane/includes/ucore-joey.bu index 2fbf7c3a..3e3c74c7 100644 --- a/ublue/butane/includes/ucore-beelzebot.bu +++ b/ublue/butane/includes/ucore-joey.bu @@ -7,7 +7,7 @@ storage: mode: 0644 contents: inline: | - ghcr.io/gbagnoli/ucore-beelzebot + ghcr.io/gbagnoli/ucore-joey systemd: units: - name: systemd-resolved.service diff --git a/ublue/butane/beelzebot.bu b/ublue/butane/joey.bu similarity index 89% rename from ublue/butane/beelzebot.bu rename to ublue/butane/joey.bu index 10632dde..61b6072b 100644 --- a/ublue/butane/beelzebot.bu +++ b/ublue/butane/joey.bu @@ -11,10 +11,10 @@ ignition: - local: includes/data-storage.bu - local: includes/brew.bu - local: includes/skillet.bu - - local: includes/skillet-beelzebot.bu + - local: includes/skillet-joey.bu - local: includes/skillet-full.bu - local: includes/ucore-bootstrap.bu - - local: includes/ucore-beelzebot.bu + - local: includes/ucore-joey.bu storage: disks: - # The link to the block device the OS was booted from. @@ -39,8 +39,8 @@ storage: - path: /etc/skillet/host mode: 0644 contents: - inline: beelzebot + inline: joey - path: /etc/hostname mode: 0644 contents: - inline: beelzebot + inline: joey diff --git a/ublue/butane/tests/bootstrap.sh b/ublue/butane/tests/bootstrap.sh index 1e1db5a9..aa182d72 100644 --- a/ublue/butane/tests/bootstrap.sh +++ b/ublue/butane/tests/bootstrap.sh @@ -64,12 +64,12 @@ check() { } check fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' "$unsigned" 1 0 0 -check bender_fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ - 'ostree-unverified-registry:ghcr.io/gbagnoli/ucore-bender:latest' 1 0 0 '' 0 \ - 'ghcr.io/gbagnoli/ucore-bender' -check beelzebot_fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ - 'ostree-unverified-registry:ghcr.io/gbagnoli/ucore-beelzebot:latest' 1 0 0 '' 0 \ - 'ghcr.io/gbagnoli/ucore-beelzebot' +check donbot_fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ + 'ostree-unverified-registry:ghcr.io/gbagnoli/ucore-donbot:latest' 1 0 0 '' 0 \ + 'ghcr.io/gbagnoli/ucore-donbot' +check joey_fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ + 'ostree-unverified-registry:ghcr.io/gbagnoli/ucore-joey:latest' 1 0 0 '' 0 \ + 'ghcr.io/gbagnoli/ucore-joey' check unsigned_with_signed_rollback \ "{\"deployments\":[{\"booted\":true,\"container-image-reference\":\"$unsigned\"},{\"booted\":false,\"container-image-reference\":\"$signed\"}]}" \ "$signed" 1 0 0 diff --git a/ublue/design/fleet-hosts.md b/ublue/design/fleet-hosts.md index e54c6875..21ab3c82 100644 --- a/ublue/design/fleet-hosts.md +++ b/ublue/design/fleet-hosts.md @@ -1,15 +1,19 @@ # Fleet host declarations -Boxy is replaced by **beelzebot**; rupik by clamps; calculon by bender. +Boxy is replaced by **joey**; rupik by clamps; calculon by donbot. The host profile is the authority for services, UI routes, credentials, boot expectations and acceptance. Shared recipes own behavior; host callers opt in. -Beelzebot selects Pi-hole, Syncthing, Tailscale, btrbk, DDNS, Datadog and shared +Joey selects Pi-hole, Syncthing, Tailscale, btrbk, DDNS, Datadog and shared SMTP. UniFi remains selected only where declared. -Decision, 2026-10-11: correct the old beezelbot spelling with no compatibility, -explicitly authorized by the operator. No manifest or vault entry is silently -renamed. Historical acceptance commands preserve the spelling actually executed. -Current CLI selectors, binary/package names, templates and images use beelzebot. +Decision, 2026-10-11: use joey for boxy's replacement and donbot for +calculon's replacement. The rename deliberately provides no selector, package +or image aliases. Current CLI selectors, binary/package names, templates and +image builds use these identities. Do not silently rename retained VM manifests, +provider ownership journals or vault entries; historical acceptance preserves +the identities actually tested. Existing disposable runs need an explicit +owned cleanup/migration path; new provisioning uses the renamed profiles and +requires the corresponding images to be built and published first. Both servers share the Btrfs data layout and credential-gated full-apply unit. The full unit applies to profiles requiring Pi-hole and Tailscale credentials; @@ -18,4 +22,4 @@ includes select artifacts/images; generic units read `/etc/skillet/host`. Boxy's subnet-routing and private cross-host routes/probes need separate policy and private inventory. Selecting the current shared modules does not claim full -live source parity. See [the migration plan](../plan/BEELZEBOT-MIGRATION.md). +live source parity. See [the migration plan](../plan/JOEY-MIGRATION.md). diff --git a/ublue/design/pihole-network.md b/ublue/design/pihole-network.md index ed52e1b4..04a5da8b 100644 --- a/ublue/design/pihole-network.md +++ b/ublue/design/pihole-network.md @@ -13,9 +13,9 @@ qualified registry image for Quadlet auto-update; the OS image already enables the auto-update timer. The planned private UI route and Cloudflare DNS-only records are described in the [private UI design](private-ui-access.md). -The planned second Pi-hole on beelzebot beside clamps is a separate DNS +The planned second Pi-hole on joey beside clamps is a separate DNS instance on the same power and internet connection. A possible third on remote -bender adds site diversity. All instances offered to the same clients must +donbot adds site diversity. All instances offered to the same clients must serve consistent local records and filtering policy; the synchronization mechanism and LAN client resolver settings remain to be decided before those hosts are deployed. diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index 8f18e64e..f06e2c91 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -44,8 +44,8 @@ revokes that token. Production records use a separately scoped credential; update them if a host's tailnet address changes. Tailscale enrollment needs no DNS-management OAuth scope. Keep the Cloudflare token creator on the workstation. -Plan a second Pi-hole on beelzebot beside clamps. They cover individual host -failure but share power and internet failure. A third Pi-hole on bender, +Plan a second Pi-hole on joey beside clamps. They cover individual host +failure but share power and internet failure. A third Pi-hole on donbot, the calculon replacement in the other country, would add a separate site. These Pi-hole instances provide redundant client DNS service, independent of UI name publication in Cloudflare. A healthy resolver can resolve an offline diff --git a/ublue/design/tailscale.md b/ublue/design/tailscale.md index a318936a..cf454fe8 100644 --- a/ublue/design/tailscale.md +++ b/ublue/design/tailscale.md @@ -1,7 +1,7 @@ # Tailscale host routing Run Tailscale rootfully with host networking and persistent state. The canonical -host caller selects `advertise_exit_node`; clamps and beelzebot enable it. Production delivery +host caller selects `advertise_exit_node`; clamps and joey enable it. Production delivery resolves that declaration into a versioned encrypted enrollment payload. Disposable delivery always selects false. Never infer environment from a runtime hostname. Only the decoded enrollment key becomes the Podman secret; policy stays in clear @@ -31,4 +31,4 @@ References: [exit-node policy](https://tailscale.com/docs/features/exit-nodes), Boxy's LAN subnet advertisement, route acceptance and disabled subnet SNAT are not supplied by this exit-node switch. Shared private routing policy is the next -[beelzebot slice](../plan/BEELZEBOT-MIGRATION.md); no LAN routes are guessed. +[joey slice](../plan/JOEY-MIGRATION.md); no LAN routes are guessed. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md index 9cac84bd..cccb5509 100644 --- a/ublue/plan/CLAMPS-MIGRATION.md +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -32,7 +32,7 @@ open; previous passing native VM checks do not close them. tests. Broader OS hardening is still a placeholder, not an accepted port. No new clamps service crate is required to start -[boxy → beelzebot](BEELZEBOT-MIGRATION.md). Acceptance details remain in +[boxy → joey](JOEY-MIGRATION.md). Acceptance details remain in [ACCEPTANCE.md](../butane/ACCEPTANCE.md) and the service plans. ## Goal and starting point diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md index e63691ad..ed0b420d 100644 --- a/ublue/plan/GENERIC-PRIVATE-UIS.md +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -90,9 +90,9 @@ bridge upstream (container DNS name and port). Each host caller supplies its enabled services and Podman network through one reusable host definition. Use that same declaration for Caddy and workstation DNS planning; do not maintain two lists that can drift. Clamps declares Syncthing and Pi-hole; -beelzebot now declares both through the same profile mechanism and its own +joey now declares both through the same profile mechanism and its own bridge. Service-free and injected capability subsets test hosts without a -Pi-hole dependency. See [its migration plan](BEELZEBOT-MIGRATION.md). +Pi-hole dependency. See [its migration plan](JOEY-MIGRATION.md). Declaring future services must not require editing Caddy's renderer. Add a validated environment configuration and one hostname derivation helper. diff --git a/ublue/plan/BEELZEBOT-MIGRATION.md b/ublue/plan/JOEY-MIGRATION.md similarity index 87% rename from ublue/plan/BEELZEBOT-MIGRATION.md rename to ublue/plan/JOEY-MIGRATION.md index a922a94b..b169f57d 100644 --- a/ublue/plan/BEELZEBOT-MIGRATION.md +++ b/ublue/plan/JOEY-MIGRATION.md @@ -1,8 +1,12 @@ -# Boxy → beelzebot +# Boxy → joey Status: host/boot slice implemented; native base readiness and fixture repeat-apply/reboot accepted, 2026-10-11. Full application/provider acceptance is pending a vault unlock; the rebuilt image change and remote CI are unverified. +Host identity changed to `joey` on 2026-10-11; the earlier native acceptance +used its previous identity. Fresh joey boot/application acceptance and publication +of `ghcr.io/gbagnoli/ucore-joey` remain pending. Existing VM manifests and vault +entries are not rewritten or accepted as joey implicitly. Review completed against `roles/boxy.rb` and `site-cookbooks/boxy/recipes/default.rb` and its includes. Private Chef overrides and live boxy state have not been inventoried. @@ -22,14 +26,14 @@ Clamps remaining work is tracked in [its migration plan](CLAMPS-MIGRATION.md). | Datadog host/process/network checks and private cross-host probes | Shared selected Agent, production region `ftwo`, test `lab`/`env:test`; private remote probes not yet ported | | ET and server baseline | Common image + shared hardening/user environment; shared SMTP also selected for every fleet server | -UniFi is absent from boxy's default recipe; beelzebot does not select it. +UniFi is absent from boxy's default recipe; joey does not select it. No private addresses, domains, keys or effective Chef overrides belong here. ## Implementation order and exits -1. **Implemented; native boot/fixture accepted:** canonical `beelzebot` crate/profile/image, shared +1. **Implemented; native boot/fixture accepted:** canonical `joey` crate/profile/image, shared full-apply unit, host Butane with existing `/dev/vda` and Btrfs layout. - Remove the old misspelling without compatibility at operator request. + Use the new identity consistently without compatibility aliases. Profile selection must drive UIs, credentials, monitoring and snapshots; tests cover those declarations and bootstrap inputs. The matching image recipe must mask resolved. Validate strict Butane compilation, local CI @@ -47,7 +51,7 @@ No private addresses, domains, keys or effective Chef overrides belong here. empty/configured records, repeat apply, reboot and private-value redaction. Settle whether omission preserves restored records before production import. 4. **Named application acceptance:** unlock with the existing workflow; run - beelzebot full provisioning and capability-selected repeat-apply/reboot checks, + joey full provisioning and capability-selected repeat-apply/reboot checks, then opted-in UI, DDNS, Datadog and isolated SMTP checks. Use disposable identities, test DNS and lifecycle-owned cleanup. No production writer/peer identity during acceptance; record failed and unavailable checks honestly. @@ -57,7 +61,7 @@ No private addresses, domains, keys or effective Chef overrides belong here. retention and rollback. Stage copies with original writers stopped as needed; do LAN/device and production certificate acceptance before moving addresses. -Use `skillet test vm create beelzebot INSTANCE --port FREE_PORT`, then +Use `skillet test vm create joey INSTANCE --port FREE_PORT`, then `test vm ready`, `test vm provision`, `test smoke --with-applications` and `test vm destroy` through the shared Rust lifecycle owner. Provisioning may need cached vault access; do independent work if unlock would block. Install diff --git a/ublue/plan/REFACTOR-02-HOST-PROFILES.md b/ublue/plan/REFACTOR-02-HOST-PROFILES.md index 1aa04f2b..ccd89c70 100644 --- a/ublue/plan/REFACTOR-02-HOST-PROFILES.md +++ b/ublue/plan/REFACTOR-02-HOST-PROFILES.md @@ -72,7 +72,7 @@ remains independent of deployment names. ## Progress - Implemented: `skillet_hosts` is the canonical profile and composition crate - for clamps, beelzebot, and the explicit generic agent baseline. Profile data + for clamps, joey, and the explicit generic agent baseline. Profile data owns boot image/masks, enabled services, UI exposure, credentials, bridge network, storage requirement, and optional btrbk inputs. - Implemented: generic and per-host apply wrappers, Caddy apply, credential diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md index f7c378cc..575a678a 100644 --- a/ublue/skillet/AGENTS.md +++ b/ublue/skillet/AGENTS.md @@ -213,7 +213,7 @@ skillet/ ├── cli/ # Workstation and guest CLI entry point └── hosts/ ├── clamps/ # skillet-clamps host entry point - └── beelzebot/ # skillet-beelzebot host entry point + └── joey/ # skillet-joey host entry point ``` ## Module Design diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index fbb65c4b..7f2c466b 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2321,23 +2321,23 @@ dependencies = [ ] [[package]] -name = "skillet-beelzebot" +name = "skillet-clamps" version = "0.1.0" dependencies = [ "anyhow", "skillet_cli_common", + "skillet_core", + "skillet_hardening", + "skillet_pihole", + "skillet_podman", ] [[package]] -name = "skillet-clamps" +name = "skillet-joey" version = "0.1.0" dependencies = [ "anyhow", "skillet_cli_common", - "skillet_core", - "skillet_hardening", - "skillet_pihole", - "skillet_podman", ] [[package]] diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index a3cb73fe..e718bcb0 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -13,7 +13,7 @@ members = [ "crates/smtp", "crates/podman", "crates/cli", - "crates/hosts/beelzebot", + "crates/hosts/joey", "crates/hosts/clamps", "crates/cli-common", "crates/test-fixture", diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index ec777be4..c5c16c01 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -81,16 +81,16 @@ to converge only the shared host baseline before credentials are available. ```bash ./target/x86_64-unknown-linux-musl/debug/skillet apply --host clamps --phase base ``` -- **Host-Specific Configuration**: If a host-specific binary has been built (e.g., `skillet-beelzebot`), you can use it to apply specific configurations. +- **Host-Specific Configuration**: If a host-specific binary has been built (e.g., `skillet-joey`), you can use it to apply specific configurations. ## Disposable VM smoke check -`beelzebot` replaces boxy and selects Pi-hole, Syncthing, Tailscale, snapshots, -DDNS, Datadog and shared SMTP. Use the [migration plan](../plan/BEELZEBOT-MIGRATION.md) +`joey` replaces boxy and selects Pi-hole, Syncthing, Tailscale, snapshots, +DDNS, Datadog and shared SMTP. Use the [migration plan](../plan/JOEY-MIGRATION.md) for remaining routing/production work and [host design](../design/fleet-hosts.md) for selection decisions. The old misspelled binary/profile has been removed. -The CI command `skillet test run beelzebot --phase base --image fedora:latest` +The CI command `skillet test run joey --phase base --image fedora:latest` runs the selected apply phase twice in a uniquely named, temporary Podman container. It builds the selected host binary, checks both applies succeed, and checks the second apply does not start or restart a service. `clamps` is @@ -471,7 +471,7 @@ record/token ownership is cleaned up by `test vm destroy`; do not use direct - Workspace checks: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo test --workspace --all-targets`. -- Container integration: `cargo run --bin skillet -- test run beelzebot +- Container integration: `cargo run --bin skillet -- test run joey --phase base --image fedora:latest` (also run for `clamps` after changing shared container behavior). - Bootstrap state regressions: `bash ublue/butane/tests/bootstrap.sh` from the diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index 01e59256..e393d08e 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -22,7 +22,7 @@ fn applying_unchanged_caddy_sites_preserves_the_container() { .ensure_podman_secret("cloudflare_acme_token", "dummy-token") .unwrap(); let sites = CaddySites::from_host( - "beelzebot", + "joey", &UiEnvironment { ui_domain: "test.example.invalid".to_string(), acme_staging: true, @@ -30,7 +30,7 @@ fn applying_unchanged_caddy_sites_preserves_the_container() { &syncthing_only(), ) .unwrap(); - super::apply(&system, &files, &sites, "beelzebot").unwrap(); + super::apply(&system, &files, &sites, "joey").unwrap(); let initial_restart_count = system.restart_count.load(Ordering::SeqCst); let initial_config = files .files @@ -47,7 +47,7 @@ fn applying_unchanged_caddy_sites_preserves_the_container() { .unwrap() .contains_key("/etc/skillet/caddy/Caddyfile")); - super::apply(&system, &files, &sites, "beelzebot").unwrap(); + super::apply(&system, &files, &sites, "joey").unwrap(); assert_eq!( system.restart_count.load(Ordering::SeqCst), @@ -71,7 +71,7 @@ fn derives_a_single_non_clamps_service_hostname_for_either_environment() { ("prod.example.invalid", false), ] { let sites = CaddySites::from_host( - "beelzebot", + "joey", &UiEnvironment { ui_domain: domain.to_string(), acme_staging: staging, @@ -82,7 +82,7 @@ fn derives_a_single_non_clamps_service_hostname_for_either_environment() { assert_eq!(sites.services.len(), 1); assert_eq!( sites.services[0].hostname, - format!("syncthing.beelzebot.{domain}") + format!("syncthing.joey.{domain}") ); assert_eq!(sites.acme_staging, staging); } @@ -128,7 +128,7 @@ fn renders_each_declared_service_with_tailnet_filter_and_container_dns() { fn guest_payload_must_match_the_callers_declared_services() { let services = syncthing_only(); let expected = CaddySites::from_host( - "beelzebot", + "joey", &UiEnvironment { ui_domain: "test.example.invalid".to_string(), acme_staging: true, @@ -138,11 +138,11 @@ fn guest_payload_must_match_the_callers_declared_services() { .unwrap(); let payload = serde_json::to_string(&expected).unwrap(); assert_eq!( - CaddySites::parse(&payload, "beelzebot", &services).unwrap(), + CaddySites::parse(&payload, "joey", &services).unwrap(), expected ); assert!(CaddySites::parse(&payload, "clamps", &services).is_err()); - assert!(CaddySites::parse(&payload, "beelzebot", &[]).is_err()); + assert!(CaddySites::parse(&payload, "joey", &[]).is_err()); } #[test] diff --git a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs index 4c7241d4..80bd435a 100644 --- a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs +++ b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs @@ -55,7 +55,7 @@ impl GuestTransport for AcceptanceTransport { fn transport() -> AcceptanceTransport { AcceptanceTransport { active: true, - network: "beelzebot".into(), + network: "joey".into(), missing_listener: None, owner: "1042:2047".into(), container_id: "container-id".into(), @@ -65,7 +65,7 @@ fn transport() -> AcceptanceTransport { // These observations exercise the Syncthing/Caddy capability subset only. // Host selection itself is checked against the complete canonical declaration. fn syncthing_ui_plan() -> HostAcceptancePlan { - let mut plan = profile_for_name("beelzebot").unwrap().acceptance_plan(); + let mut plan = profile_for_name("joey").unwrap().acceptance_plan(); plan.services .retain(|service| matches!(service.unit.as_str(), "syncthing.service" | "caddy.service")); plan diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index b54cfbf8..9ed5d444 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -1395,7 +1395,7 @@ mod tests { "skillet", "test", "run", - "beelzebot", + "joey", "--phase", "base", "--image", diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs index 66bf1ce5..3050b07f 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -85,11 +85,11 @@ fn delivery_eligibility_uses_declared_service_capabilities() { assert!(validate_delivery_service("clamps", "datadog").is_ok()); assert!(validate_delivery_service("agent", "pihole").is_err()); assert!(validate_delivery_service("agent", "tailscale").is_err()); - assert!(validate_delivery_service("beelzebot", "caddy").is_ok()); + assert!(validate_delivery_service("joey", "caddy").is_ok()); assert!(validate_delivery_service("agent", "ddns").is_err()); assert!(validate_delivery_service("clamps", "ddns").is_ok()); for service in ["pihole", "tailscale", "caddy", "ddns", "datadog", "smtp"] { - assert!(validate_delivery_service("beelzebot", service).is_ok()); + assert!(validate_delivery_service("joey", service).is_ok()); } assert!(validate_delivery_service("missing-host", "caddy").is_err()); assert!(validate_delivery_service("clamps", "unknown").is_err()); @@ -163,19 +163,19 @@ fn smtp_delivery_and_capture_only_cli_are_supported() { "test", "vm", "provision", - "beelzebot", + "joey", "smtp", "--smtp-only" ]) .is_ok()); assert!(super::validate_delivery_service("agent", "smtp").is_err()); - assert!(super::validate_delivery_service("beelzebot", "smtp").is_ok()); + assert!(super::validate_delivery_service("joey", "smtp").is_ok()); } #[test] fn live_smtp_requires_explicit_smtp_only_selection() { use clap::Parser; - let base = ["skillet", "test", "vm", "provision", "beelzebot", "mailjet"]; + let base = ["skillet", "test", "vm", "provision", "joey", "mailjet"]; assert!(super::super::Args::try_parse_from(base.into_iter().chain(["--live-smtp"])).is_err()); let parsed = super::super::Args::try_parse_from(base.into_iter().chain(["--smtp-only", "--live-smtp"])) diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index ec06f739..96cd95db 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -50,9 +50,9 @@ fn boot_expectations_are_profile_inputs_and_unknown_profiles_are_refused() { let clamps = super::boot_policy_for_host("clamps").unwrap(); assert_eq!(clamps.signed_image, "ghcr.io/gbagnoli/ucore-clamps"); assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); - let other = super::boot_policy_for_host("beelzebot").unwrap(); + let other = super::boot_policy_for_host("joey").unwrap(); assert_eq!(other.masked_units, ["systemd-resolved.service"]); - assert_eq!(other.signed_image, "ghcr.io/gbagnoli/ucore-beelzebot"); + assert_eq!(other.signed_image, "ghcr.io/gbagnoli/ucore-joey"); assert!(super::boot_policy_for_host("clamps-test-smoke").is_none()); assert!(super::boot_policy_for_host("unknown").is_none()); } @@ -143,10 +143,9 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s .options .contains(&"Subnet=fd59:4e23:2950:11f5::/64".to_string())); - let beelzebot = super::profile_for_name("beelzebot").unwrap(); + let joey = super::profile_for_name("joey").unwrap(); assert_eq!( - beelzebot - .services + joey.services .iter() .map(HostService::name) .collect::>(), @@ -160,20 +159,17 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s "smtp" ] ); - assert!(!beelzebot.supports_service("unifi")); + assert!(!joey.supports_service("unifi")); assert_eq!( - beelzebot.btrbk_config().unwrap().snapshot_subvolumes, + joey.btrbk_config().unwrap().snapshot_subvolumes, [std::path::PathBuf::from("syncthing")] ); - assert_eq!( - beelzebot.signed_image, - Some("ghcr.io/gbagnoli/ucore-beelzebot") - ); - assert_eq!(beelzebot.masked_units, ["systemd-resolved.service"]); - assert!(beelzebot.requires_data_mount); - assert!(beelzebot.requires_pihole_dns_listener_policy()); - assert!(beelzebot.tailscale_exit_node()); - assert!(beelzebot + assert_eq!(joey.signed_image, Some("ghcr.io/gbagnoli/ucore-joey")); + assert_eq!(joey.masked_units, ["systemd-resolved.service"]); + assert!(joey.requires_data_mount); + assert!(joey.requires_pihole_dns_listener_policy()); + assert!(joey.tailscale_exit_node()); + assert!(joey .services .iter() .any(|service| matches!(service.config, ServiceConfig::Pihole { custom_dns: [] }))); @@ -231,7 +227,7 @@ fn credential_values_are_selected_by_host_and_apply_phase() { ] ); assert_eq!( - super::credentials_for_phase("beelzebot", super::HostApplyPhase::Full).unwrap(), + super::credentials_for_phase("joey", super::HostApplyPhase::Full).unwrap(), [ super::PIHOLE_WEB_PASSWORD_CREDENTIAL, super::TAILSCALE_AUTH_KEY_CREDENTIAL @@ -299,12 +295,12 @@ fn host_ui_declarations_include_only_the_services_each_host_runs() { assert_eq!(clamps.services[1].port, 8384); assert_eq!(clamps.services[1].aliases, ["sync.{host}"]); - let beelzebot = ui_config_for_host("beelzebot").expect("beelzebot UI declaration"); - assert_eq!(beelzebot.services.len(), 2); - assert_eq!(beelzebot.services[0].name, "pihole"); - assert_eq!(beelzebot.services[1].name, "syncthing"); - assert_eq!(beelzebot.services[1].aliases, ["sync.{host}"]); - assert_eq!(beelzebot.network_name, "beelzebot"); + let joey = ui_config_for_host("joey").expect("joey UI declaration"); + assert_eq!(joey.services.len(), 2); + assert_eq!(joey.services[0].name, "pihole"); + assert_eq!(joey.services[1].name, "syncthing"); + assert_eq!(joey.services[1].aliases, ["sync.{host}"]); + assert_eq!(joey.network_name, "joey"); assert!(ui_config_for_host("unknown-host").is_none()); } @@ -362,10 +358,9 @@ fn application_acceptance_tracks_each_profiles_declared_services() { Some(AcceptanceOwner::Numeric { uid: 999, gid: 999 }) ); - let beelzebot = profile_for_name("beelzebot").unwrap().acceptance_plan(); + let joey = profile_for_name("joey").unwrap().acceptance_plan(); assert_eq!( - beelzebot - .services + joey.services .iter() .map(|service| service.unit.as_str()) .collect::>(), @@ -377,14 +372,14 @@ fn application_acceptance_tracks_each_profiles_declared_services() { "caddy.service" ] ); - assert!(beelzebot + assert!(joey .services .iter() .filter(|service| matches!( service.container.as_deref(), Some("pihole" | "syncthing" | "caddy") )) - .all(|service| service.network_mode.as_deref() == Some("beelzebot"))); + .all(|service| service.network_mode.as_deref() == Some("joey"))); } #[test] @@ -483,7 +478,7 @@ fn a_credential_service_remains_eligible_when_its_ui_is_not_exposed() { #[test] fn smtp_is_shared_and_has_an_independent_credential_phase() { - for host in ["clamps", "beelzebot"] { + for host in ["clamps", "joey"] { let profile = super::profile_for_name(host).unwrap(); assert_eq!( profile @@ -532,28 +527,26 @@ fn tailscale_exit_node_is_caller_selected_and_preferences_follow_readiness() { } #[test] -fn beelzebot_boot_template_and_credential_unit_match_the_profile() { - let template = include_str!("../../../../butane/beelzebot.bu"); - let image = include_str!("../../../../butane/includes/ucore-beelzebot.bu"); +fn joey_boot_template_and_credential_unit_match_the_profile() { + let template = include_str!("../../../../butane/joey.bu"); + let image = include_str!("../../../../butane/includes/ucore-joey.bu"); let unit = include_str!("../../../../butane/includes/skillet-full.bu"); - let profile = super::profile_for_name("beelzebot").unwrap(); + let profile = super::profile_for_name("joey").unwrap(); assert!(template.contains("device: /dev/vda")); for include in [ "data-storage", "resolver", "skillet", "skillet-full", - "skillet-beelzebot", + "skillet-joey", "ucore-bootstrap", - "ucore-beelzebot", + "ucore-joey", ] { assert!(template.contains(&format!("local: includes/{include}.bu"))); } assert!(image.contains(profile.signed_image.unwrap())); assert!(image.contains("name: systemd-resolved.service\n mask: true")); - for credential in - super::credentials_for_phase("beelzebot", super::HostApplyPhase::Full).unwrap() - { + for credential in super::credentials_for_phase("joey", super::HostApplyPhase::Full).unwrap() { assert!(unit.contains(&format!("LoadCredentialEncrypted={credential}:/etc/credstore.encrypted/skillet/{credential}.cred"))); } assert!(unit.contains("apply --host-file /etc/skillet/host --phase full")); @@ -561,7 +554,7 @@ fn beelzebot_boot_template_and_credential_unit_match_the_profile() { } #[test] -fn beelzebot_full_apply_uses_shared_services_and_is_repeatable() { +fn joey_full_apply_uses_shared_services_and_is_repeatable() { use skillet_core::{ system::{AccountResource, GroupIdentity, UserIdentity}, test_utils::MockFiles, @@ -608,17 +601,17 @@ fn beelzebot_full_apply_uses_shared_services_and_is_repeatable() { .payload() .unwrap(), ); - super::apply_host("beelzebot", &system, &files, &credentials).unwrap(); + super::apply_host("joey", &system, &files, &credentials).unwrap(); let restarts = system.restart_count.load(Ordering::SeqCst); let starts = system.start_count.load(Ordering::SeqCst); let before = files.files.lock().unwrap().clone(); - super::apply_host("beelzebot", &system, &files, &credentials).unwrap(); + super::apply_host("joey", &system, &files, &credentials).unwrap(); assert_eq!(*files.files.lock().unwrap(), before); assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); assert_eq!(system.start_count.load(Ordering::SeqCst), starts); let pihole = String::from_utf8(before["/etc/containers/systemd/pihole.container"].clone()).unwrap(); - assert!(pihole.contains("Network=beelzebot.network")); + assert!(pihole.contains("Network=joey.network")); for address in ["0.0.0.0", "[::]"] { for protocol in ["tcp", "udp"] { assert!(pihole.contains(&format!("PublishPort={address}:53:53/{protocol}"))); diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index eda89621..cb1c6946 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -508,10 +508,10 @@ fn clamps() -> HostProfile { } /// Boxy's replacement. Private DNS and routing inputs are delivered separately. -fn beelzebot() -> HostProfile { +fn joey() -> HostProfile { HostProfile { - id: HostId::known("beelzebot"), - signed_image: Some("ghcr.io/gbagnoli/ucore-beelzebot"), + id: HostId::known("joey"), + signed_image: Some("ghcr.io/gbagnoli/ucore-joey"), masked_units: &["systemd-resolved.service"], network: NetworkPolicy { ipv4_subnet: "172.26.26.0/24", @@ -588,7 +588,7 @@ fn agent_baseline() -> HostProfile { pub fn profile_for_host(host: &HostId) -> Option { match host.as_str() { "clamps" => Some(with_fleet_baseline(clamps())), - "beelzebot" => Some(with_fleet_baseline(beelzebot())), + "joey" => Some(with_fleet_baseline(joey())), "agent" => Some(agent_baseline()), _ => None, } @@ -601,7 +601,7 @@ pub fn profile_for_name(host: &str) -> Option { pub fn declared_profiles() -> Vec { vec![ with_fleet_baseline(clamps()), - with_fleet_baseline(beelzebot()), + with_fleet_baseline(joey()), agent_baseline(), ] } diff --git a/ublue/skillet/crates/hosts/beelzebot/Cargo.toml b/ublue/skillet/crates/hosts/joey/Cargo.toml similarity index 83% rename from ublue/skillet/crates/hosts/beelzebot/Cargo.toml rename to ublue/skillet/crates/hosts/joey/Cargo.toml index f17c0fd0..9b014c7a 100644 --- a/ublue/skillet/crates/hosts/beelzebot/Cargo.toml +++ b/ublue/skillet/crates/hosts/joey/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "skillet-beelzebot" +name = "skillet-joey" version = "0.1.0" edition = "2021" diff --git a/ublue/skillet/crates/hosts/beelzebot/src/main.rs b/ublue/skillet/crates/hosts/joey/src/main.rs similarity index 51% rename from ublue/skillet/crates/hosts/beelzebot/src/main.rs rename to ublue/skillet/crates/hosts/joey/src/main.rs index 73433fb6..4034065a 100644 --- a/ublue/skillet/crates/hosts/beelzebot/src/main.rs +++ b/ublue/skillet/crates/hosts/joey/src/main.rs @@ -2,8 +2,8 @@ use anyhow::Result; use skillet_cli_common::{hosts, run_host}; fn main() -> Result<()> { - run_host("beelzebot", |phase, system, files, credentials| { - hosts::apply_host_phase("beelzebot", phase, system, files, credentials) + run_host("joey", |phase, system, files, credentials| { + hosts::apply_host_phase("joey", phase, system, files, credentials) .map_err(|e| e.to_string()) })?; Ok(()) diff --git a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs index 3ed33e17..67b45626 100644 --- a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs +++ b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs @@ -299,7 +299,7 @@ fn smtp_delivery_is_stdin_only_and_capture_cannot_contain_provider_fields() { }; assert!(crate::smtp_provisioning::deliver("agent", &input, &guest).is_err()); assert_eq!(guest.calls.lock().unwrap().len(), 0); - crate::smtp_provisioning::deliver("beelzebot", &input, &guest).unwrap(); + crate::smtp_provisioning::deliver("joey", &input, &guest).unwrap(); let calls = guest.calls.lock().unwrap(); assert!(calls.iter().all(|(args, _)| args .iter() diff --git a/ublue/skillet/crates/workstation/src/root_recovery/tests.rs b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs index 92c09bd7..fe4eb910 100644 --- a/ublue/skillet/crates/workstation/src/root_recovery/tests.rs +++ b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs @@ -139,7 +139,7 @@ fn failed_persistence_and_volume_mismatch_never_enroll() { fn wrong_host_never_saves() { let mut store = Store::default(); assert!(provision( - "beelzebot", + "joey", "path", Path::new("helper"), &mut store, diff --git a/ublue/skillet/crates/workstation/src/secrets/tests.rs b/ublue/skillet/crates/workstation/src/secrets/tests.rs index a648793d..3b48d81d 100644 --- a/ublue/skillet/crates/workstation/src/secrets/tests.rs +++ b/ublue/skillet/crates/workstation/src/secrets/tests.rs @@ -41,7 +41,7 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { .all(|entry| !entry.path.contains("/prod/") && !entry.path.ends_with("web-password") && !entry.path.ends_with("acme-token"))); - let profile = skillet_hosts::profile_for_name("beelzebot").unwrap(); + let profile = skillet_hosts::profile_for_name("joey").unwrap(); let report = check( &[profile], ProvisioningPolicy::new(Environment::Production), @@ -90,14 +90,14 @@ fn unused_audit_keeps_both_environments_hosts_generated_and_legacy_paths() { "skillet/environments/test/datadog/api-key", "skillet/environments/test/datadog/site", "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name", - "skillet/environments/prod/hosts/beelzebot/cloudflare/acme-token", - "skillet/environments/test/hosts/beelzebot/cloudflare/acme-token", + "skillet/environments/prod/hosts/joey/cloudflare/acme-token", + "skillet/environments/test/hosts/joey/cloudflare/acme-token", "skillet/hosts/clamps/cloudflare/acme-token", "skillet/hosts/clamps/pihole/web-password", "personal/not-for-skillet", "skillet/environments/dns/cloudflare-zone-id", "skillet/environments/prod/hosts/unknown/cloudflare/acme-token", - "skillet/environments/test/hosts/beelzebot/cloudflare/ddns-dns-name", + "skillet/environments/test/hosts/joey/cloudflare/ddns-dns-name", ] .map(str::to_string); let unused = unused_paths(&skillet_hosts::profile::declared_profiles(), &stored).unwrap(); @@ -108,8 +108,9 @@ fn unused_audit_keeps_both_environments_hosts_generated_and_legacy_paths() { assert!( unused.contains(&"skillet/environments/prod/hosts/unknown/cloudflare/acme-token".into()) ); - assert!(!unused - .contains(&"skillet/environments/test/hosts/beelzebot/cloudflare/ddns-dns-name".into())); + assert!( + !unused.contains(&"skillet/environments/test/hosts/joey/cloudflare/ddns-dns-name".into()) + ); } #[test] diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index 07d4991e..bc75b917 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -338,7 +338,7 @@ fn create_cleanup_fixture(run_directory: &Path, provider: &FakeCloudflare) { #[test] fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { let production = build_ui_provisioning_plan( - "beelzebot", + "joey", ProvisioningPolicy::new(Environment::Production), "example.invalid", Some("ui"), @@ -346,7 +346,7 @@ fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { ) .unwrap(); let test = build_ui_provisioning_plan( - "beelzebot", + "joey", ProvisioningPolicy::new(Environment::Test), "example.invalid", Some("smoke.ui"), @@ -354,14 +354,8 @@ fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { ) .unwrap(); - assert_eq!( - production.sites.machine_hostname, - "beelzebot.ui.example.invalid" - ); - assert_eq!( - test.sites.machine_hostname, - "beelzebot.smoke.ui.example.invalid" - ); + assert_eq!(production.sites.machine_hostname, "joey.ui.example.invalid"); + assert_eq!(test.sites.machine_hostname, "joey.smoke.ui.example.invalid"); assert!(!production.sites.acme_staging); assert!(test.sites.acme_staging); assert_eq!(production.sites.services.len(), test.sites.services.len()); @@ -423,7 +417,7 @@ fn profiles_without_ui_and_incomplete_address_families_fail_closed() { let only_v4 = BTreeSet::from(["100.64.0.10".to_string()]); assert!(matches!( build_ui_provisioning_plan( - "beelzebot", + "joey", ProvisioningPolicy::new(Environment::Test), "example.invalid", None, From 80cd63b7e98fd274336a42910b2bd017071382c4 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 23:15:57 +0200 Subject: [PATCH 249/252] docs(donbot): inventory services and plan public ingress migration --- ublue/design/fleet-hosts.md | 5 + ublue/design/public-service-access.md | 48 +++++ ublue/plan/DONBOT-INVENTORY.md | 228 +++++++++++++++++++++ ublue/plan/DONBOT-MIGRATION.md | 283 ++++++++++++++++++++++++++ ublue/plan/JELLYFIN-CADDY-CACHE.md | 107 ++++++++++ 5 files changed, 671 insertions(+) create mode 100644 ublue/design/public-service-access.md create mode 100644 ublue/plan/DONBOT-INVENTORY.md create mode 100644 ublue/plan/DONBOT-MIGRATION.md create mode 100644 ublue/plan/JELLYFIN-CADDY-CACHE.md diff --git a/ublue/design/fleet-hosts.md b/ublue/design/fleet-hosts.md index 21ab3c82..ada2077d 100644 --- a/ublue/design/fleet-hosts.md +++ b/ublue/design/fleet-hosts.md @@ -23,3 +23,8 @@ includes select artifacts/images; generic units read `/etc/skillet/host`. Boxy's subnet-routing and private cross-host routes/probes need separate policy and private inventory. Selecting the current shared modules does not claim full live source parity. See [the migration plan](../plan/JOEY-MIGRATION.md). + +Calculon/donbot source services, exclusions and public/mixed ingress needs are +in [the inventory](../plan/DONBOT-INVENTORY.md) and +[migration plan](../plan/DONBOT-MIGRATION.md). The donbot image recipe exists; +its Skillet profile, host binary and Butane configuration remain pending. diff --git a/ublue/design/public-service-access.md b/ublue/design/public-service-access.md new file mode 100644 index 00000000..ee8cf442 --- /dev/null +++ b/ublue/design/public-service-access.md @@ -0,0 +1,48 @@ +# Public and mixed service access + +## Requirements agreed 2026-10-11 + +Calculon's replacement, donbot, needs publicly reachable HTTPS for Plex and +Jellyfin without a Tailscale client. Former OAuth-protected administrative UIs +move to tailnet-only access. Gokapi retains public sharing/upload flows with +private administration; inventory Airtrail's current public API exception and +Joplin's conditional public endpoint before selecting their policy. + +The private file-management capability should be shared by Syncthing hosts. +The magiusstaff second instance is deferred. File browsing requires no internal +password prompt; use the [shared tailnet identity policy](tailnet-ui-authentication.md) +for the two intended users and reuse it for Gokapi administration. + +## Proposed implementation, not yet accepted or implemented + +Extend the existing generic Caddy and provider lifecycle with explicit service +access policies. Keep private as the default and deny unspecified mixed routes. +Public and tailnet machine records must have independent address destinations; +UI aliases inherit their service's policy. Reuse Cloudflare DNS-01 with scoped +credentials; public endpoints use DNS-only public addresses and do not depend on +Tailscale. Domain/address values stay in private configuration. + +Use Caddy for public TLS and bridge proxying with native media authentication. +Decision, 2026-10-11: use Caddy caching for Jellyfin, via a pinned +`caddyserver/cache-handler` service image and tested filesystem storage. Start +with images, then prove protected video/range behavior; nginx slicing parity is +unverified. Cache hits must preserve authorization/revocation, and cache data +stays outside synchronized/snapshotted folders. The +[cache plan](../plan/JELLYFIN-CADDY-CACHE.md) owns implementation gates. See +[Jellyfin's proxy requirements](https://jellyfin.org/docs/general/post-install/networking/reverse-proxy/) +and [Caddy cache module status](https://caddyserver.com/docs/modules/http.handlers.cache). + +The original [File Browser](https://github.com/filebrowser/filebrowser) is +archived. [File browsing](file-browsing.md) records the shortlist, with Mikochi +as the selected shared implementation on clamps and joey; browser acceptance +is still pending. Keep any +persistent app state outside synchronized user files and serve declared folders. + +## Status + +Current code serves every declared Caddy UI with a tailnet restriction; it has +no generic public or mixed policy. Existing private behavior remains authoritative +until the versioned extension is implemented and tested. Sources and execution +gates: [inventory](../plan/DONBOT-INVENTORY.md), +[migration plan](../plan/DONBOT-MIGRATION.md), and +[private UI access](private-ui-access.md). diff --git a/ublue/plan/DONBOT-INVENTORY.md b/ublue/plan/DONBOT-INVENTORY.md new file mode 100644 index 00000000..2616c075 --- /dev/null +++ b/ublue/plan/DONBOT-INVENTORY.md @@ -0,0 +1,228 @@ +# Calculon → donbot: source inventory + +Status: source review completed 2026-10-11. This inventories declarations, not +running services. Private Chef overrides, deployed image versions, effective +nginx configuration and live storage have not been inspected. The target name +is **donbot**. Implementation is tracked in [the migration plan](DONBOT-MIGRATION.md). +No literal domains, addresses, zone IDs or credential values belong here. + +## Source and scope + +Entry points: [role](../../roles/calculon.rb), +[default recipe](../../site-cookbooks/calculon/recipes/default.rb), and +[attributes](../../site-cookbooks/calculon/attributes/default.rb). +`default.rb` includes media, magiusstaff (which includes primary Syncthing), +Joplin, Airtrail and Gokapi, plus shared infrastructure and the excluded apps. +Several services require private attributes; inclusion alone does not establish +successful deployment. Default portal/media/notes domains are unset. + +Reuse uCore/Skillet's existing OS, users, hardening, SSH/ET, Podman storage, +bridge DNS, Quadlets, Tailscale, Pi-hole, btrbk, Datadog and SMTP support. Do not +reimplement Chef repository/kernel installation, Rocky Btrfs packages, zram, +Podman engine setup, web pods, nginx/OAuth scaffolding or distrobox installation. +App data ownership, mount semantics and routing choices still require migration. + +## Applications and access + +Ports below are **current backend ports**, not a proposed public firewall list. +Paths beginning `/` in the access column are nginx portal routes. OAuth applies +to the portal, with dedicated exceptions described below. + +| Service | Source image | Current access | Target access / scope | +| --- | --- | --- | --- | +| Prowlarr | `lscr.io/linuxserver/prowlarr:latest` | OAuth portal `/prowlarr`, 9696 | Tailnet-only UI | +| Radarr | `lscr.io/linuxserver/radarr:latest` | OAuth portal `/radarr`, 7878 | Tailnet-only UI | +| Sonarr | `lscr.io/linuxserver/sonarr:latest` | OAuth portal `/sonarr`, 8989 | Tailnet-only UI | +| Lidarr | `lscr.io/linuxserver/lidarr:nightly` | OAuth portal `/lidarr`, 8686 | Tailnet-only UI; confirm release channel before restore | +| putioarr | `ghcr.io/wouterdebie/putioarr:latest` | Shared web pod, 9091; no declared nginx UI route | Internal download integration for Arr; no public UI needed | +| Jellyfin | `lscr.io/linuxserver/jellyfin:latest` | Dedicated public HTTPS vhost when media domain is set, backend 8096, no OAuth | Public HTTPS, native application authentication | +| Plex | `docker.io/plexinc/pms-docker:latest` | Direct dual-stack host ports; **no Plex nginx vhost in this cookbook** | Public HTTPS; native Plex authentication and client discovery | +| Main Syncthing | `docker.io/syncthing/syncthing:latest` | OAuth portal `/sync`; GUI 8384, peer TCP/UDP 22000 | Tailnet-only GUI; peer connectivity is separate | +| Main Filebrowser | `docker.io/filebrowser/filebrowser:latest` | OAuth portal `/files`; backend 8080 via bridge-gateway port 8385 | Shared file-browsing UI on Syncthing hosts, tailnet-only | +| Tenant Syncthing | Same Syncthing image | Separate OAuth portal `/sync`; GUI gateway port 8386, peer TCP/UDP 22200 → 22000 | Deferred: preserve source data; no second instance in this migration | +| Tenant Filebrowser | Same Filebrowser image | Separate OAuth portal `/files`; backend 8080 via gateway port 8387 | Deferred alongside tenant Syncthing | +| Joplin Server | `docker.io/joplin/server:latest` | Backend 22300: public dedicated notes vhost **if configured**, otherwise OAuth portal `/joplin` | Preserve effective mode after private inventory | +| Airtrail | `docker.io/johly/airtrail:latest` | Dedicated vhost, backend 3214: OAuth **except `/api/*`** | Private UI; inventory whether authenticated public API access is still needed | +| Gokapi | `docker.io/f0rc3/gokapi` (implicit latest) | Backend 53842: OAuth admin plus public downloads/uploads/assets | Mixed: private administration, explicit public sharing routes | +| PostgreSQL: Joplin | `docker.io/postgres:18` resource default | Shared web pod, 5959; app connects over loopback | Internal named-container connection, no host publication | +| PostgreSQL: Airtrail | Same resource default | Shared web pod, 5858; app connects over loopback | Internal named-container connection, no host publication | +| Pi-hole | `docker.io/pihole/pihole:latest` default | Included through Tailscale recipe; web-pod backend 8088 by default, no calculon-specific UI vhost | Shared selected DNS service and private UI | +| Pi-hole synchronization | `ghcr.io/lovelaze/nebula-sync:latest` | Conditional hourly oneshot, only with enabled primary/replica/password settings | Inventory whether active; declare explicitly if retained | + +Sources: [media](../../site-cookbooks/calculon/recipes/media.rb), +[Syncthing/Filebrowser](../../site-cookbooks/calculon/recipes/syncthing.rb), +[tenant](../../site-cookbooks/calculon/recipes/magiusstaff.rb), +[Joplin](../../site-cookbooks/calculon/recipes/joplin.rb), +[Airtrail](../../site-cookbooks/calculon/recipes/airtrail.rb), +[Gokapi](../../site-cookbooks/calculon/recipes/gokapi.rb), and +[PostgreSQL resource](../../site-cookbooks/calculon/resources/postgresql.rb), +[Pi-hole](../../site-cookbooks/pihole/recipes/default.rb), and +[conditional Pi-hole sync](../../site-cookbooks/pihole/recipes/nebula_sync.rb). + +The tenant recipe logs “Skipping” for absent settings but does not return. +Check whether it is actually active; do not treat that log as evidence of absence. +The static portal also contains external links; a link is not another proxied app. +`mybibliotheca` has a path attribute but no application definition in this cookbook. +A Google Photos Takeout Helper binary is installed by the Syncthing recipe; +it is a manual utility, not a web service. An rclone template exists without a +recipe reference. Inventory manual uses before deciding either utility's fate. + +## Persistent state and jobs + +All paths below are **Chef defaults**. Confirm overrides, subvolumes, sizes, +ownership, ACLs, labels and application paths before any copy. + +| State | Current default under `/var/lib/data` | Migration consequence | +| --- | --- | --- | +| Arr configuration | `prowlarr`, `radarr`, `sonarr`, `lidarr` → `/config` | Preserve databases, API keys, base URLs and download-client configuration | +| Download integration | `putioarr` → `/config`; `media/downloads` → `/downloads` | TOML contains Put.io token, client password and Arr API keys; move private leaves through shared delivery | +| Media library | `media/{movies,series}/{downloads,library}`, additional yoga/pilates library | Downloads bind-mounted into movie/series trees; do not copy bind views twice | +| Music | `sync/music`; Lidarr `/music`, media servers `/data/music` | Preserve shared ownership; cross-subvolume moves cannot be assumed atomic/hardlinked | +| Jellyfin | `jellyfin` → `/config` | Accounts, library metadata and settings; series mount currently has `/dara/tvshows` typo—inspect stored library paths before correcting | +| Plex | `plex/config` → `/config`, `plex/transcode` → `/transcode` | Preserve server identity/library state; transcode contents are temporary | +| Main synchronization | `sync` → `/var/syncthing`; `.filebrowser` holds DB/settings | Move to declared target layout explicitly; keep browser state outside synchronized user data | +| Tenant synchronization | `magiusstaff/sync`, its own `.filebrowser`, default UID/GID 2002 | Keep separate from primary media/data account (default 2001) and Syncthing identity | +| Gokapi | `gokapi/files` → `/app/data`, `gokapi/config` → `/app/config` | Files plus SQLite metadata/config, salts and any encryption material must be restored together | +| PostgreSQL | `dbs/{joplin,airtrail}` → `/var/lib/postgresql` | Confirm running major/layout; source image default 18 is not runtime evidence | +| Airtrail uploads | `sync/airtrail` → `/app/uploads` | Pair uploads with a consistent database backup | +| Proxy state | `www`: nginx configuration, certificates and caches | Import only required runtime values; regenerate Caddy configuration/certificates, discard cache contents | + +Existing jobs: + +- btrbk snapshots **only `sync` and `magiusstaff/sync`**, hourly; minimum 6h, + retention `24h 31d 6m`. No whole-data snapshot policy. Existing shared btrbk + supports caller-selected paths; preserve leaf-only selection. +- Daily PostgreSQL `pg_dumpall` compressed with zstd into + `sync/backups/`; prune archives older than seven days. Restore helpers + exist. Atomic publication and coordination with uploaded files need review. +- Daily Airtrail uploads mirror into `sync/backups/airtrail/files`, with deletion + and up to four hours jitter; its timer is independent of the database dump. +- Daily Arr cleanup deletes files/directories older than seven days from shared + downloads. Replace only after defining how active/incomplete downloads are + excluded; age alone does not establish completion. +- Registry auto-update comes from the shared Chef container resource. Record + effective images/digests and choose compatible restore versions before enabling + updates; do not upgrade application and database majors during the initial copy. + +## nginx features to account for + +### Jellyfin + +The media vhost has **two server-side caches**, not just image headers: + +- Images: `/Items/(.*)/Images`, 15 GB maximum, 30-day inactivity, revalidation + and request locking. +- Video: regex under `/Videos/…`, 2 MB slices, 35,000 MB maximum, 90-day + inactivity, 30-day validity for 200/206/301/302, stale responses on failures. + A custom key includes selected transcoding parameters and byte slice. +- Browser cache headers vary by content type. There are `/` and `/web/` + redirects, `/socket` WebSockets, a method filter, CSP/security headers and + extension rejection rules. + +The video cache ignores upstream `Expires`, `Cache-Control`, `Set-Cookie` and +`X-Accel-Expires`; its key contains no user/authentication identity. This is a +source finding, **not a demonstrated live exploit**. Any retained shared cache +must prove authorization and revocation still apply on cache hits. Cache-Control +headers alone do not reproduce these disk caches. + +Caddy supports reverse proxying and WebSockets; Jellyfin recommends it for +HTTPS and requires correctly configured known proxies. See +[Jellyfin proxy guidance](https://jellyfin.org/docs/general/post-install/networking/reverse-proxy/) +and [Caddy reverse proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy). +Response caching needs an extra component: +[Caddy's cache module is nonstandard](https://caddyserver.com/docs/modules/http.handlers.cache), +with a [cache-handler implementation](https://github.com/caddyserver/cache-handler). +Neither source establishes parity with this nginx video slicing configuration. +Update, 2026-10-11: Caddy caching is selected for the target. Build and validate +`caddyserver/cache-handler` with a filesystem storage provider; see the +[Jellyfin cache plan](JELLYFIN-CADDY-CACHE.md). nginx video-slice parity remains +unproven and must be tested, not presumed from generic HTTP caching support. + +### Plex + +Chef publishes TCP 32400/8324/32469/1900 and UDP 32410–32414 on wildcard +IPv4/IPv6. Its firewall resource is named `syncthing`, also used by the actual +Syncthing recipe: inspect effective rules before relying on either declaration. +Discovery ports are not a proposed public streaming requirement. +Neither media container has a GPU mapping in the source. + +Plex supports custom server access URLs for reverse proxies. A proposed Caddy +endpoint should be advertised explicitly as `https://:443`: +omitting the port can make Plex use its configured Remote Access port instead. +Require client authentication; never add the proxy bridge to Plex's +“allowed without auth” list. Prove secure **direct** playback on actual clients, +not fallback through Plex Relay. See [Plex network settings](https://support.plex.tv/articles/200430283-network/). + +### Gokapi and Airtrail + +Gokapi skips OAuth for `/api/uploadrequest/chunk/…`, assets/css/js/static, +favicon/manifest, `/publicUpload…`, `/downloadFile…`, and broad `^/d.*` routes. +It permits 200 MB requests with 300-second proxy timeouts. The app trusts +`X-Forwarded-Email` (`Authentication.Method=2`); removing OAuth requires an +explicit replacement for that identity contract. Its config template uses +version 22 and salt fields that are not supplied by the recipe's template +variables. Restore effective state instead of regenerating it from that template. + +Derive an exact path/method list from the selected Gokapi version; retain public +sharing and guest-upload flows while denying administration and administrative +APIs externally. Use the planned verified tailnet identity gate plus Gokapi header +authentication for private administration, without a second password prompt; +strip client-supplied identity headers. Confirm whether the live zone is actually +Cloudflare-proxied before retaining `GOKAPI_USE_CLOUDFLARE=true`. +[Official Gokapi setup](https://gokapi.readthedocs.io/en/latest/setup.html) +describes native and header authentication; it does not justify copying these +legacy broad exemptions into Caddy unchanged. + +Airtrail exempts every `/api/*` request from OAuth. Its `ORIGIN`, +`SCRIPT_NAME=/airtrail`, and loopback-only app/DB settings need review when +moving to separate containers and a dedicated hostname. Establish which API +clients exist and whether application authentication protects those requests. + +## Filebrowser selection + +The legacy [File Browser project](https://github.com/filebrowser/filebrowser) +is archived and states it will receive no further security fixes. Keep the +requested private file-management capability, but review the application choice +before deploying that image fleet-wide. [FileBrowser Quantum](https://github.com/gtsteffaniak/filebrowser) +is a candidate to evaluate, not a selected replacement or a proven drop-in DB +migration. Preserve old browser DBs/configs until account/permission migration +is settled. [Mikochi and other candidates](../design/file-browsing.md) are now +being evaluated for the primary instance. The target uses Caddy tailnet identity +and no internal password prompt; no application has been selected yet. + +## Infrastructure retained through shared support + +Pi-hole is included by `tailscale.rb`; Tailscale is host-networked and persists +under `tailscale`. Chef advertises container subnets and an exit node, accepts +routes, disables subnet SNAT and enables masquerading. Current shared Skillet +supports selected exit-node policy but **does not yet implement all those routing +preferences**. Reconcile needed routing through the shared routing work in +[joey's plan](JOEY-MIGRATION.md); keep values private. + +Datadog checks include SSH/ET, Btrfs, process/network monitoring, nginx status +and both Filebrowser/Syncthing instances. Reuse the Agent; service modules own +new checks and test/lab alert exclusions. Preserve intended production location +through private configuration instead of copying old provider labels blindly. +The included Pi-hole cookbook can also run hourly Nebula Sync: DNS/DHCP and +other selected settings plus gravity data, with primary/replica credentials. +Its presence depends on private configuration; this behavior is not implemented +by the current Skillet Pi-hole module. Keep one declared synchronization owner +and disable production replication in smoke tests. + +No calculon-specific DDNS updater was found; opt in only if public addressing +actually requires it. nginx's `cloudflare true` configures trusted-proxy support; +it does not establish that DNS records are proxied through Cloudflare. + +## Deferred tenant + +Decision, 2026-10-11: do not port magiusstaff now. Keep both tenant rows above +as source evidence; its accounts, data, peer identity and snapshots remain +outside donbot implementation. Do not extend Skillet for multiple Syncthing +instances to satisfy this migration, or delete the source tenant state. + +## Explicit exclusions + +Do not migrate AdventureLog (frontend, backend and its PostGIS database/uploads), +Wanderer (web, PocketBase database and Meilisearch), or Openclaw and their ingress, +credentials and scheduled jobs. This does not authorize deleting source data or +removing shared dependencies needed by retained services. diff --git a/ublue/plan/DONBOT-MIGRATION.md b/ublue/plan/DONBOT-MIGRATION.md new file mode 100644 index 00000000..cd87ae76 --- /dev/null +++ b/ublue/plan/DONBOT-MIGRATION.md @@ -0,0 +1,283 @@ +# Calculon → donbot migration + +Status: **draft, inventory first**, 2026-10-11. Source inventory is complete; +private/live inventory and donbot implementation remain pending. The shared +file browser and identity gate are implemented for clamps/joey; their live +acceptance is tracked separately below. Read +[DONBOT-INVENTORY.md](DONBOT-INVENTORY.md) before changing code. Follow the +[refactoring roadmap](SKILLET-REFACTOR.md) and keep its parked acceptance visible; +this plan does not close those gates or claim fresh donbot acceptance. + +## Scope and prerequisites + +Retain Arr/putioarr, Plex, Jellyfin, Gokapi, Syncthing and a private file-browsing +UI. Joplin and Airtrail are retain candidates; establish their effective +mode/use in slice 0. **Defer the magiusstaff tenant**, including its second +Syncthing/Filebrowser instance and snapshot selection. Do not add multi-instance +support for this migration. Preserve its source data. Exclude AdventureLog, +Wanderer and Openclaw. + +Reuse the existing fleet baseline. Do not port Chef host/Podman setup. Only add +OS image packages where an actual service requires them; applications stay in +rootful containers. `ucore-images/recipes/ucore-donbot.yml` exists as a thin image; +a donbot profile/binary/Butane configuration does not exist yet. Do not clone +clamps-specific service choices such as UniFi or Home Assistant. + +Accepted access requirements: former OAuth-protected UIs become tailnet-only; +Plex/Jellyfin are reachable publicly without a tailnet client; mixed services +keep explicitly required public flows. HTTPS is mandatory for all web endpoints. +Architecture constraints and proposals are in +[public service access](../design/public-service-access.md). + +## Implementation slices and exit criteria + +Implement one slice at a time, with focused validated commits. Do not silently +turn the source inventory into an assumption about running state. + +### 0. Complete the effective inventory + +Read effective private Chef settings and live service/container/nginx definitions +without recording credentials. Record privately: image versions/digests, +application URLs and accounts, public IPv4/IPv6 and NAT/firewalls, +media sizes/mounts/subvolumes, hardware acceleration, backup/restore state, and +actual Jellyfin cache hits/load. Check manual putioarr binary overrides and the +Plex/Syncthing firewall resource collision. + +**Exit:** each retained service has a version, state/mount map and access policy; +resolve Joplin public versus private mode, Airtrail public API +clients, conditional Pi-hole synchronization, source subvolumes inside +synchronized data, and public reachability. +Keep private evidence outside Git. No source service is stopped or altered. + +### 1. Add donbot using existing baseline + +Add the canonical host profile, thin host binary, Butane selector/install inputs +and matching image policy. Select existing Syncthing, host-network Tailscale, +btrbk, Datadog and SMTP. Include Pi-hole for the intended third resolver and +mask resolved in the host image if selected. Choose a data account mapping that +preserves the source's shared media permissions without assuming workstation IDs. +Keep `/dev/vda` VM inputs and physical disk/root-profile choices distinct. + +**Exit:** profile drives credentials/UIs/monitoring/readiness; strict Butane and +local CI pass; a fresh named donbot VM reaches signed-image base readiness, +repeat apply and reboot preserve the shared data mount. No source peer identity, +production routes or public DNS writer is imported into a smoke VM. +Shared subnet routing/private DNS gaps stay tracked in joey's plan. If live +Nebula Sync is needed, add it as an explicit shared capability with private +primary/replica delivery and a single production owner; smoke must not replicate +production settings or leases. + +### 2. Extend generic ingress and verified tailnet authentication + +The current Caddy renderer makes **every** declared UI tailnet-only and the +Cloudflare lifecycle targets tailnet machine addresses. Extend the shared typed +model with explicit public/private policy and mixed path/method rules; default +to private. Host callers select services and policy; private values come from +templates/vault. Keep one renderer/delivery/provider ownership path, with a +versioned migration for retained private payloads. + +Add a reusable caller-selected identity gate for file browsing and Gokapi admin, +following [tailnet UI authentication](../design/tailnet-ui-authentication.md). +Resolve the actual connection peer through the existing host-network daemon +LocalAPI; validate user ownership and the explicit allowlist before upstream +access. Preserve Caddy-observed source addresses through rootful port forwarding. +Do not trust browser headers or grant every tailnet user administrative access. +Keep the allowlist/mapping in privately delivered configuration. The shared +Rust forward-auth gate now implements identity authorization for Mikochi. It +uses the existing daemon, without enrolling Caddy as another node. Expose only +the authorization endpoint to Caddy through a bounded trust boundary; +a read-only mount is not an API authorization policy. Validate the Headscale +LocalAPI identity path separately; current workstation enrollment is hosted +Tailscale-specific and this work does not claim Headscale lifecycle support. + +Keep public machine address records separate from tailnet machine records; +CNAMEs/aliases must target the declared access destination. Reuse DNS-01 and +zone-scoped issuance/cleanup. Address updates and certificate renewal have +different owners: select DDNS only when needed and reject competing writers. +Public sites use DNS-only records by default. Advertise AAAA only when routed +IPv6 has been verified. Public 443 must work with Tailscale disconnected; port +80 may redirect to HTTPS, but never serves an application in plaintext. + +**Exit:** CI verifies actual adapted Caddy routing with dummy names: public sites +reachable; private sites denied externally; mixed routes default-deny; aliases +have identical policy; unknown Host/SNI and spoofed identity/forwarded headers +cannot select a private upstream. Identity tests distinguish the two allowed +users, a denied user, a tagged device, unavailable WhoIs and altered headers; +Gokapi receives only verified identity and file browsing has no second login. +Use LocalAPI fixtures for CI, then an actual user-owned client for acceptance +(the tagged server VM cannot stand in for a human client). IPv4/IPv6 rules, +payload migration, DNS +ownership/retry/cleanup and credential redaction have tests. Named-VM HTTPS +acceptance covers both allowed and denied routes; separately prove real internet +reachability because passt/localhost testing cannot establish it. + +### 3. Add shared private file browsing + +Mikochi is selected for the current shared-root scope; Quantum and +Copyparty remain alternatives. See [file browsing](../design/file-browsing.md). +The shared module is implemented alongside Syncthing for clamps and joey. +Reuse canonical caller composition when donbot is added. Declare served folders, +read/write policy and data ownership. Keep app DB/config outside synchronized +user files, on persistent service storage; do not expose the entire data mount. +Use container DNS and a private Caddy hostname with no host GUI port. +**No internal username/password login:** reuse the verified tailnet-user policy +for the two intended users. For Mikochi, configure `NO_AUTH=true` only after +Caddy identity enforcement and backend isolation pass. A private IP-range +matcher alone does not satisfy this requirement. The shared authentication work +in slice 2 is a dependency before activating this UI. Confine the unauthenticated +backend to a dedicated private network with Caddy as its only proxy client; +prove isolation from host/LAN/tailnet and other application networks rather than +assuming that omitted host ports provide it. No file-browser password entry is +required by this policy; shared identity configuration remains audited. + +**Exit:** all declared Syncthing hosts select the same module; credentials and +monitoring follow that declaration. Tests cover mounting/ownership, absent +state, private ingress, duplicate names and repeat apply. A named VM can upload, +read and delete a sample file with expected permissions, persists settings after +reboot and denies an outside-tailnet request. Migration of existing browser +accounts/settings is explicit and the original DB is preserved. Both allowed +users enter without an app password; other users, tagged devices, anonymous +clients and direct backend connections are denied. A daemon outage fails closed. + +### 4. Port Arr and download integration + +Use a cohesive media module with typed per-app configuration and shared helpers, +not a new broad system trait or a parallel host service registry. Declare +Prowlarr/Radarr/Sonarr/Lidarr and putioarr as separate bridge containers. Replace +pod loopback references with container DNS; prefer dedicated private hostnames +and deliberately migrate old application URL bases. + +Resolve Put.io credentials, putioarr password and Arr API keys through existing +templates/delivery. Avoid literal secret environments; use supported secret +files or existing secret-aware runtime configuration. Preserve restored API keys +unless an intentional rotation updates every consumer. Review latest/nightly +channels and database compatibility before enabling automatic updates. + +Declare media/download/library mounts and identity mappings once. Prefer common +container paths for downloads/imports, following the +[Servarr mount guidance](https://wiki.servarr.com/en/sonarr/installation/docker); +verify hardlinks/atomic rename on the +actual filesystem instead of assuming every Btrfs subvolume shares that behavior. +Preserve separate synchronized music policy explicitly. Replace the age-only +cleanup job with a confirmed completed-download policy or leave it disabled. + +**Exit:** restored synthetic app configs can connect over bridge DNS, a harmless +sample transfer/import reaches the right library with correct ownership, private +UI routes work and no backend UI port is published. Repeat apply/reboot preserve +app state; secrets are absent from Quadlets/recordings. Any path remapping and +copy-versus-hardlink behavior is documented. + +### 5. Port Jellyfin, then Plex, with public HTTPS + +Use separate public hostnames, Caddy TLS and named bridge upstreams. Keep native +application authentication; backend HTTP remains confined to the bridge. +Migrate Jellyfin's known-proxy/client-IP policy, library paths and WebSockets. +Inspect the legacy `/dara/tvshows` path before fixing it. Declare GPU access only +for hardware actually present; preserve a CPU-only configuration. + +For Plex preserve server identity and library state. Publish the explicit custom +access URL `https://:443`, and test intended Plex clients through +that route. Do not expose discovery ports publicly or grant unauthenticated +access to the proxy subnet. A native Plex TLS port is an alternative only if +client acceptance demonstrates a need and its security policy is reviewed. + +**Exit:** a real client without Tailscale can authenticate, play, seek/range, +resume and load subtitles through trusted HTTPS on each service. Test direct +play and an available transcode path; Plex reports a secure direct connection, +not Relay fallback. Unauthenticated restricted APIs/media stay denied. +Unknown hosts and direct backend ports are inaccessible from the internet. + +**Caddy cache milestone selected:** implement the pinned Souin-based +`caddyserver/cache-handler` in a controlled Caddy service image. Follow the +[Jellyfin cache plan](JELLYFIN-CADDY-CACHE.md): image caching first, then prove +video/range support against the former nginx behavior. Keep disk state bounded +and outside Syncthing/snapshots. No nginx cache fallback or automatic waiver is +selected. An unsupported video feature remains an explicit migration gap; do +not claim slicing parity or enable unsafe caching to close it. + +### 6. Port Gokapi's mixed exposure + +Pin a compatible version and restore config plus file/metadata state together. +Replace OAuth header identity with the shared verified tailnet-user gate. +Use Gokapi header authentication with the complete verified identity mapped to +precreated application accounts for the two allowed users; no internal password +prompt. Decide their application roles explicitly and disable automatic creation +of arbitrary users. Public sharing routes get no administrative identity. Derive a version-specific public +path/method allowlist for downloads, guest uploads/chunks and required assets; +keep admin, account, API-key and management routes private. Do not translate the +broad legacy `^/d.*` exemption directly. Strip untrusted identity headers. + +Preserve the public sharing URL and existing links where required. Decide one +mixed hostname versus separate administration hostname based on supported base +URLs/cookies/origin behavior. Prefer a tailnet-addressed admin hostname and +public-addressed sharing hostname; verify that generated links and frontend API +calls use the correct entry point. Being connected to Tailscale does not identify +a browser request sent over the public IP. Carry file/chunk size and timeout requirements; +`GOKAPI_USE_CLOUDFLARE` follows actual proxy topology, not DNS provider choice. + +**Exit:** without Tailscale, an existing/restored share downloads and a guest +upload completes; expiration/limits remain effective. Admin routes and APIs deny +external clients even with forged headers, encoded paths or alternate methods. +Tailnet admin authentication works. Backup/restore retains metadata/files and +repeat apply preserves application-managed configuration. Do not cache share +responses unless expiry and counters are proven unaffected. + +### 7. Port PostgreSQL-backed apps + +Add a reusable PostgreSQL composition/backup helper for Joplin and Airtrail; +use named-container connectivity and secret delivery, not pod-local loopback. +Keep each app's state/version/credential declaration authoritative. Restore +logical backups into a compatible database major before planning upgrades. +Publish completed backup archives atomically into declared synchronized backup +folders; coordinate Airtrail files and DB state. Reuse shared SMTP where needed +without creating a remotely accessible unauthenticated relay. + +Implement Joplin's chosen public/private mode and Airtrail's agreed API policy. +The second synchronization/browser tenant stays deferred; preserve its source +state without importing it or selecting its snapshots. No multi-instance API +extension is required by this slice. + +**Exit:** Joplin client sync and Airtrail attachments/API work in their selected +modes, backup restore succeeds on disposable state, and repeat apply/reboot +preserve app/database state. No live production peers receive restored smoke +changes. + +### 8. Production rehearsal and cutover + +Record per-service copy/restore/rollback steps and verified backups, capacity, +ownership/ACLs and final path mappings. Snapshot only caller-selected leaves; +never snapshot all `/var/lib/data`. Raw database clusters and temporary caches +must not be synchronized. Rehearse using isolated state before final copy. + +**Exit:** all retained services have acceptance evidence; public HTTPS succeeds +from an external client without Tailscale; private/mixed denials are tested; +backup restore, reboot and repeat apply pass; DNS/DDNS has one production owner. +Stop source writers for the final consistent copy and preserve calculon for +rollback. Excluded app data is left untouched. Do not describe image publication +or VM tests as physical deployment or production acceptance. + +## Validation and handoff + +Each code slice runs the applicable checks from `AGENTS.md`, with routine CI +requiring no vault or external API. Real tests use a named disposable VM through +the Rust lifecycle owner; cache unlock prompts never block independent work. +Do not install workstation tools. Record honest evidence in +[ACCEPTANCE.md](../butane/ACCEPTANCE.md), update designs and this plan, and maintain +the shared secret-requirement catalog/root checklist when exact entries are added. +The shared identity allowlist is implemented at `skillet/tailscale/ui-users`. +Gokapi application-account mapping and other migration-specific inputs remain +to be designed. + +**Next action:** slice 0, a bounded read-only effective inventory. First code +slice is donbot baseline/profile, reusing the shared tailnet authentication +and file UI after their acceptance gates. Public ingress must be validated before importing public +service state. + +## Shared file browsing implementation (2026-10-11) + +Mikochi and the generic LocalAPI identity gate are implemented for existing +Syncthing callers (clamps and joey), including encrypted allowlist delivery, +canonical DNS/Caddy declarations, ownership, private backend and CI regressions. +Donbot still needs its profile; the shared composition will include file browsing. +Gokapi identity/account mapping and Headscale acceptance remain pending. See +[Butane acceptance](../butane/ACCEPTANCE.md) for live checks and limitations. diff --git a/ublue/plan/JELLYFIN-CADDY-CACHE.md b/ublue/plan/JELLYFIN-CADDY-CACHE.md new file mode 100644 index 00000000..bdebebec --- /dev/null +++ b/ublue/plan/JELLYFIN-CADDY-CACHE.md @@ -0,0 +1,107 @@ +# Jellyfin caching through Caddy + +Status: implementation planned, 2026-10-11. Caddy caching is selected for donbot; +no nginx fallback is selected. Prerequisites are the shared ingress work in +[donbot migration](DONBOT-MIGRATION.md) and the existing +[refactoring gates](SKILLET-REFACTOR.md). The source image and 2 MB video-slice +caches are recorded in [the inventory](DONBOT-INVENTORY.md). + +## Target and boundaries + +Use pinned `github.com/caddyserver/cache-handler` (Souin-based). Build a controlled +Caddy service image in CI with Cloudflare DNS support and the selected storage +module; do not compile plugins on guests or install tools on the workstation. +Verify compatible pinned versions and required modules in the built image. +Application-image tooling remains separate from uCore host packages. Include the +cache/storage modules through the same build. Tailnet UI authorization now uses +the shared Rust forward-auth endpoint and does not need a Caddy auth plugin. + +Start with a filesystem provider, evaluating +[Simplefs](https://docs.souin.io/docs/storages/simplefs/) for the compatible pinned +cache release. Provider sizing/eviction semantics must be demonstrated; an entry +count, per-directory option or Go heap limit is not a proven total disk cap. +Use `/var/lib/data/caddy/cache/jellyfin` as disposable cache state, separately +mounted from certificates. Keep it out of Syncthing, btrbk and backups. Bound +both resident memory and total disk bytes; establish limits from host capacity +and source usage. No external Redis/database cluster is required by this plan. + +Expose caching as an optional typed service policy; private UIs, Gokapi, Plex and +unrelated APIs are not cached simply because Jellyfin selects it. Render ordered +routing explicitly and validate with the actual built Caddy binary. Configurable +policy includes allowed routes/methods, response sizes, TTL, storage caps and +keys. Real hostnames and any private values stay outside the repository. + +## Implementation order and exits + +### 1. Build and prove the cache component + +Add the reproducible service-image build and tests with an instrumented synthetic +upstream. Check module loading, adapted configuration and storage ownership. +Use only safe read methods and opt-in response statuses. Cache management/purge +APIs stay on a restricted local interface, never the public media site. Disable +credential-bearing debug logs and exposed cache keys. + +**Exit:** a second permitted request demonstrates a real cache hit, expiry and +purge work, the chosen provider evicts/bounds disk and memory, and cache loss or +corruption can be cleared without harming Caddy certificates or service state. +CI performs these checks without KeePassXC, Cloudflare or live library data. + +### 2. Enable approved image/static routes + +Inventory the selected Jellyfin version's exact routes and auth/cache behavior. +Start with approved images and static assets; exclude login/session, user/profile, +admin APIs and responses with `Set-Cookie` or no-store/private policy unless a +specific reviewed, isolated policy exists. Never globally bypass upstream cache +headers. A Cache-Control header alone does not establish a server cache. + +Retain scheme, host, full path/query and representation inputs in keys. For +protected requests separate authentication inputs and hash keys; query tokens, +Authorization and Jellyfin auth headers must not appear in logs or Cache-Status. +A token-specific key alone does not enforce revocation or changed library rights: +protected hits require upstream authorization/revalidation before returning bytes, +with no stale-on-error bypass. Prove that the consumer/module can enforce this; +keep unsupported protected routes uncached and report the gap. + +**Exit:** image cache hits and invalidation are proven against real Jellyfin; +changed image size/format/query parameters do not collide. Tests cover two users, +missing/changed/revoked tokens, changed library access, errors and cookies. +Protected data is never served merely because it was previously cached. Benchmark +image loads and observe upstream work/latency without recording private requests. + +### 3. Prove video and range caching before enabling it + +Evaluate the pinned module against real media routes and synthetic byte fixtures: +200/206/416, matching and different ranges, repeated seeks, If-Range/ETag changes, +HEAD versus GET, transcoding/subtitle/query variants and concurrent readers. +Preserve content length/range/encoding and request limits. Exclude live streams, +WebSockets and mutation routes. Bound the maximum cached response size and avoid +buffering entire large videos into memory. + +The former nginx 2 MB slice scheme is **not assumed to exist** in cache-handler. +Determine whether bounded segment/range caching provides equivalent useful +behavior. If it does not, record the exact missing feature and leave that route +uncached pending a Caddy-compatible solution; do not silently declare video cache +parity or substitute nginx. Image caching can be accepted as an intermediate +slice while video acceptance remains pending. Authorize protected cache hits as +in slice 2, and do not serve stale protected video after upstream/auth failure. + +**Exit:** byte-for-byte range/seek and concurrent playback tests pass, cache +contents/keys cannot cross representations or authorization boundaries, and real +clients can play/resume/transcode without excessive memory/disk growth. Record +any difference from the original slice cache before production cutover. + +### 4. Lifecycle and monitoring acceptance + +Module-owned metrics cover cache hits/misses, upstream requests, failures and +storage pressure without tokens or private URLs. Repeat apply preserves declared +policy and certificates; reboot starts correctly with retained or absent cache. +Changing/removing a cache policy must not delete application data or enable +caching elsewhere. Pin/update the service image through the shared container +workflow and record named-VM evidence in +[ACCEPTANCE.md](../butane/ACCEPTANCE.md). + +Sources: [cache-handler](https://github.com/caddyserver/cache-handler), +[storage/build integration](https://docs.souin.io/docs/middlewares/caddy/), and +[Jellyfin proxy requirements](https://jellyfin.org/docs/general/post-install/networking/reverse-proxy/). +This plan specifies acceptance targets; no plugin build, cache benchmark or video +range acceptance has run yet. From 8ebd3e336026c0dbd0bba740a8dd68abef0399f2 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Sun, 11 Oct 2026 23:16:14 +0200 Subject: [PATCH 250/252] feat(skillet): add tailnet-authorized Mikochi file browsing --- .github/workflows/ci.yml | 12 + SECRETS.md | 12 + ublue/AGENTS.md | 11 + ublue/butane/ACCEPTANCE.md | 50 ++++ ublue/design/file-browsing.md | 45 +++ ublue/design/private-ui-access.md | 26 +- ublue/design/tailnet-ui-authentication.md | 69 +++++ ublue/skillet/Cargo.lock | 49 ++++ ublue/skillet/Cargo.toml | 2 + ublue/skillet/README.md | 45 +++ ublue/skillet/crates/caddy/Cargo.toml | 4 + ublue/skillet/crates/caddy/src/auth.rs | 264 ++++++++++++++++++ ublue/skillet/crates/caddy/src/auth/tests.rs | 208 ++++++++++++++ ublue/skillet/crates/caddy/src/lib.rs | 142 ++++++++-- ublue/skillet/crates/caddy/src/tests.rs | 130 +++++++++ ublue/skillet/crates/cli-common/Cargo.toml | 1 + ublue/skillet/crates/cli-common/src/lib.rs | 30 ++ .../cli/src/application_acceptance_tests.rs | 64 +++++ ublue/skillet/crates/cli/src/main.rs | 97 +++++++ .../skillet/crates/cli/src/secret_delivery.rs | 15 + ublue/skillet/crates/host-profiles/Cargo.toml | 1 + .../crates/host-profiles/src/hosts_tests.rs | 46 ++- ublue/skillet/crates/host-profiles/src/lib.rs | 67 ++++- .../crates/host-profiles/src/profile.rs | 135 +++++++-- ublue/skillet/crates/mikochi/Cargo.toml | 19 ++ ublue/skillet/crates/mikochi/src/lib.rs | 121 ++++++++ ublue/skillet/crates/mikochi/src/tests.rs | 223 +++++++++++++++ ublue/skillet/crates/syncthing/src/lib.rs | 51 +++- ublue/skillet/crates/syncthing/src/tests.rs | 31 ++ .../workstation/src/cloudflare/tests.rs | 2 + .../workstation/src/secret-requirements.json | 2 + .../skillet/crates/workstation/src/secrets.rs | 4 + .../crates/workstation/src/secrets/tests.rs | 6 +- .../crates/workstation/src/ui_provisioning.rs | 38 ++- .../workstation/src/ui_provisioning/tests.rs | 12 + 35 files changed, 1941 insertions(+), 93 deletions(-) create mode 100644 ublue/design/file-browsing.md create mode 100644 ublue/design/tailnet-ui-authentication.md create mode 100644 ublue/skillet/crates/caddy/src/auth.rs create mode 100644 ublue/skillet/crates/caddy/src/auth/tests.rs create mode 100644 ublue/skillet/crates/mikochi/Cargo.toml create mode 100644 ublue/skillet/crates/mikochi/src/lib.rs create mode 100644 ublue/skillet/crates/mikochi/src/tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a29b228b..7891fdf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,18 @@ jobs: cd ublue/skillet cargo run --bin skillet -- test run joey --phase base --image fedora:latest + - name: Validate private UI Caddy configuration + if: steps.filter.outputs.rust == 'true' + run: | + cd ublue/skillet + cargo test -p skillet_caddy generated_identity_caddyfile_validates_with_the_real_consumer -- --ignored + + - name: Test Mikochi file operations without external credentials + if: steps.filter.outputs.rust == 'true' + run: | + cd ublue/skillet + cargo test -p skillet_mikochi real_mikochi_has_no_second_login_and_supports_owned_file_operations -- --ignored + - name: Run installation and bootstrap regressions if: steps.filter.outputs.rust == 'true' run: | diff --git a/SECRETS.md b/SECRETS.md index 6204877b..ec537e29 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -53,6 +53,10 @@ Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skil | `skillet/environments//hosts//cloudflare/ddns-dns-name` | Required | Choose the relative DNS record name, not a full domain. | | `skillet/environments/prod/hosts//cloudflare/ddns-token` | Generated | Skillet creates the persistent scoped token; disposable tokens are lifecycle-owned. | +## Mikochi + +No application password; uses the shared Tailnet UI allowlist. [Setup](ublue/skillet/README.md#private-file-browsing). + ## Pi-hole [Delivery](ublue/skillet/README.md#secret-setup-and-delivery). @@ -82,6 +86,14 @@ Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet No vault entries currently required; Skillet manages the private UI configuration. +## Tailnet UI + +Identity authorization for caller-declared private UIs. Uses the existing host-network daemon; no second login. [Setup](ublue/skillet/README.md#private-file-browsing). + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/tailscale/ui-users` | Required | Password = complete Tailscale LoginName values, separated by commas or newlines; Username is unused. Obtain the identities from tailscale status --json on an authenticated client. Both environments share this explicit allowlist. | + ## Tailscale Create an OAuth client in Tailscale Trust credentials with Auth Keys Write and Devices Core Read/Write, restricted to the provisioner tag. [Tag and scope guide](ublue/skillet/README.md#tailscale-setup-for-skillet). Skillet mints enrollment keys; do not create per-host auth-key entries. diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 933f8fe3..43d4f367 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -58,6 +58,17 @@ ## Shared infrastructure boundaries +- Disabling application authentication requires explicit proxy authorization + and a private backend boundary. Preserve the actual connection peer, replace + untrusted identity headers, and deny unknown/tagged sources or lookup failures. + Sharing a network address range is not user authentication. Test policy + revocation, daemon outages, browser origin checks, and direct backend isolation. + Verify effective forwarding isolation in both IP families; network flags can + change whether the runtime generates firewall rules at all. +- Identity/private policy follows the same canonical UI declaration and encrypted + delivery path as the service. Give proxy processes only the API surface they + consume; a read-only Unix socket bind does not limit API methods. + - Authentication finalization must follow explicit provisioning completion, not base readiness. One-time account mutations need durable recovery state and must preserve later operator password changes; routine apply must not reopen diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index 44190ec5..dd06c41f 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,53 @@ +## Mikochi and shared tailnet identity — 2026-10-11 + +- Shared Syncthing composition selects Mikochi on clamps and joey. It serves + only the declared user-file directory, using Syncthing's process/host mapping, + without a native login or published port. Caddy receives the authorization + socket; the gate alone receives daemon access. Encrypted version 3 UI payloads + carry the shared allowlist from `skillet/tailscale/ui-users` (Password, commas + or newlines). The unlocked vault audit passed: seven required, none missing, + invalid or unused. No private identifiers or credential values are recorded here. +- Local musl-target formatting, strict all-target Clippy and all 385 workspace + tests passed. The Fedora joey base integration applied twice without service + activation on repeat apply. Bootstrap and hardware regressions passed. + New CI checks validate generated identity Caddy configuration using the real + Caddy image and exercise pinned Mikochi 1.12.0 without a vault/provider API: + no app authentication, 3-MiB upload/download, spaced filenames, mkdir, rename, + delete, preserved host ownership and denial of an unmounted host symlink target. +- The first disposable `clamps / files` probe rejected `Internal=true` as unsafe: + Netavark 1.17.2 skips firewall generation for internal networks, allowing direct + backend access. Mikochi was stopped and that VM disposed through the owner. + The final profile uses a dedicated **non-internal**, strictly isolated bridge; + Caddy forwarding is disabled. Runtime smoke now requires positive IPv4/IPv6 + readiness and negative sibling connections, rejecting broken probe commands. +- Fresh `clamps / files`, native `qemu:///session`, SSH port 2203, captured the + corrected binaries and passed signed image/bootstrap/base/user readiness. + A temporary Rust driver held the per-run lock and checked domain ownership + around every guest command: positive readiness passed over IPv4 and IPv6, + while both sibling bridge connections timed out. There was one Mikochi backend + network and no host publication. After the synthetic reboot, independent full + apply and Caddy apply preserved Mikochi's container ID and start timestamp. Real LocalAPI lookup + matched this workstation's untagged user to the allowlist; the authorization + endpoint returned 204. This does not establish two-person browser acceptance. +- A preliminary VM obtained four staging ACME certificates and returned its + explicit non-tailnet 403 after a diagnostic public-resolver change. Public + ACME resolvers are now in shared rendering, separately from upstream Podman + DNS. On the fresh VM, provisioning still failed its HTTPS denial/readiness + check: Let's Encrypt reported **secondary-validation DNS networking errors**. + Certificate issuance is retrying; fresh live ACME acceptance remains pending. +- End-to-end user HTTPS/file operations remain pending: this workstation's + connection to the tailnet VM returned `Host is unreachable`. The operator was + asked to verify human-user TCP 443 grants to the smoke tag. Do not equate + direct authorization-socket success or direct-container CI file operations + with successful HTTPS file operations. Irene, unknown-user live requests, + live revocation/outage, broader symlink behavior and Headscale remain unverified. + Gokapi mapping and donbot implementation remain planned. +- Synthetic smoke passed first apply, repeat apply and reboot checks. The full + `--with-applications` run failed because the fresh UniFi controller did not + listen on UDP 10001 within 120 seconds; it stopped before the Mikochi roster + probe. The independent Mikochi checks above passed afterward. Full-profile + application acceptance remains failed; no UniFi requirement was relaxed. + ## Fleet rename — 2026-10-11 Current profiles/artifacts use `joey` (formerly beelzebot) and `donbot` diff --git a/ublue/design/file-browsing.md b/ublue/design/file-browsing.md new file mode 100644 index 00000000..c1370075 --- /dev/null +++ b/ublue/design/file-browsing.md @@ -0,0 +1,45 @@ +# Private file browsing + +Decision, 2026-10-11: provide a shared file-management UI on Syncthing hosts, +with Caddy HTTPS and [verified tailnet identity](tailnet-ui-authentication.md). +The two intended users must enter without an internal password prompt. Serve +only caller-declared user folders; do not mount Syncthing configuration or the +whole data filesystem. Keep any app state outside synchronized files. The +magiusstaff second instance is deferred. + +## Selection + +| Candidate | Fit / tradeoff | +| --- | --- | +| [Mikochi](https://github.com/zer0tonin/Mikochi) | Small Go/Preact UI for browsing/upload/download/rename/delete and folders. One configured login/root; `NO_AUTH` fits the proxy-authenticated flow. Selected for the initial rollout. | +| [FileBrowser Quantum](https://github.com/gtsteffaniak/filebrowser) | Closest Filebrowser-style experience, richer sources/permissions; supports proxy authentication. More configuration and explicit old-state migration. | +| [Copyparty](https://github.com/9001/copyparty) | Existing folders, per-user/per-folder permissions, resumable uploads and WebDAV; a broader file-server feature set and different interface. | +| [Filestash](https://www.filestash.app/) | Browser client for varied storage backends; useful when protocol/cloud access is needed, adding integration choices for this local-folder use case. | + +The requested [Awesome Selfhosted section](https://github.com/awesome-selfhosted/awesome-selfhosted#file-transfer---web-based-file-managers) +provided the initial shortlist; capabilities above were checked against upstream +projects. Mikochi's latest [1.12.0 release](https://github.com/zer0tonin/Mikochi/releases/tag/1.12.0) +was published 2026-09-25 and includes authentication fixes. Its documented single +login is not a per-user permission model: with `NO_AUTH=true`, the proxy allowlist +controls access and both users share the mounted scope. Confirm supported +operations and use a current pinned image in a disposable trial. + +Mikochi 1.12.0 is declared alongside Syncthing on clamps and joey, with +`files..`, no application login, and a private backend. The +caller declares its user-file scope; the default is the Syncthing `data` directory, +separate from `config`. Both containers use the same numeric process identity +and host-user mapping. Existing synchronized folder paths are not moved. + +Real-container CI passed file operations and mapped ownership. The disposable +VM passed LocalAPI authorization, repeat apply and dual-stack backend isolation; +HTTPS browser acceptance is pending external ACME/connectivity checks. See +[the acceptance record](../butane/ACCEPTANCE.md). Broader symlink behavior remains +unverified; denial of an unmounted host target does not prove all container-path +containment. + +Disposable acceptance must test +large upload/download, filenames, folder operations, ownership, symlinks/root +containment, reboot, no second login and denial of other users/direct backend +access. Keep native login disabled only when the proxy is the sole entry point. +If different per-user folder rights become required, evaluate Quantum/Copyparty +before introducing another instance or custom authorization in Mikochi. diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md index f06e2c91..0679e489 100644 --- a/ublue/design/private-ui-access.md +++ b/ublue/design/private-ui-access.md @@ -55,9 +55,14 @@ routing. Restrict Caddy's UI listener to tailnet traffic and grant access only to the intended user identities. Tailnet access authorizes the connection; it does not -create an application login. Keep application passwords until the private path +create an application login. Keep application passwords for existing services until the private path and absence of direct UI access have been verified, then decide per application -whether a second login is useful. In particular, Syncthing's current wildcard +whether a second login is useful. File browsing and Gokapi administration now +have an explicit no-second-login requirement: the shared +[tailnet identity gate](tailnet-ui-authentication.md) authenticates and authorizes +the two intended users before proxying. It is implemented for Mikochi; Gokapi +integration remains planned. An IP-range check alone is insufficient +for those services. In particular, Syncthing's current wildcard host publication of GUI port 8384 must be removed or limited to a safe local proxy path before relying on tailnet access alone. @@ -110,3 +115,20 @@ References: [Tailscale DNS](https://tailscale.com/docs/reference/dns-in-tailscal [DNS rebinding](https://tailscale.com/docs/reference/faq/dns-rebinding/), [Let's Encrypt DNS-01](https://letsencrypt.org/docs/challenge-types/), [Caddy DNS challenge](https://caddyserver.com/docs/caddyfile/directives/tls). + +## ACME resolver policy + +DNS-01 propagation checks use explicit public Cloudflare resolvers, separately +from Podman DNS used for container upstreams. This keeps ACME independent of +the host's filtering/private DNS path. A diagnostic run completed issuance +with explicit resolvers; this does not eliminate external ACME validator DNS +failures, observed on the fresh VM. Keep this policy in shared rendering, +including aliases, and distinguish local reachability from issuer acceptance. + +## Planned public services + +Donbot adds public media and mixed file-sharing requirements. These are tracked +in [public service access](public-service-access.md) and +[its migration plan](../plan/DONBOT-MIGRATION.md). They require a generic access +policy extension; the current private renderer does not support them. Public +routes must preserve every existing private UI restriction. diff --git a/ublue/design/tailnet-ui-authentication.md b/ublue/design/tailnet-ui-authentication.md new file mode 100644 index 00000000..d7cdb288 --- /dev/null +++ b/ublue/design/tailnet-ui-authentication.md @@ -0,0 +1,69 @@ +# Tailnet identity for private web UIs + +Decision, 2026-10-11: file browsing must not ask for another username/password. +Reuse one caller-selected Caddy identity policy for file browsing and Gokapi +administration, allowing the two intended human users. Private policy values +and application-account mappings are delivered privately, never compiled into +source. An address-range gate alone supplies no user identity. + +Authenticate the actual connection through the existing host-network tailscaled +LocalAPI WhoIs. Require a user-owned device and an explicitly allowed complete +user identity; reject anonymous/unknown/tagged sources and lookup failures. +Private authenticated hostnames resolve to tailnet addresses: a browser using +the public IP does not supply a tailnet identity merely because Tailscale is +running. For Gokapi, prefer a private admin hostname and a public sharing hostname +if the selected app version supports their URL/origin behavior. Public routes +bypass the identity gate and never receive an administrative header. +Authorize before proxying every protected request. Never use browser-supplied +forwarded or identity headers to identify the user. Preserve the actual peer +through Podman ingress and overwrite/strip identity headers at Caddy. + +Gokapi supports [proxy header authentication](https://gokapi.readthedocs.io/en/latest/setup.html). +Map verified identity to existing application accounts with explicitly chosen +roles; permit no arbitrary account creation. Public sharing requests carry no +administrative identity. For a file UI supporting disabled authentication, +disable its internal login only behind the verified gate and a backend confined +to the proxy path. No UI host port or broadly reachable backend is allowed. +Use a private backend network accessible only through the declared proxy path; +verify isolation from other application networks. Omitted host ports alone do +not establish that boundary. No app password is required for file browsing. +App-native passwords are not the chosen user flow for these services. + +Implementation: Caddy's built-in `forward_auth` calls the Skillet Rust gate on +its private Unix socket. The gate calls only LocalAPI WhoIs and never caches +identity decisions. The full daemon socket is available to the root-owned gate, +not to Caddy or application containers. The reviewed gate is therefore trusted +with daemon access; a read-only socket mount would not restrict API operations. +This avoids the experimental Caddy plugin and a custom Caddy image. Policy is +part of the encrypted version 3 Caddy payload and converges to a root-only host +file. `skillet/tailscale/ui-users` supplies complete LoginName values separated +by commas or newlines. Both environments share that allowlist; explicit caller +UI declarations select which routes use it. Missing/invalid input fails delivery; +a missing or malformed host policy denies access. + +Mikochi runs on a dedicated backend bridge with explicit Netavark strict +isolation. Only it and Caddy join that bridge; Caddy has IP forwarding disabled. +Do not set `Internal=true`: Netavark skips the firewall rules for internal +networks, which allowed direct access in the dual-stack acceptance probe. +The selected bridge permits outbound traffic but blocks forwarded ingress from +other networks. Host root remains trusted; no backend port is published. +Strict isolation is supported by the guest's Netavark 1.17.2, independently of +Podman 6's newer default. Verify the actual bridge boundary during acceptance. +Cross-origin browser requests are rejected before authentication/proxying. + +Using the Tailscale client LocalAPI with Headscale is the intended portable +approach, but compatibility, returned identity and authorization must be tested. +This is an inference from the client-side integration, not accepted Headscale +support. Hosted Tailscale workstation enrollment remains separate. Do not match +only the username before `@`, infer people from tags or equate a tagged test +server with an authorized human client. + +Status: implemented for Mikochi on clamps and joey; Gokapi remains planned. +No-vault tests cover both allowed users, other/tagged/mismatched identities, +forged headers, daemon failure, IPv4/IPv6 and immediate host-policy revocation. +Live LocalAPI authorization, repeat apply and IPv4/IPv6 backend readiness and +sibling-network isolation passed. End-to-end HTTPS source preservation, both +human users, denied users, revocation/outage and file operations remain pending; +see [the acceptance record](../butane/ACCEPTANCE.md) for external ACME and +connectivity failures. CI file operations do not establish browser acceptance. +See [donbot migration](../plan/DONBOT-MIGRATION.md). diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 7f2c466b..2d25afe9 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -90,6 +90,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "askama" version = "0.12.1" @@ -461,6 +467,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cipher" version = "0.5.2" @@ -1046,6 +1058,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humansize" version = "2.1.3" @@ -2365,12 +2383,16 @@ dependencies = [ name = "skillet_caddy" version = "0.1.0" dependencies = [ + "reqwest", "serde", "serde_json", + "sha2 0.10.9", "skillet_core", "skillet_datadog", "skillet_podman", + "tempfile", "thiserror 1.0.69", + "tiny_http", ] [[package]] @@ -2382,6 +2404,7 @@ dependencies = [ "serde", "serde_yml", "sha2 0.10.9", + "skillet_caddy", "skillet_core", "skillet_hosts", "skillet_smtp", @@ -2456,6 +2479,7 @@ dependencies = [ "skillet_datadog", "skillet_ddns", "skillet_hardening", + "skillet_mikochi", "skillet_pihole", "skillet_podman", "skillet_smtp", @@ -2464,6 +2488,19 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "skillet_mikochi" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde_json", + "skillet_core", + "skillet_datadog", + "skillet_podman", + "tempfile", + "thiserror 1.0.69", +] + [[package]] name = "skillet_pihole" version = "0.1.0" @@ -2757,6 +2794,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.4" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index e718bcb0..27bb3df3 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/pihole", "crates/unifi", "crates/syncthing", + "crates/mikochi", "crates/btrbk", "crates/caddy", "crates/ddns", @@ -30,6 +31,7 @@ skillet_core = { path = "crates/core" } skillet_hardening = { path = "crates/hardening" } skillet_pihole = { path = "crates/pihole" } skillet_unifi = { path = "crates/unifi" } +skillet_mikochi = { path = "crates/mikochi" } skillet_syncthing = { path = "crates/syncthing" } skillet_btrbk = { path = "crates/btrbk" } skillet_caddy = { path = "crates/caddy" } diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index c5c16c01..96d3ab6f 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -347,6 +347,51 @@ Do not create a manual smoke VM ACME token. See the [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md) and [Cloudflare lifecycle design](../design/cloudflare-ui-lifecycle.md). +### Private file browsing + +Hosts declaring Syncthing also run Mikochi at `https://files..`. +For the default UI prefix, this is `files..ui.`. + +1. Create `skillet/tailscale/ui-users` in KeePassXC. Put complete Tailscale + `LoginName` values in **Password**, separated by commas or newlines; leave + **Username** empty. Find the identities in `tailscale status --json` on an + authenticated client. Include each allowed person explicitly. +2. In Tailscale Access controls, allow those people to reach the server tags on + `tcp:443`. For [grants](https://tailscale.com/docs/features/access-control/grants), + use their user group in `src`, `tag:skillet-server` and `tag:skillet-smoke` in + `dst`, and `["tcp:443"]` in `ip`. Tag ownership does not grant connectivity. +3. Unlock with `skillet secrets unlock`, then run + `skillet secrets check --host clamps --environment test`. +4. Provision the named test VM with + `skillet test vm provision clamps --with-ui`. +5. Open the files hostname from an allowed person's untagged tailnet client. + Mikochi has no additional login. Tagged server clients are denied. + +The default mounted file scope is `/var/lib/data/syncthing/data`, owned and +mapped like Syncthing. Configure synchronized folders beneath that directory. +New Syncthing instances use it as their home (the initial folder is `data/Sync`); +existing folder paths are preserved and must be moved/configured separately. +Mikochi cannot browse Syncthing's `config` directory or other service data. + +After updating a retained VM's Skillet binaries, redeliver Caddy credentials: +its previous service inventory lacks the new files site. Version 2 inputs +remain readable for unchanged profiles without identity-protected sites; +identity protection uses version 3. Removing a person from the vault takes +host effect after redelivery. The host policy is checked on every request. + +Mikochi has no published host port. Only Caddy joins its dedicated, isolated +backend network. Use a Netavark backend supporting `isolate=strict` and verify +bridge isolation; the smoke guest uses Podman 5.8.4 / Netavark 1.17.2. +The authorization endpoint uses `/run/skillet-tailnet-ui/auth.sock`; +the daemon socket remains outside Caddy at +`/run/skillet-tailscale/tailscaled.sock`. Do not forward the backend port or +mount either socket into unrelated containers. + +See [file browsing](../design/file-browsing.md) and +[tailnet UI authentication](../design/tailnet-ui-authentication.md). +Headscale identity lookup is not accepted yet; hosted Tailscale enrollment and +cleanup still use the hosted provider API. + ### Tailscale setup for Skillet Clamps runs `tailscale/tailscale` as a host-network container, matching Chef diff --git a/ublue/skillet/crates/caddy/Cargo.toml b/ublue/skillet/crates/caddy/Cargo.toml index 3e27bd82..baf91e4c 100644 --- a/ublue/skillet/crates/caddy/Cargo.toml +++ b/ublue/skillet/crates/caddy/Cargo.toml @@ -13,6 +13,10 @@ skillet_podman.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +reqwest.workspace = true +sha2.workspace = true +tiny_http = "0.12" [dev-dependencies] +tempfile.workspace = true skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/caddy/src/auth.rs b/ublue/skillet/crates/caddy/src/auth.rs new file mode 100644 index 00000000..3f6e2af8 --- /dev/null +++ b/ublue/skillet/crates/caddy/src/auth.rs @@ -0,0 +1,264 @@ +//! Local, per-request tailnet identity authorization for Caddy `forward_auth`. +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use skillet_core::{ + activation::{activate, ActivationRequest, ConsumerKind}, + files::{FileMutationResource, FileReadResource, Ownership}, + system::ServiceResource, +}; +use std::{ + collections::BTreeSet, + io::Read as _, + net::{IpAddr, SocketAddr}, + path::Path, + time::Duration, +}; +use thiserror::Error; + +pub const USERS_ENTRY: &str = "skillet/tailscale/ui-users"; +pub const DAEMON_SOCKET: &str = "/run/skillet-tailscale/tailscaled.sock"; +pub const AUTH_SOCKET: &str = "/run/skillet-tailnet-ui/auth.sock"; +pub const POLICY_FILE: &str = "/etc/skillet/tailnet-ui.json"; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("invalid tailnet UI policy; use distinct complete LoginName values, separated by commas or newlines")] + InvalidPolicy, + #[error("tailnet identity lookup failed")] + Lookup, + #[error("tailnet UI runtime I/O: {0}")] + Io(#[from] std::io::Error), + #[error("could not create tailnet UI authorization socket")] + Listener, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Policy { + users: BTreeSet, +} + +impl Policy { + pub fn new(users: Vec) -> Result { + if users.iter().any(|name| !valid_identity(name)) { + return Err(AuthError::InvalidPolicy); + } + let count = users.len(); + let unique: BTreeSet<_> = users.into_iter().collect(); + if unique.len() != count { + return Err(AuthError::InvalidPolicy); + } + Ok(Self { users: unique }) + } + + pub fn parse_users(contents: &str) -> Result, AuthError> { + let users: Vec<_> = contents + .split([',', '\n']) + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + if users.is_empty() { + return Err(AuthError::InvalidPolicy); + } + Self::new(users.clone())?; + Ok(users) + } + + pub fn authorize(&self, identity: &Identity) -> bool { + identity.node.tags.is_empty() + && identity.node.user != 0 + && identity.node.user == identity.user_profile.id + && self.users.contains(&identity.user_profile.login_name) + } +} + +fn valid_identity(name: &str) -> bool { + !name.is_empty() + && name.len() <= 254 + && name + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b',') +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Identity { + node: Node, + user_profile: UserProfile, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct Node { + user: u64, + #[serde(default)] + tags: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct UserProfile { + #[serde(rename = "ID")] + id: u64, + login_name: String, +} + +pub struct LocalApi { + client: reqwest::blocking::Client, +} + +impl LocalApi { + pub fn new(socket: &Path) -> Result { + let client = reqwest::blocking::Client::builder() + .no_proxy() + .unix_socket(socket) + .timeout(Duration::from_secs(2)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| AuthError::Lookup)?; + Ok(Self { client }) + } + + pub fn whois(&self, peer: SocketAddr) -> Result { + let response = self + .client + .get("http://local-tailscaled.sock/localapi/v0/whois") + .query(&[("addr", peer.to_string())]) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .map_err(|_| AuthError::Lookup)?; + let mut contents = Vec::new(); + response + .take(65_537) + .read_to_end(&mut contents) + .map_err(|_| AuthError::Lookup)?; + if contents.len() > 65_536 { + return Err(AuthError::Lookup); + } + serde_json::from_slice(&contents).map_err(|_| AuthError::Lookup) + } +} + +fn tailnet_peer(peer: SocketAddr) -> bool { + match peer.ip() { + IpAddr::V4(ip) => ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1]), + IpAddr::V6(ip) => ip.segments()[..3] == [0xfd7a, 0x115c, 0xa1e0], + } +} + +/// This endpoint trusts only Caddy's private Unix socket connection. Caddy must +/// overwrite the peer header with the original TCP peer, never forwarded input. +pub fn authorize_peer(policy: &Policy, peer: &str, api: &LocalApi) -> Option { + let peer: SocketAddr = peer.parse().ok()?; + if !tailnet_peer(peer) { + return None; + } + let identity = api.whois(peer).ok()?; + policy + .authorize(&identity) + .then(|| identity.user_profile.login_name.clone()) +} + +/// Guest runtime adapter. Policy is reread for every request; failures deny access. +pub fn serve(socket: &Path, daemon: &Path, policy_file: &Path) -> Result<(), AuthError> { + use std::os::unix::{ + fs::{FileTypeExt as _, PermissionsExt as _}, + net::{UnixListener, UnixStream}, + }; + if let Ok(metadata) = std::fs::symlink_metadata(socket) { + if !metadata.file_type().is_socket() || UnixStream::connect(socket).is_ok() { + return Err(AuthError::Listener); + } + std::fs::remove_file(socket)?; + } + let listener = UnixListener::bind(socket)?; + std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?; + let server = + tiny_http::Server::from_listener(listener, None).map_err(|_| AuthError::Listener)?; + let api = LocalApi::new(daemon)?; + for request in server.incoming_requests() { + respond(request, &api, policy_file); + } + Ok(()) +} + +fn respond(request: tiny_http::Request, api: &LocalApi, policy_file: &Path) { + let mut peers = request + .headers() + .iter() + .filter(|header| header.field.equiv("X-Skillet-Peer")); + let peer = peers.next().map(|header| header.value.as_str()); + let user = if request.method() == &tiny_http::Method::Get + && request.url() == "/auth" + && peers.next().is_none() + { + std::fs::read(policy_file) + .ok() + .filter(|bytes| bytes.len() <= 65_536) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|policy| { + // Deserialization must not bypass validation. + let policy = Policy::new(policy.users.into_iter().collect()).ok()?; + authorize_peer(&policy, peer?, api) + }) + } else { + None + }; + let mut response = tiny_http::Response::from_string(if user.is_some() { + "" + } else { + "Access denied by Skillet identity policy" + }) + .with_status_code(if user.is_some() { 204 } else { 403 }); + if let Some(user) = user { + if let Ok(header) = tiny_http::Header::from_bytes("X-Skillet-User", user) { + response.add_header(header); + } else { + response = tiny_http::Response::from_string("Access denied").with_status_code(403); + } + } + // A client disappearing must not take down authorization for other requests. + let _ = request.respond(response); +} + +pub fn apply(system: &S, files: &F, policy: &Policy) -> Result<(), super::CaddyError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let root = Ownership::named(Some("root"), Some("root")); + files.ensure_directory(Path::new("/etc/skillet"), Some(0o755), &root)?; + let payload = serde_json::to_vec(policy)?; + let policy_changed = files.ensure_file(Path::new(POLICY_FILE), &payload, Some(0o600), &root)?; + let unit = "[Unit]\nDescription=Skillet tailnet UI authorization\nAfter=tailscale.service\n\n[Service]\nExecStart=/var/usrlocal/bin/skillet tailnet-ui serve\nRuntimeDirectory=skillet-tailnet-ui\nRuntimeDirectoryMode=0700\nRuntimeDirectoryPreserve=yes\nRestart=on-failure\nUMask=0077\nNoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=yes\nPrivateTmp=yes\nReadWritePaths=/run/skillet-tailnet-ui\nRestrictAddressFamilies=AF_UNIX\n\n[Install]\nWantedBy=multi-user.target\n"; + let changed = files.ensure_file( + Path::new("/etc/systemd/system/skillet-tailnet-ui.service"), + unit.as_bytes(), + Some(0o644), + &root, + )?; + files.ensure_directory(Path::new("/var/lib/skillet/tailnet-ui"), Some(0o700), &root)?; + system.service_enable("skillet-tailnet-ui.service")?; + // The applied revision must not disclose allowed identities in a recording. + let mut revision = Sha256::new(); + revision.update(&payload); + revision.update(unit); + activate( + system, + files, + &ActivationRequest { + service: "skillet-tailnet-ui.service".into(), + state_path: "/var/lib/skillet/tailnet-ui/applied".into(), + revision: revision.finalize().to_vec(), + definition_changed: changed || policy_changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "auth/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/caddy/src/auth/tests.rs b/ublue/skillet/crates/caddy/src/auth/tests.rs new file mode 100644 index 00000000..ada7eedb --- /dev/null +++ b/ublue/skillet/crates/caddy/src/auth/tests.rs @@ -0,0 +1,208 @@ +use super::*; +use std::os::unix::net::UnixListener; + +fn identity(user: &str, tags: &[&str]) -> Identity { + Identity { + node: Node { + user: 1, + tags: tags.iter().map(|tag| (*tag).into()).collect(), + }, + user_profile: UserProfile { + id: 1, + login_name: user.into(), + }, + } +} + +#[test] +fn full_identities_not_prefixes_are_authorized_and_tags_are_denied() { + let policy = Policy::new(vec![ + "alice@example.invalid".into(), + "irene@example.invalid".into(), + ]) + .unwrap(); + assert!(policy.authorize(&identity("alice@example.invalid", &[]))); + assert!(policy.authorize(&identity("irene@example.invalid", &[]))); + for denied in ["alice@other.invalid", "alice", "unknown@example.invalid"] { + assert!(!policy.authorize(&identity(denied, &[]))); + } + assert!(!policy.authorize(&identity("alice@example.invalid", &["tag:server"]))); + let mut mismatch = identity("alice@example.invalid", &[]); + mismatch.node.user = 2; + assert!(!policy.authorize(&mismatch)); + assert!(!Policy::new(Vec::new()) + .unwrap() + .authorize(&identity("alice@example.invalid", &[]))); +} + +#[test] +fn malformed_policy_and_empty_required_entry_are_rejected_without_values() { + for invalid in ["", "\n", "alice smith", "alice\nalice", "bad\0identity"] { + let error = Policy::parse_users(invalid).unwrap_err(); + assert!(!error.to_string().contains(invalid) || invalid.is_empty()); + } + // Headscale's complete LoginName can differ from a hosted email identity. + assert_eq!( + Policy::parse_users("person\nother-person").unwrap(), + ["person", "other-person"] + ); +} + +#[test] +fn real_unix_localapi_checks_exact_peer_and_daemon_failure_denies() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("tailscaled.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = tiny_http::Server::from_listener(listener, None).unwrap(); + let thread = std::thread::spawn(move || { + let request = server + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + assert_eq!( + request.url(), + "/localapi/v0/whois?addr=100.100.100.1%3A1234" + ); + let body = + r#"{"Node":{"User":1},"UserProfile":{"ID":1,"LoginName":"alice@example.invalid"}}"#; + request + .respond(tiny_http::Response::from_string(body)) + .unwrap(); + }); + let api = LocalApi::new(&socket).unwrap(); + let policy = Policy::new(vec!["alice@example.invalid".into()]).unwrap(); + assert!(authorize_peer(&policy, "127.0.0.1:1234", &api).is_none()); + assert!(authorize_peer(&policy, "forged", &api).is_none()); + assert_eq!( + authorize_peer(&policy, "100.100.100.1:1234", &api).as_deref(), + Some("alice@example.invalid") + ); + thread.join().unwrap(); + assert!(authorize_peer(&policy, "100.100.100.1:1234", &api).is_none()); +} + +#[test] +fn ipv4_and_ipv6_ranges_are_exact() { + for addr in [ + "100.64.0.1:10", + "100.127.255.254:10", + "[fd7a:115c:a1e0::1]:10", + ] { + assert!(tailnet_peer(addr.parse().unwrap())); + } + for addr in [ + "100.63.0.1:10", + "100.128.0.1:10", + "[fd7a:115c:a1e1::1]:10", + "[::1]:10", + ] { + assert!(!tailnet_peer(addr.parse().unwrap())); + } +} + +#[test] +fn users_accept_commas_newlines_and_whitespace_but_never_duplicates() { + assert_eq!( + Policy::parse_users( + " alice@example.invalid, irene@example.invalid\nother@example.invalid " + ) + .unwrap(), + [ + "alice@example.invalid", + "irene@example.invalid", + "other@example.invalid" + ] + ); + assert!(Policy::parse_users("alice, alice").is_err()); +} + +#[test] +fn authorization_endpoint_ignores_forged_headers_and_reloads_revocations() { + let temp = tempfile::tempdir().unwrap(); + let daemon = temp.path().join("daemon.sock"); + let auth = temp.path().join("auth.sock"); + let policy = temp.path().join("policy.json"); + std::fs::write( + &policy, + serde_json::to_vec(&Policy::new(vec!["alice@example.invalid".into()]).unwrap()).unwrap(), + ) + .unwrap(); + let api_server = tiny_http::Server::http_unix(&daemon).unwrap(); + let api_thread = std::thread::spawn(move || { + for _ in 0..2 { + let request = api_server + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + request.respond(tiny_http::Response::from_string(r#"{"Node":{"User":1},"UserProfile":{"ID":1,"LoginName":"alice@example.invalid"}}"#)).unwrap(); + } + }); + let server = tiny_http::Server::http_unix(&auth).unwrap(); + let api = LocalApi::new(&daemon).unwrap(); + let policy_path = policy.clone(); + let auth_thread = std::thread::spawn(move || { + for _ in 0..5 { + let request = server + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + super::respond(request, &api, &policy_path); + } + }); + let client = reqwest::blocking::Client::builder() + .no_proxy() + .unix_socket(auth.as_path()) + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let url = "http://auth.local/auth"; + let allowed = client + .get(url) + .header("X-Skillet-Peer", "100.100.100.1:1234") + .header("X-Skillet-User", "attacker") + .send() + .unwrap(); + assert_eq!(allowed.status(), 204); + assert_eq!(allowed.headers()["X-Skillet-User"], "alice@example.invalid"); + assert_eq!( + client + .get(url) + .header("X-Skillet-User", "alice@example.invalid") + .send() + .unwrap() + .status(), + 403 + ); + assert_eq!( + client + .get(url) + .header("X-Skillet-Peer", "127.0.0.1:1234") + .header("X-Forwarded-For", "100.100.100.1") + .send() + .unwrap() + .status(), + 403 + ); + assert_eq!( + client + .get(url) + .header("X-Skillet-Peer", "100.100.100.1:1234") + .header("X-Skillet-Peer", "100.100.100.2:1234") + .send() + .unwrap() + .status(), + 403 + ); + std::fs::write(&policy, br#"{"users":[]}"#).unwrap(); + assert_eq!( + client + .get(url) + .header("X-Skillet-Peer", "100.100.100.1:1234") + .send() + .unwrap() + .status(), + 403 + ); + auth_thread.join().unwrap(); + api_thread.join().unwrap(); +} diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs index c56a4eac..b1b77f3c 100644 --- a/ublue/skillet/crates/caddy/src/lib.rs +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -12,6 +12,8 @@ use skillet_podman::{ use std::{collections::BTreeMap, path::Path}; use thiserror::Error; +pub mod auth; + #[cfg(test)] #[path = "tests.rs"] mod tests; @@ -23,6 +25,8 @@ pub struct UiService { pub port: u16, #[serde(default)] pub aliases: Vec, + #[serde(default)] + pub tailnet_identity: bool, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -40,6 +44,8 @@ pub struct CaddySites { pub acme_staging: bool, pub machine_hostname: String, pub services: Vec, + #[serde(default)] + pub tailnet_users: Vec, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -50,6 +56,8 @@ pub struct CaddySite { pub aliases: Vec, pub upstream: String, pub port: u16, + #[serde(default)] + pub tailnet_identity: bool, } #[derive(Debug, Error)] @@ -64,6 +72,10 @@ pub enum CaddyError { InvalidConfiguration(String), #[error("invalid Caddy sites credential: {0}")] InvalidSites(#[from] serde_json::Error), + #[error(transparent)] + Auth(#[from] auth::AuthError), + #[error(transparent)] + Activation(#[from] skillet_core::activation::ActivationError), } impl CaddySites { @@ -133,27 +145,29 @@ impl CaddySites { aliases, upstream: service.upstream.clone(), port: service.port, + tailnet_identity: service.tailnet_identity, }); } Ok(Self { - version: 2, + version: 3, host: host.to_string(), ui_domain: environment.ui_domain.clone(), acme_staging: environment.acme_staging, machine_hostname, services: sites, + tailnet_users: Vec::new(), }) } pub fn parse(contents: &str, host: &str, services: &[UiService]) -> Result { let sites: Self = serde_json::from_str(contents)?; - if sites.version != 2 { + if !matches!(sites.version, 2 | 3) { return Err(CaddyError::InvalidConfiguration(format!( "unsupported payload version {}", sites.version ))); } - let expected = Self::from_host( + let mut expected = Self::from_host( host, &UiEnvironment { ui_domain: sites.ui_domain.clone(), @@ -161,6 +175,15 @@ impl CaddySites { }, services, )?; + auth::Policy::new(sites.tailnet_users.clone())?; + if sites.version == 2 && sites.services.iter().any(|site| site.tailnet_identity) { + return Err(CaddyError::InvalidConfiguration( + "identity protection requires payload version 3; redeliver Caddy credentials" + .into(), + )); + } + expected.version = sites.version; + expected.tailnet_users.clone_from(&sites.tailnet_users); if sites != expected { return Err(CaddyError::InvalidConfiguration( "payload does not match this host's declared UI services".to_string(), @@ -169,6 +192,10 @@ impl CaddySites { Ok(expected) } + pub fn needs_identity_gate(&self) -> bool { + self.services.iter().any(|site| site.tailnet_identity) + } + pub fn render(&self) -> String { let acme = if self.acme_staging { " acme_ca https://acme-staging-v02.api.letsencrypt.org/directory\n" @@ -180,10 +207,15 @@ impl CaddySites { for service in &self.services { use std::fmt::Write as _; for hostname in std::iter::once(&service.hostname).chain(&service.aliases) { + let _ = writeln!(config, "\n{hostname} {{\n tls {{\n resolvers 1.1.1.1 1.0.0.1\n }}\n route {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet \"Access denied by Skillet tailnet policy\" 403"); + if service.tailnet_identity { + let _ = writeln!(config, " @cross_origin expression `{{http.request.header.Origin}} != \"\" && {{http.request.header.Origin}} != \"https://{hostname}\"`\n respond @cross_origin 403\n @cross_site header Sec-Fetch-Site cross-site\n respond @cross_site 403"); + config.push_str(" request_header -X-Skillet-Peer\n request_header -X-Skillet-User\n request_header -X-Webauth-User\n request_header -Remote-User\n request_header -Tailscale-User\n forward_auth unix//run/skillet-tailnet-ui/auth.sock {\n uri /auth\n header_up X-Skillet-Peer {http.request.remote}\n copy_headers X-Skillet-User\n }\n"); + } let _ = writeln!( config, - "\n{} {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet \"Access denied by Skillet tailnet policy\" 403\n reverse_proxy {}:{}\n}}", - hostname, service.upstream, service.port + " reverse_proxy {}:{}\n }}\n}}", + service.upstream, service.port ); } } @@ -276,7 +308,25 @@ where S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, F: FileMutationResource + FileReadResource + ?Sized, { + let policy = auth::Policy::new(sites.tailnet_users.clone())?; let caddyfile = sites.render(); + let mut networks = vec![NetworkAttachment::Bridge(network_name.to_string())]; + let mut auth_volumes = Vec::new(); + if sites.needs_identity_gate() { + auth::apply(system, files, &policy)?; + auth_volumes.push(Volume { + host_path: "/run/skillet-tailnet-ui".into(), + container_path: "/run/skillet-tailnet-ui".into(), + options: Some("ro,z".into()), + host_mode: None, + host_ownership: None, + }); + for site in sites.services.iter().filter(|site| site.tailnet_identity) { + let network = private_network(&sites.host, &site.name); + skillet_podman::ensure_network(system, files, &network)?; + networks.push(NetworkAttachment::Bridge(network.unit_name)); + } + } let config_dir = Path::new("/etc/skillet/caddy"); files.ensure_directory( config_dir, @@ -308,10 +358,20 @@ where ], ); - let config = PodmanConfig { + if sites.needs_identity_gate() { + container.entry("Unit".into()).or_default().extend([ + "Requires=skillet-tailnet-ui.service".into(), + "After=skillet-tailnet-ui.service".into(), + ]); + container.entry("Container".into()).or_default().extend([ + "Sysctl=net.ipv4.ip_forward=0".into(), + "Sysctl=net.ipv6.conf.all.forwarding=0".into(), + ]); + } + let mut config = PodmanConfig { name: "caddy".to_string(), image: "ghcr.io/caddybuilds/caddy-cloudflare:2".to_string(), - network_attachments: vec![NetworkAttachment::Bridge(network_name.to_string())], + network_attachments: networks, port_publications: ["::", "0.0.0.0"] .into_iter() .map(|address| PortPublication { @@ -327,29 +387,7 @@ where storage_dependency: Some(MountDependency::shared_service_data()), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, - volumes: vec![ - Volume { - host_path: "/etc/skillet/caddy".to_string(), - container_path: "/etc/caddy".to_string(), - options: Some("ro,Z".to_string()), - host_mode: None, - host_ownership: None, - }, - Volume { - host_path: "/var/lib/data/caddy/data".to_string(), - container_path: "/data".to_string(), - options: Some("Z".to_string()), - host_mode: None, - host_ownership: None, - }, - Volume { - host_path: "/var/lib/data/caddy/config".to_string(), - container_path: "/config".to_string(), - options: Some("Z".to_string()), - host_mode: None, - host_ownership: None, - }, - ], + volumes: state_volumes(), secrets: vec![QuadletSecret { secret_name: "cloudflare_acme_token".to_string(), target: SecretTarget::Environment { @@ -359,6 +397,7 @@ where config_revisions: vec![caddyfile.into_bytes()], extra_config: container, }; + config.volumes.extend(auth_volumes); // Preserve the caller's network configuration and let Podman create its // persistent directories on the shared data filesystem. for path in ["/var/lib/data/caddy/data", "/var/lib/data/caddy/config"] { @@ -371,3 +410,46 @@ where skillet_podman::container(system, files, config)?; Ok(()) } + +/// A private backend whose strict firewall isolation allows only joined containers. +pub fn private_network(host: &str, service: &str) -> skillet_podman::PodmanNetwork { + let name = format!("{host}-{service}"); + skillet_podman::PodmanNetwork { + unit_name: name.clone(), + options: vec![ + "Driver=bridge".into(), + // Netavark skips firewall/isolation rules for internal networks. + "Internal=false".into(), + "DisableDNS=false".into(), + "IPv6=true".into(), + "Options=isolate=strict".into(), + format!("NetworkName={name}"), + ], + } +} + +fn state_volumes() -> Vec { + vec![ + Volume { + host_path: "/etc/skillet/caddy".to_string(), + container_path: "/etc/caddy".to_string(), + options: Some("ro,Z".to_string()), + host_mode: None, + host_ownership: None, + }, + Volume { + host_path: "/var/lib/data/caddy/data".to_string(), + container_path: "/data".to_string(), + options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, + }, + Volume { + host_path: "/var/lib/data/caddy/config".to_string(), + container_path: "/config".to_string(), + options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, + }, + ] +} diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs index e393d08e..d0d04312 100644 --- a/ublue/skillet/crates/caddy/src/tests.rs +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -10,6 +10,7 @@ fn syncthing_only() -> Vec { name: "syncthing".to_string(), upstream: "syncthing".to_string(), port: 8384, + tailnet_identity: false, aliases: Vec::new(), }] } @@ -95,6 +96,7 @@ fn renders_each_declared_service_with_tailnet_filter_and_container_dns() { name: "pihole".to_string(), upstream: "pihole".to_string(), port: 8088, + tailnet_identity: false, aliases: Vec::new(), }, syncthing_only().remove(0), @@ -185,6 +187,7 @@ fn renders_declared_aliases_and_rejects_conflicting_names() { name: "syncthing".to_string(), upstream: "syncthing".to_string(), port: 8384, + tailnet_identity: false, aliases: vec!["sync".to_string(), "sync.{host}".to_string()], }]; let sites = CaddySites::from_host( @@ -208,9 +211,11 @@ fn renders_declared_aliases_and_rejects_conflicting_names() { assert!(rendered.contains("sync.test.example.invalid {")); assert!(rendered.contains("sync.clamps.test.example.invalid {")); assert_eq!(rendered.matches("reverse_proxy syncthing:8384").count(), 3); + assert_eq!(rendered.matches("resolvers 1.1.1.1 1.0.0.1").count(), 3); for alias in ["", ".sync", "sync.", "*.sync", "bad{host}", "clamps"] { let invalid = vec![UiService { + tailnet_identity: false, aliases: vec![alias.to_string()], ..services[0].clone() }]; @@ -274,3 +279,128 @@ fn relative_ui_domain_rejects_invalid_prefixes_and_total_length() { let too_long = format!("{label}.{label}.{label}.{label}"); assert!(super::resolve_ui_domain("example.com", Some(&too_long)).is_err()); } + +#[test] +fn file_ui_is_gated_before_proxy_and_cannot_disable_declared_identity() { + let services = vec![UiService { + name: "files".into(), + upstream: "mikochi".into(), + port: 8080, + aliases: vec!["browse.{host}".into()], + tailnet_identity: true, + }]; + let mut sites = CaddySites::from_host( + "joey", + &UiEnvironment { + ui_domain: "ui.example.invalid".into(), + acme_staging: true, + }, + &services, + ) + .unwrap(); + sites.tailnet_users = vec!["alice@example.invalid".into()]; + let config = sites.render(); + assert!(config.contains("files.joey.ui.example.invalid")); + assert_eq!( + config + .matches("forward_auth unix//run/skillet-tailnet-ui/auth.sock") + .count(), + 2 + ); + assert!(config.find("forward_auth").unwrap() < config.find("reverse_proxy mikochi").unwrap()); + assert!(config.contains("header_up X-Skillet-Peer {http.request.remote}")); + assert!(config.contains("request_header -X-Skillet-User")); + assert!(config.contains("respond @cross_origin 403")); + let encoded = serde_json::to_string(&sites).unwrap(); + assert_eq!( + CaddySites::parse(&encoded, "joey", &services).unwrap(), + sites + ); + sites.services[0].tailnet_identity = false; + assert!(CaddySites::parse(&serde_json::to_string(&sites).unwrap(), "joey", &services).is_err()); +} + +#[test] +fn identity_caddy_has_only_the_authorization_socket_and_a_private_backend() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system + .ensure_podman_secret("cloudflare_acme_token", "dummy-token") + .unwrap(); + let mut sites = CaddySites::from_host( + "joey", + &UiEnvironment { + ui_domain: "ui.example.invalid".into(), + acme_staging: true, + }, + &[UiService { + name: "files".into(), + upstream: "mikochi".into(), + port: 8080, + aliases: Vec::new(), + tailnet_identity: true, + }], + ) + .unwrap(); + sites.tailnet_users = vec!["alice@example.invalid".into()]; + super::apply(&system, &files, &sites, "joey").unwrap(); + let records = files.files.lock().unwrap(); + let quadlet = String::from_utf8_lossy(&records["/etc/containers/systemd/caddy.container"]); + assert!(quadlet.contains("Network=joey-files.network")); + assert!(quadlet.contains("Volume=/run/skillet-tailnet-ui:/run/skillet-tailnet-ui:ro,z")); + assert!(!quadlet.contains("skillet-tailscale")); + let network = String::from_utf8_lossy(&records["/etc/containers/systemd/joey-files.network"]); + assert!(network.contains("Internal=false")); + assert!(network.contains("Options=isolate=strict")); + let restarts = system.restart_count.load(Ordering::SeqCst); + drop(records); + super::apply(&system, &files, &sites, "joey").unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); +} + +#[test] +#[ignore = "requires Podman and the Caddy container image"] +fn generated_identity_caddyfile_validates_with_the_real_consumer() { + let temp = tempfile::tempdir().unwrap(); + let sites = CaddySites::from_host( + "clamps", + &UiEnvironment { + ui_domain: "ui.example.invalid".into(), + acme_staging: true, + }, + &[UiService { + name: "files".into(), + upstream: "mikochi".into(), + port: 8080, + aliases: Vec::new(), + tailnet_identity: true, + }], + ) + .unwrap(); + let path = temp.path().join("Caddyfile"); + std::fs::write(&path, sites.render()).unwrap(); + let volume = format!("{}:/etc/caddy/Caddyfile:ro,z", path.display()); + let output = std::process::Command::new("podman") + .args([ + "run", + "--rm", + "--network", + "none", + "--volume", + &volume, + "--env", + "CF_API_TOKEN=0000000000000000000000000000000000000000", + "ghcr.io/caddybuilds/caddy-cloudflare:2", + "caddy", + "validate", + "--config", + "/etc/caddy/Caddyfile", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml index 3bba6dc3..5f61de92 100644 --- a/ublue/skillet/crates/cli-common/Cargo.toml +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -11,6 +11,7 @@ nix.workspace = true sha2.workspace = true zeroize = "1.9" skillet_smtp.workspace = true +skillet_caddy.workspace = true skillet_core.workspace = true skillet_hosts.workspace = true clap.workspace = true diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs index cf7228b2..c3394aa7 100644 --- a/ublue/skillet/crates/cli-common/src/lib.rs +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -56,6 +56,11 @@ pub struct HostArgs { #[derive(clap::Subcommand, Debug)] pub enum HostCommands { + /// Run the private tailnet identity authorization endpoint + TailnetUi { + #[command(subcommand)] + command: TailnetUiCommands, + }, /// Finalize interactive administrative access after provisioning Access { #[command(subcommand)] @@ -117,6 +122,7 @@ where tracing::subscriber::set_global_default(subscriber)?; match args.command { + HostCommands::TailnetUi { command } => run_tailnet_ui(command), HostCommands::Access { command } => access::dispatch(command), HostCommands::Smtp { command: SmtpCommands::Prepare, @@ -303,3 +309,27 @@ pub fn prepare_smtp() -> Result<(), CliCommonError> { Ok(()) }) } + +#[derive(clap::Subcommand, Debug)] +pub enum TailnetUiCommands { + /// Serve Caddy authorization over a private Unix socket + Serve { + #[arg(long, default_value = skillet_caddy::auth::AUTH_SOCKET)] + socket: PathBuf, + #[arg(long, default_value = skillet_caddy::auth::DAEMON_SOCKET)] + daemon_socket: PathBuf, + #[arg(long, default_value = skillet_caddy::auth::POLICY_FILE)] + policy_file: PathBuf, + }, +} + +pub fn run_tailnet_ui(command: TailnetUiCommands) -> Result<(), CliCommonError> { + match command { + TailnetUiCommands::Serve { + socket, + daemon_socket, + policy_file, + } => skillet_caddy::auth::serve(&socket, &daemon_socket, &policy_file) + .map_err(|error| CliCommonError::Config(error.to_string())), + } +} diff --git a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs index 80bd435a..7b0188b2 100644 --- a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs +++ b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs @@ -149,3 +149,67 @@ fn data_mount_and_persistence_hash_checks_reject_lost_state() { super::verify_guest_hash(&transport, "/var/lib/data/.marker", "expected-hash").is_err() ); } + +struct FilesTransport { + sibling_exit: i32, + probes: std::sync::Mutex>, +} + +impl GuestTransport for FilesTransport { + fn execute(&self, request: &GuestCommand<'_>, _: Option<&[u8]>) -> skillet_vm::Result { + let (stdout, code) = match (request.program, request.arguments) { + ("sudo", ["-n", "podman", "inspect", "--format", _, "mikochi"]) => ( + br#"{"fixture-files":{"IPAddress":"172.28.0.2","GlobalIPv6Address":"fd00:1234::2"}}"#.to_vec(), + 0, + ), + ("curl", arguments) => { + self.probes.lock().unwrap().push(arguments.last().unwrap().to_string()); + (br#"{"status":"ok"}"#.to_vec(), 0) + } + ("sudo", ["-n", "podman", "exec", "syncthing", "curl", arguments @ ..]) => { + self.probes.lock().unwrap().push(arguments.last().unwrap().to_string()); + (Vec::new(), self.sibling_exit) + } + _ => return Err(skillet_vm::Error::Invalid("unsupported acceptance fake request".into())), + }; + Ok(Output { + status: ExitStatus::from_raw(code << 8), + stdout, + stderr: Vec::new(), + }) + } + + fn upload(&self, _: &Path, _: &str) -> skillet_vm::Result<()> { + Err(skillet_vm::Error::Invalid( + "unsupported acceptance fake upload".into(), + )) + } +} + +#[test] +fn file_ui_health_proves_readiness_and_sibling_denial_in_both_ip_families() { + for sibling_exit in [7, 28] { + let guest = FilesTransport { + sibling_exit, + probes: std::sync::Mutex::default(), + }; + super::check_mikochi_health(&guest).unwrap(); + let probes = guest.probes.lock().unwrap(); + assert_eq!(probes.len(), 4); + assert_eq!(probes[0], probes[1]); + assert_eq!(probes[2], probes[3]); + assert!(probes[0].contains("172.28.0.2:8080")); + assert!(probes[2].contains("[fd00:1234::2]:8080")); + } +} + +#[test] +fn file_ui_health_rejects_accessible_backends_and_broken_probe_commands() { + for sibling_exit in [0, 1, 3, 127] { + let guest = FilesTransport { + sibling_exit, + probes: std::sync::Mutex::default(), + }; + assert!(super::check_mikochi_health(&guest).is_err()); + } +} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs index 9ed5d444..d99b2f85 100644 --- a/ublue/skillet/crates/cli/src/main.rs +++ b/ublue/skillet/crates/cli/src/main.rs @@ -23,6 +23,11 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { + /// Run the guest private UI authorization endpoint + TailnetUi { + #[command(subcommand)] + command: skillet_cli_common::TailnetUiCommands, + }, /// Prepare physical installation media without writing a block device Install { #[command(subcommand)] @@ -350,6 +355,7 @@ fn main() -> Result<()> { .context("setting default subscriber failed")?; match args.command { + Commands::TailnetUi { command } => skillet_cli_common::run_tailnet_ui(command)?, Commands::Install { command } => installation::dispatch(command)?, Commands::Access { command } => skillet_cli_common::access::dispatch(command)?, Commands::Smtp { @@ -1037,6 +1043,7 @@ fn check_health( ) -> Result> { use skillet_hosts::HealthProbe; match probe { + Some(HealthProbe::Mikochi) => check_mikochi_health(transport), Some(HealthProbe::Pihole) => { smoke_guest_command( transport, @@ -1074,6 +1081,96 @@ fn check_health( } } +fn check_mikochi_health( + transport: &impl skillet_vm::transport::GuestTransport, +) -> Result> { + let inspected = guest_text( + transport, + "sudo", + &[ + "-n", + "podman", + "inspect", + "--format", + "{{json .NetworkSettings.Networks}}", + "mikochi", + ], + )?; + let networks: serde_json::Map = serde_json::from_str(&inspected)?; + if networks.len() != 1 { + return Err(anyhow!( + "Mikochi must have exactly one private backend network" + )); + } + let network = networks + .values() + .next() + .ok_or_else(|| anyhow!("Mikochi backend absent"))?; + for field in ["IPAddress", "GlobalIPv6Address"] { + let address: std::net::IpAddr = network + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| anyhow!("Mikochi backend address absent"))? + .parse()?; + let url = format!("http://{}/ready", std::net::SocketAddr::new(address, 8080)); + let reply = guest_text( + transport, + "curl", + &[ + "--noproxy", + "*", + "--silent", + "--show-error", + "--fail", + "--max-time", + "10", + &url, + ], + )?; + let ready: serde_json::Value = serde_json::from_str(&reply)?; + if ready.get("status").and_then(serde_json::Value::as_str) != Some("ok") { + return Err(anyhow!("Mikochi is not ready")); + } + check_mikochi_backend_isolation(transport, &url)?; + } + Ok(Some("health:mikochi:ready:isolated-dual-stack".into())) +} + +fn check_mikochi_backend_isolation( + transport: &impl skillet_vm::transport::GuestTransport, + url: &str, +) -> Result<()> { + let output = transport.execute( + &skillet_vm::transport::GuestCommand { + program: "sudo", + arguments: &[ + "-n", + "podman", + "exec", + "syncthing", + "curl", + "--noproxy", + "*", + "--silent", + "--show-error", + "--connect-timeout", + "3", + "--max-time", + "5", + url, + ], + }, + None, + )?; + // A missing probe executable or malformed command is not isolation evidence. + if !matches!(output.status.code(), Some(7 | 28)) { + return Err(anyhow!( + "Mikochi backend isolation probe did not reject a sibling application connection" + )); + } + Ok(()) +} + fn smoke_guest_command( transport: &impl skillet_vm::transport::GuestTransport, program: &str, diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs index 225509fc..6e7c7e1a 100644 --- a/ublue/skillet/crates/cli/src/secret_delivery.rs +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -174,6 +174,7 @@ fn deliver_ddns_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Resul } fn deliver_caddy_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Result<()> { + let users = tailnet_ui_users(&args.hostname, vault)?; let policy = args.environment.policy(); let domain_path = policy.ui_domain_entry(); let domain_prefix = vault.get(&domain_path)?; @@ -200,6 +201,7 @@ fn deliver_caddy_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Resu policy, zone_id: &zone_id, relative_ui_domain: domain_prefix.as_deref(), + tailnet_users: &users, creator_token: &creator, }, &mut token_store, @@ -460,6 +462,7 @@ fn provision_vm_ui( vault: &mut Vault, device: &tailscale::DeviceRecord, ) -> Result<()> { + let users = tailnet_ui_users(&args.hostname, vault)?; let policy = UiEnvironmentName::Test.policy(); let domain_path = policy.ui_domain_entry(); let zone_path = policy.cloudflare_zone_entry(); @@ -483,6 +486,7 @@ fn provision_vm_ui( policy, zone_id: &zone_id, relative_ui_domain: domain_prefix.as_deref(), + tailnet_users: &users, creator_token: &creator, run_directory: run_dir, addresses: &device.addresses, @@ -681,3 +685,14 @@ fn provision_vm_smtp(args: &VmProvisionArgs, transport: &impl GuestTransport) -> #[cfg(test)] #[path = "secret_delivery/tests.rs"] mod tests; + +fn tailnet_ui_users(host: &str, vault: &Vault) -> Result> { + let profile = + skillet_hosts::profile_for_name(host).ok_or_else(|| anyhow!("unknown host profile"))?; + if !profile.ui_services().iter().any(|ui| ui.tailnet_identity) { + return Ok(Vec::new()); + } + let users = vault.get(skillet_caddy::auth::USERS_ENTRY)?.ok_or_else(|| + anyhow!("KeePassXC entry is missing: {}; put full LoginName values in Password, separated by commas or newlines", skillet_caddy::auth::USERS_ENTRY))?; + Ok(skillet_caddy::auth::Policy::parse_users(&users)?) +} diff --git a/ublue/skillet/crates/host-profiles/Cargo.toml b/ublue/skillet/crates/host-profiles/Cargo.toml index fb5c6da5..befaaa29 100644 --- a/ublue/skillet/crates/host-profiles/Cargo.toml +++ b/ublue/skillet/crates/host-profiles/Cargo.toml @@ -14,6 +14,7 @@ skillet_hardening.workspace = true skillet_pihole.workspace = true skillet_unifi.workspace = true skillet_syncthing.workspace = true +skillet_mikochi.workspace = true skillet_btrbk.workspace = true skillet_caddy.workspace = true skillet_ddns.workspace = true diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 96cd95db..47a651d7 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -72,7 +72,7 @@ fn clamps_tailscale_uses_host_network_and_persistent_state() { config.network_attachments, [skillet_podman::NetworkAttachment::Host] ); - assert_eq!(config.volumes.len(), 1); + assert_eq!(config.volumes.len(), 2); assert_eq!(config.volumes[0].host_path, "/var/lib/data/tailscale"); assert_eq!(config.volumes[0].container_path, "/var/lib/tailscale"); let container = &config.extra_config["Container"]; @@ -113,6 +113,7 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s "ddns", "datadog", "btrbk", + "mikochi", "smtp" ] ); @@ -156,6 +157,7 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s "ddns", "datadog", "btrbk", + "mikochi", "smtp" ] ); @@ -285,7 +287,7 @@ fn host_ui_declarations_include_only_the_services_each_host_runs() { .iter() .map(|service| service.name.as_str()) .collect::>(), - ["pihole", "syncthing"] + ["pihole", "syncthing", "files"] ); assert_eq!(clamps.network_name, "clamps"); assert_eq!(clamps.services[0].upstream, "pihole"); @@ -296,7 +298,7 @@ fn host_ui_declarations_include_only_the_services_each_host_runs() { assert_eq!(clamps.services[1].aliases, ["sync.{host}"]); let joey = ui_config_for_host("joey").expect("joey UI declaration"); - assert_eq!(joey.services.len(), 2); + assert_eq!(joey.services.len(), 3); assert_eq!(joey.services[0].name, "pihole"); assert_eq!(joey.services[1].name, "syncthing"); assert_eq!(joey.services[1].aliases, ["sync.{host}"]); @@ -322,7 +324,9 @@ fn application_acceptance_tracks_each_profiles_declared_services() { "syncthing.service", "unifi.service", "skillet-btrbk.timer", - "caddy.service" + "mikochi.service", + "caddy.service", + "skillet-tailnet-ui.service" ] ); let pihole = clamps @@ -369,7 +373,9 @@ fn application_acceptance_tracks_each_profiles_declared_services() { "tailscale.service", "syncthing.service", "skillet-btrbk.timer", - "caddy.service" + "mikochi.service", + "caddy.service", + "skillet-tailnet-ui.service" ] ); assert!(joey @@ -423,6 +429,7 @@ fn synthetic_profile_derives_network_and_ui_from_its_identity() { requires_data_mount: false, services: vec![HostService { config: ServiceConfig::Syncthing { + files_path: "/var/lib/data/syncthing/data", data_path: "/srv/sync", data_owner: "sync", data_group: "sync", @@ -433,6 +440,7 @@ fn synthetic_profile_derives_network_and_ui_from_its_identity() { name: "sync", upstream: "sync", port: 8384, + tailnet_identity: false, aliases: &["files.{host}"], }), }], @@ -625,3 +633,31 @@ fn joey_full_apply_uses_shared_services_and_is_repeatable() { assert!(!before.contains_key("/etc/containers/systemd/datadog-agent.container")); assert!(!before.contains_key("/etc/containers/systemd/cloudflare-ddns.container")); } + +#[test] +fn every_syncthing_caller_has_matching_private_file_browsing() { + for profile in super::declared_profiles() { + let syncthing = profile + .services + .iter() + .find(|service| matches!(service.config, ServiceConfig::Syncthing { .. })); + let files = profile + .services + .iter() + .find(|service| matches!(service.config, ServiceConfig::Mikochi { .. })); + assert_eq!(syncthing.is_some(), files.is_some()); + if let Some(files) = files { + let ui = files.ui.unwrap(); + assert_eq!(ui.name, "files"); + assert!(ui.tailnet_identity); + assert_eq!(ui.upstream, "mikochi"); + let plan = profile.acceptance_plan(); + let acceptance = plan + .services + .iter() + .find(|service| service.unit == "mikochi.service") + .unwrap(); + assert_eq!(acceptance.health_probe, Some(crate::HealthProbe::Mikochi)); + } + } +} diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index 364f6611..5df3983c 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -89,6 +89,8 @@ pub enum ApplyError { Unifi(#[from] skillet_unifi::UnifiError), #[error("Syncthing apply error: {0}")] Syncthing(#[from] skillet_syncthing::SyncthingError), + #[error(transparent)] + Mikochi(#[from] skillet_mikochi::MikochiError), #[error("Btrbk apply error: {0}")] Btrbk(#[from] skillet_btrbk::BtrbkError), #[error("Caddy apply error: {0}")] @@ -167,6 +169,7 @@ fn tailscale_config( "HealthStartPeriod=60s".to_string(), format!("Environment=TS_HOSTNAME={hostname}"), "Environment=TS_STATE_DIR=/var/lib/tailscale".to_string(), + "Environment=TS_SOCKET=/var/run/tailscale/tailscaled.sock".to_string(), "Environment=TS_USERSPACE=false".to_string(), ], ), @@ -190,13 +193,25 @@ fn tailscale_config( storage_dependency: Some(skillet_podman::MountDependency::shared_service_data()), process_identity: ProcessIdentity::ImageDefault, namespace_mapping: None, - volumes: vec![Volume { - host_path: state_path.to_string(), - container_path: "/var/lib/tailscale".to_string(), - options: Some("Z".to_string()), - host_mode: None, - host_ownership: None, - }], + volumes: vec![ + Volume { + host_path: state_path.to_string(), + container_path: "/var/lib/tailscale".to_string(), + options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, + }, + Volume { + host_path: "/run/skillet-tailscale".into(), + container_path: "/var/run/tailscale".into(), + options: Some("z".into()), + host_mode: Some(0o700), + host_ownership: Some(skillet_core::files::Ownership::named( + Some("root"), + Some("root"), + )), + }, + ], secrets: vec![QuadletSecret { secret_name: TAILSCALE_AUTH_KEY_CREDENTIAL.to_string(), target: SecretTarget::Environment { @@ -216,6 +231,7 @@ fn apply_syncthing( service: &HostService, ) -> Result<(), ApplyError> { let ServiceConfig::Syncthing { + files_path, data_path, data_owner, data_group, @@ -231,6 +247,7 @@ fn apply_syncthing( system, files, skillet_syncthing::SyncthingConfig { + files_path: files_path.to_string(), data_path: data_path.to_string(), data_owner: data_owner.to_string(), data_group: data_group.to_string(), @@ -312,6 +329,7 @@ fn apply_profile( )?; } ServiceConfig::Syncthing { .. } => apply_syncthing(system, files, profile, service)?, + ServiceConfig::Mikochi { .. } => apply_mikochi(system, files, profile, service)?, ServiceConfig::Unifi => skillet_unifi::apply(system, files)?, ServiceConfig::Tailscale { state_path, @@ -509,3 +527,38 @@ fn apply_caddy_host( skillet_caddy::apply(system, files, &sites, &ui_config.network_name)?; Ok(()) } + +fn apply_mikochi( + system: &dyn SystemResource, + files: &dyn FileResource, + profile: &HostProfile, + service: &HostService, +) -> Result<(), ApplyError> { + let ServiceConfig::Mikochi { + files_path, + data_owner, + data_group, + container_uid, + container_gid, + } = service.config + else { + return Err(ApplyError::FixtureInput( + "Mikochi apply received an incompatible declaration".into(), + )); + }; + let network = skillet_caddy::private_network(profile.id.as_str(), "files"); + skillet_podman::ensure_network(system, files, &network)?; + skillet_mikochi::apply( + system, + files, + &skillet_mikochi::Config { + files_path, + owner: data_owner, + group: data_group, + container_uid, + container_gid, + private_network: &network.unit_name, + }, + )?; + Ok(()) +} diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index cb1c6946..71f46a6e 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -30,6 +30,7 @@ pub struct UiServiceDeclaration { pub name: &'static str, pub upstream: &'static str, pub port: u16, + pub tailnet_identity: bool, pub aliases: &'static [&'static str], } @@ -45,12 +46,20 @@ pub enum ServiceConfig { custom_dns: &'static [(&'static str, &'static str)], }, Syncthing { + files_path: &'static str, data_path: &'static str, data_owner: &'static str, data_group: &'static str, container_uid: u32, container_gid: u32, }, + Mikochi { + files_path: &'static str, + data_owner: &'static str, + data_group: &'static str, + container_uid: u32, + container_gid: u32, + }, Unifi, Smtp, Ddns, @@ -78,6 +87,7 @@ impl HostService { match self.config { ServiceConfig::Pihole { .. } => "pihole", ServiceConfig::Syncthing { .. } => "syncthing", + ServiceConfig::Mikochi { .. } => "mikochi", ServiceConfig::Unifi => "unifi", ServiceConfig::Ddns => "ddns", ServiceConfig::Datadog { .. } => "datadog", @@ -169,6 +179,7 @@ pub enum ListenerProtocol { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum HealthProbe { + Mikochi, Pihole, Tailscale, } @@ -209,6 +220,17 @@ impl HostProfile { .collect::>(); if self.services.iter().any(|service| service.ui.is_some()) { services.push(caddy_acceptance(self.id.as_str())); + if self.ui_services().iter().any(|ui| ui.tailnet_identity) { + services.push(AcceptanceService { + unit: "skillet-tailnet-ui.service".into(), + container: None, + network_mode: None, + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + }); + } } HostAcceptancePlan { requires_data_mount: self.requires_data_mount, @@ -254,6 +276,7 @@ impl HostProfile { name: ui.name.to_string(), upstream: ui.upstream.to_string(), port: ui.port, + tailnet_identity: ui.tailnet_identity, aliases: ui .aliases .iter() @@ -371,32 +394,24 @@ fn acceptance_service(profile: &str, service: &HostService) -> AcceptanceService .into(), health_probe: None, }, - ServiceConfig::Unifi => AcceptanceService { - unit: "unifi.service".into(), - container: Some("unifi".into()), - network_mode: Some("host".into()), - bind_paths: vec!["/var/lib/data/unifi".into()], - owner: Some(AcceptanceOwner::Numeric { uid: 999, gid: 999 }), - listeners: vec![ - AcceptanceListener { - port: 8080, - protocol: ListenerProtocol::Tcp, - }, - AcceptanceListener { - port: 8443, - protocol: ListenerProtocol::Tcp, - }, - AcceptanceListener { - port: 3478, - protocol: ListenerProtocol::Udp, - }, - AcceptanceListener { - port: 10001, - protocol: ListenerProtocol::Udp, - }, - ], - health_probe: None, + ServiceConfig::Mikochi { + files_path, + data_owner, + data_group, + .. + } => AcceptanceService { + unit: "mikochi.service".into(), + container: Some("mikochi".into()), + network_mode: Some(format!("{profile}-files")), + bind_paths: vec![files_path.into()], + owner: Some(AcceptanceOwner::Named { + user: data_owner.into(), + group: data_group.into(), + }), + listeners: Vec::new(), + health_probe: Some(HealthProbe::Mikochi), }, + ServiceConfig::Unifi => unifi_acceptance(), ServiceConfig::Tailscale { state_path, .. } => AcceptanceService { unit: "tailscale.service".into(), container: Some("tailscale".into()), @@ -438,6 +453,7 @@ const SYNCTHING_UI: UiServiceDeclaration = UiServiceDeclaration { name: "syncthing", upstream: "syncthing", port: 8384, + tailnet_identity: false, aliases: &["sync.{host}"], }; @@ -462,6 +478,7 @@ fn clamps() -> HostProfile { name: "pihole", upstream: "pihole", port: 8088, + tailnet_identity: false, aliases: &[], }), }, @@ -474,6 +491,7 @@ fn clamps() -> HostProfile { }, HostService { config: ServiceConfig::Syncthing { + files_path: "/var/lib/data/syncthing/data", data_path: "/var/lib/data/syncthing", data_owner: "giacomo", data_group: "giacomo", @@ -527,6 +545,7 @@ fn joey() -> HostProfile { name: "pihole", upstream: "pihole", port: 8088, + tailnet_identity: false, aliases: &[], }), }, @@ -539,6 +558,7 @@ fn joey() -> HostProfile { }, HostService { config: ServiceConfig::Syncthing { + files_path: "/var/lib/data/syncthing/data", data_path: "/var/lib/data/syncthing", data_owner: "giacomo", data_group: "giacomo", @@ -607,6 +627,42 @@ pub fn declared_profiles() -> Vec { } fn with_fleet_baseline(mut profile: HostProfile) -> HostProfile { + // File browsing is part of the shared Syncthing composition, not a second + // independently maintained per-host inventory. + let file_browsers: Vec<_> = profile + .services + .iter() + .filter_map(|service| { + let ServiceConfig::Syncthing { + files_path, + data_owner, + data_group, + container_uid, + container_gid, + .. + } = service.config + else { + return None; + }; + Some(HostService { + config: ServiceConfig::Mikochi { + files_path, + data_owner, + data_group, + container_uid, + container_gid, + }, + ui: Some(UiServiceDeclaration { + name: "files", + upstream: "mikochi", + port: 8080, + aliases: &[], + tailnet_identity: true, + }), + }) + }) + .collect(); + profile.services.extend(file_browsers); profile.services.push(HostService { config: ServiceConfig::Smtp, ui: None, @@ -637,3 +693,32 @@ fn datadog_acceptance() -> AcceptanceService { health_probe: None, } } + +fn unifi_acceptance() -> AcceptanceService { + AcceptanceService { + unit: "unifi.service".into(), + container: Some("unifi".into()), + network_mode: Some("host".into()), + bind_paths: vec!["/var/lib/data/unifi".into()], + owner: Some(AcceptanceOwner::Numeric { uid: 999, gid: 999 }), + listeners: vec![ + AcceptanceListener { + port: 8080, + protocol: ListenerProtocol::Tcp, + }, + AcceptanceListener { + port: 8443, + protocol: ListenerProtocol::Tcp, + }, + AcceptanceListener { + port: 3478, + protocol: ListenerProtocol::Udp, + }, + AcceptanceListener { + port: 10001, + protocol: ListenerProtocol::Udp, + }, + ], + health_probe: None, + } +} diff --git a/ublue/skillet/crates/mikochi/Cargo.toml b/ublue/skillet/crates/mikochi/Cargo.toml new file mode 100644 index 00000000..47034ac8 --- /dev/null +++ b/ublue/skillet/crates/mikochi/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "skillet_mikochi" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +skillet_datadog.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } +tempfile.workspace = true +reqwest = { workspace = true, features = ["multipart"] } +serde_json.workspace = true diff --git a/ublue/skillet/crates/mikochi/src/lib.rs b/ublue/skillet/crates/mikochi/src/lib.rs new file mode 100644 index 00000000..96e84a50 --- /dev/null +++ b/ublue/skillet/crates/mikochi/src/lib.rs @@ -0,0 +1,121 @@ +//! Private file browsing over a caller-declared user-file scope. +use skillet_core::{ + files::{FileError, FileMutationResource, FileReadResource, Ownership}, + system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, + }, +}; +use skillet_podman::{ + HostUser, MountDependency, NetworkAttachment, PodmanConfig, ProcessIdentity, + UserNamespaceMapping, Volume, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum MikochiError { + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Podman(#[from] skillet_podman::PodmanError), + #[error("Mikochi requires an existing data owner and its primary group")] + Owner, + #[error("Mikochi requires a dedicated file directory below /var/lib/data")] + Scope, +} + +pub struct Config<'a> { + pub files_path: &'a str, + pub owner: &'a str, + pub group: &'a str, + pub container_uid: u32, + pub container_gid: u32, + pub private_network: &'a str, +} + +pub fn apply(system: &S, files: &F, config: &Config<'_>) -> Result<(), MikochiError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let path = Path::new(config.files_path); + if !path.starts_with("/var/lib/data") + || path == Path::new("/var/lib/data") + || path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err(MikochiError::Scope); + } + let user = system + .user_by_name(config.owner)? + .ok_or(MikochiError::Owner)?; + let group = system + .group_by_name(config.group)? + .ok_or(MikochiError::Owner)?; + if user.primary_gid != group.gid { + return Err(MikochiError::Owner); + } + files.ensure_directory( + path, + Some(0o755), + &Ownership::named(Some(config.owner), Some(config.group)), + )?; + skillet_podman::container( + system, + files, + PodmanConfig { + name: "mikochi".into(), + image: "docker.io/zer0tonin/mikochi:1.12.0".into(), + network_attachments: vec![NetworkAttachment::Bridge(config.private_network.into())], + port_publications: Vec::new(), + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::Numeric { + uid: config.container_uid, + gid: config.container_gid, + }, + namespace_mapping: Some(UserNamespaceMapping { + host_user: HostUser::Name(config.owner.into()), + }), + volumes: vec![Volume { + host_path: config.files_path.into(), + container_path: "/data".into(), + options: Some("z".into()), + host_mode: None, + host_ownership: None, + }], + secrets: Vec::new(), + config_revisions: Vec::new(), + extra_config: BTreeMap::from([ + ( + "Container".into(), + vec![ + "ContainerName=mikochi".into(), + "Environment=NO_AUTH=true".into(), + "Environment=HOST=:8080".into(), + "Environment=DATA_DIR=/data".into(), + "Environment=FRONTEND_FILES=/usr/share/mikochi/static".into(), + "DropCapability=ALL".into(), + "NoNewPrivileges=true".into(), + skillet_datadog::process("mikochi", "mikochi"), + ], + ), + ("Service".into(), vec!["Restart=always".into()]), + ( + "Unit".into(), + vec![ + "Description=Mikochi private Syncthing file browser".into(), + "After=syncthing.service".into(), + ], + ), + ]), + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/mikochi/src/tests.rs b/ublue/skillet/crates/mikochi/src/tests.rs new file mode 100644 index 00000000..85daf10d --- /dev/null +++ b/ublue/skillet/crates/mikochi/src/tests.rs @@ -0,0 +1,223 @@ +use super::*; +use skillet_core::{ + system::{GroupIdentity, UserIdentity}, + test_utils::{MockFiles, MockSystem}, +}; +use std::os::unix::fs::MetadataExt as _; + +#[test] +fn private_scope_and_namespace_match_syncthing_without_publishing_a_port() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.user_identities.lock().unwrap().insert( + "owner".into(), + UserIdentity { + name: "owner".into(), + uid: 1042, + primary_gid: 2047, + }, + ); + system.group_identities.lock().unwrap().insert( + "owner".into(), + GroupIdentity { + name: "owner".into(), + gid: 2047, + }, + ); + files + .files + .lock() + .unwrap() + .insert("/etc/subuid".into(), b"owner:100000:65536\n".to_vec()); + files + .files + .lock() + .unwrap() + .insert("/etc/subgid".into(), b"owner:200000:65536\n".to_vec()); + let config = Config { + files_path: "/var/lib/data/syncthing/data", + owner: "owner", + group: "owner", + container_uid: 1000, + container_gid: 1000, + private_network: "example-files", + }; + apply(&system, &files, &config).unwrap(); + let definitions = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&definitions["/etc/containers/systemd/mikochi.container"]); + for directive in [ + "Network=example-files.network", + "Environment=NO_AUTH=true", + "User=1000:1000", + "Volume=/var/lib/data/syncthing/data:/data:z", + ] { + assert!(quadlet.contains(directive), "missing {directive}"); + } + assert!(!quadlet.contains("PublishPort")); + assert!(!quadlet.contains("config:/")); + let initial = quadlet.to_string(); + drop(definitions); + apply(&system, &files, &config).unwrap(); + assert_eq!( + initial.as_bytes(), + files.files.lock().unwrap()["/etc/containers/systemd/mikochi.container"] + ); +} + +struct TrialContainer(String); +impl Drop for TrialContainer { + fn drop(&mut self) { + let _ = std::process::Command::new("podman") + .args(["rm", "--force", &self.0]) + .output(); + } +} + +#[test] +#[ignore = "requires Podman and the Mikochi image"] +fn real_mikochi_has_no_second_login_and_supports_owned_file_operations() { + let temp = tempfile::tempdir().unwrap(); + let data = temp.path().join("data"); + std::fs::create_dir(&data).unwrap(); + let payload = vec![0x61; 3 * 1024 * 1024]; + std::fs::write(data.join("source file.txt"), &payload).unwrap(); + // A synchronized link must not expose a sibling host file outside the mount. + let outside = temp.path().join("private-host-file"); + std::fs::write(&outside, b"outside-host-scope").unwrap(); + std::os::unix::fs::symlink(&outside, data.join("external-link")).unwrap(); + let (_container, client, base) = start_trial(temp.path(), &data); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if client + .get(format!("{base}/ready")) + .send() + .is_ok_and(|response| response.status().is_success()) + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Mikochi did not become ready" + ); + std::thread::sleep(std::time::Duration::from_millis(200)); + } + assert!(client.get(&base).send().unwrap().status().is_success()); + let listed = client.get(format!("{base}/api/browse/")).send().unwrap(); + assert!(listed.status().is_success()); + assert!(listed.text().unwrap().contains("source file.txt")); + let download = client + .get(format!("{base}/api/stream/source%20file.txt")) + .send() + .unwrap(); + assert!(download.status().is_success()); + assert_eq!(download.bytes().unwrap().as_ref(), payload.as_slice()); + let linked = client + .get(format!("{base}/api/stream/external-link")) + .send() + .unwrap(); + assert!(!linked.status().is_success()); + assert!(!linked.text().unwrap().contains("outside-host-scope")); + assert!(client + .put(format!("{base}/api/mkdir/folder")) + .send() + .unwrap() + .status() + .is_success()); + let form = reqwest::blocking::multipart::Form::new().part( + "file", + reqwest::blocking::multipart::Part::bytes(payload.clone()).file_name("uploaded.txt"), + ); + assert!(client + .put(format!("{base}/api/upload/folder/uploaded.txt")) + .multipart(form) + .send() + .unwrap() + .status() + .is_success()); + assert_eq!( + std::fs::read(data.join("folder/uploaded.txt")).unwrap(), + payload + ); + assert!(client + .put(format!("{base}/api/move/folder/uploaded.txt")) + .json(&serde_json::json!({"newPath":"/folder/renamed.txt"})) + .send() + .unwrap() + .status() + .is_success()); + assert!(client + .delete(format!("{base}/api/delete/folder/renamed.txt")) + .send() + .unwrap() + .status() + .is_success()); + assert!(!data.join("folder/renamed.txt").exists()); + // User namespace mapping must preserve workstation ownership on written files. + assert_eq!( + std::fs::metadata(data.join("folder")).unwrap().uid(), + std::fs::metadata(&data).unwrap().uid() + ); +} + +fn start_trial( + temp: &std::path::Path, + data: &std::path::Path, +) -> (TrialContainer, reqwest::blocking::Client, String) { + let name = format!( + "skillet-mikochi-trial-{}-{}", + std::process::id(), + temp.file_name().unwrap().to_string_lossy() + ); + let volume = format!("{}:/data:z", data.display()); + let container = TrialContainer(name); + let run = std::process::Command::new("podman") + .args([ + "run", + "--detach", + "--name", + &container.0, + "--userns", + "keep-id:uid=1000,gid=1000", + "--user", + "1000:1000", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--publish", + "127.0.0.1::8080", + "--volume", + &volume, + "--env", + "HOST=:8080", + "--env", + "NO_AUTH=true", + "docker.io/zer0tonin/mikochi:1.12.0", + ]) + .output() + .unwrap(); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + let port = std::process::Command::new("podman") + .args(["port", &container.0, "8080/tcp"]) + .output() + .unwrap(); + assert!(port.status.success()); + let address: std::net::SocketAddr = String::from_utf8(port.stdout) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(address.ip().is_loopback()); + let base = format!("http://{address}"); + let client = reqwest::blocking::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap(); + (container, client, base) +} diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs index 605c09cb..5ce06998 100644 --- a/ublue/skillet/crates/syncthing/src/lib.rs +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -27,9 +27,12 @@ pub enum SyncthingError { UnknownDataGroup(String), #[error("configured Syncthing data group `{group}` is not the primary group of `{owner}`")] NonPrimaryDataGroup { owner: String, group: String }, + #[error("Syncthing user-file scope must be a separate directory beneath its data root")] + InvalidFileScope, } pub struct SyncthingConfig { + pub files_path: String, pub data_path: String, pub data_owner: String, pub data_group: String, @@ -44,6 +47,7 @@ where F: FileMutationResource + FileReadResource + StorageResource + ?Sized, { info!("Applying Syncthing configuration..."); + let container_home = container_home(&config)?; let user = system .user_by_name(&config.data_owner)? .ok_or_else(|| SyncthingError::UnknownDataOwner(config.data_owner.clone()))?; @@ -57,15 +61,7 @@ where }); } - let data_path = Path::new(&config.data_path); - files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; - files.ensure_btrfs_subvolume(data_path)?; - files.ensure_directory( - data_path, - Some(0o755), - &Ownership::named(Some(&config.data_owner), Some(&config.data_group)), - )?; - + prepare_storage(files, &config)?; let volumes = vec![Volume { host_path: config.data_path, container_path: "/var/syncthing".to_string(), @@ -81,6 +77,7 @@ where vec![ "AutoUpdate=registry".to_string(), "ContainerName=syncthing".to_string(), + format!("Environment=HOME={container_home}"), skillet_datadog::http("syncthing", "http://%%host%%:8384/rest/noauth/health"), ], ); @@ -150,3 +147,39 @@ where #[cfg(test)] #[path = "tests.rs"] mod tests; + +fn container_home(config: &SyncthingConfig) -> Result { + let file_scope = Path::new(&config.files_path) + .strip_prefix(&config.data_path) + .map_err(|_| SyncthingError::InvalidFileScope)?; + if file_scope.as_os_str().is_empty() + || file_scope.starts_with("config") + || file_scope + .components() + .any(|part| !matches!(part, std::path::Component::Normal(_))) + { + return Err(SyncthingError::InvalidFileScope); + } + Ok(format!("/var/syncthing/{}", file_scope.display())) +} + +fn prepare_storage(files: &F, config: &SyncthingConfig) -> Result<(), SyncthingError> +where + F: FileMutationResource + StorageResource + ?Sized, +{ + let data_path = Path::new(&config.data_path); + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + files.ensure_btrfs_subvolume(data_path)?; + files.ensure_directory( + data_path, + Some(0o755), + &Ownership::named(Some(&config.data_owner), Some(&config.data_group)), + )?; + + files.ensure_directory( + Path::new(&config.files_path), + Some(0o755), + &Ownership::named(Some(&config.data_owner), Some(&config.data_group)), + )?; + Ok(()) +} diff --git a/ublue/skillet/crates/syncthing/src/tests.rs b/ublue/skillet/crates/syncthing/src/tests.rs index bfab9c9c..494b7d29 100644 --- a/ublue/skillet/crates/syncthing/src/tests.rs +++ b/ublue/skillet/crates/syncthing/src/tests.rs @@ -37,6 +37,7 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { &system, &files, SyncthingConfig { + files_path: "/var/lib/data/syncthing/data".to_string(), data_path: "/var/lib/data/syncthing".to_string(), data_owner: "giacomo".to_string(), data_group: "giacomo".to_string(), @@ -55,6 +56,7 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { for directive in [ "AutoUpdate=registry", "ContainerName=syncthing", + "Environment=HOME=/var/syncthing/data", "User=1000:1000", "UIDMap=1000:1042:1", "GIDMap=1000:2047:1", @@ -77,6 +79,33 @@ fn syncthing_uses_persistent_data_and_shared_dns_network() { ); } +#[test] +fn user_files_are_separate_from_configuration_and_cannot_escape_the_mount() { + let mut config = SyncthingConfig { + files_path: "/var/lib/data/syncthing/data".into(), + data_path: "/var/lib/data/syncthing".into(), + data_owner: "owner".into(), + data_group: "owner".into(), + container_uid: 1000, + container_gid: 1000, + network_name: "example".into(), + }; + assert_eq!(container_home(&config).unwrap(), "/var/syncthing/data"); + for path in [ + "/var/lib/data/syncthing", + "/var/lib/data/syncthing/config", + "/var/lib/data/syncthing/config/files", + "/var/lib/data/syncthing/../other", + "/var/lib/data/other", + ] { + config.files_path = path.into(); + assert!(matches!( + container_home(&config), + Err(SyncthingError::InvalidFileScope) + )); + } +} + #[test] fn syncthing_requires_declared_host_account_identities() { let system = MockSystem::new(); @@ -87,6 +116,7 @@ fn syncthing_requires_declared_host_account_identities() { &system, &files, SyncthingConfig { + files_path: "/var/lib/data/syncthing/data".to_string(), data_path: "/var/lib/data/syncthing".to_string(), data_owner: "giacomo".to_string(), data_group: "giacomo".to_string(), @@ -129,6 +159,7 @@ fn syncthing_rejects_a_non_primary_host_group_for_namespace_mapping() { &system, &files, SyncthingConfig { + files_path: "/var/lib/data/syncthing/data".to_string(), data_path: "/var/lib/data/syncthing".to_string(), data_owner: "giacomo".to_string(), data_group: "data".to_string(), diff --git a/ublue/skillet/crates/workstation/src/cloudflare/tests.rs b/ublue/skillet/crates/workstation/src/cloudflare/tests.rs index 34a09bb9..d0dfd4c8 100644 --- a/ublue/skillet/crates/workstation/src/cloudflare/tests.rs +++ b/ublue/skillet/crates/workstation/src/cloudflare/tests.rs @@ -11,6 +11,7 @@ fn desired_dns_contains_dual_stack_machine_ui_and_alias_records() { name: "syncthing".to_string(), upstream: "syncthing".to_string(), port: 8384, + tailnet_identity: false, aliases: vec!["sync.{host}".to_string()], }]; let sites = skillet_caddy::CaddySites::from_host( @@ -59,6 +60,7 @@ fn desired_dns_rejects_missing_address_family() { name: "syncthing".to_string(), upstream: "syncthing".to_string(), port: 8384, + tailnet_identity: false, aliases: Vec::new(), }], ) diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json index 27e7602f..70d859ec 100644 --- a/ublue/skillet/crates/workstation/src/secret-requirements.json +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -17,6 +17,7 @@ {"path":"skillet/environments//hosts//cloudflare/ddns-dns-name","status":"required","help":"Choose the relative DNS record name, not a full domain."}, {"path":"skillet/environments/prod/hosts//cloudflare/ddns-token","status":"generated","help":"Skillet creates the persistent scoped token; disposable tokens are lifecycle-owned."} ]}, + {"module":"Mikochi","required_by":["mikochi"],"description":"No application password; uses the shared Tailnet UI allowlist. [Setup](ublue/skillet/README.md#private-file-browsing).","entries":[]}, {"module":"Pi-hole","required_by":["pihole"],"description":"[Delivery](ublue/skillet/README.md#secret-setup-and-delivery).","entries":[ {"path":"skillet/hosts//pihole/web-password","status":"generated","help":"Skillet generates and saves the web admin password for a fresh host; optionally create it to choose a password. For an existing host, restore its entry rather than inventing a replacement."} ]}, @@ -64,6 +65,7 @@ }, {"module":"Storage","required_by":["storage"],"description":"Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet/README.md#root-recovery-keys). Password contains the printable passphrase; custom luks-uuid and recovery-state fields and the protected root-luks.header attachment are managed by Skillet.","entries":[{"path":"skillet/hosts//storage/root-recovery-key","status":"generated","help":"Run skillet secrets root-recovery-key before finishing production provisioning. The command unlocks the vault, saves before enrollment, and verifies the key against this volume.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]},{"path":"skillet/environments/test/hosts//instances//storage/root-recovery-key","status":"generated","help":"Run skillet test vm recovery-key HOST INSTANCE. Archived test keys remain in the vault after VM disposal; delete the instance entry before reusing its name for a new disk.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]}]}, {"module":"Syncthing","required_by":["syncthing"],"description":"No vault entries currently required; Skillet manages the private UI configuration.","entries":[]}, + {"module":"Tailnet UI","required_by":["tailnet-ui"],"description":"Identity authorization for caller-declared private UIs. Uses the existing host-network daemon; no second login. [Setup](ublue/skillet/README.md#private-file-browsing).","entries":[{"path":"skillet/tailscale/ui-users","status":"required","help":"Password = complete Tailscale LoginName values, separated by commas or newlines; Username is unused. Obtain the identities from tailscale status --json on an authenticated client. Both environments share this explicit allowlist.","fields":[{"name":"Password","rule":"tailnetusers"}]}]}, {"module":"Tailscale","required_by":["tailscale"],"description":"Create an OAuth client in Tailscale Trust credentials with Auth Keys Write and Devices Core Read/Write, restricted to the provisioner tag. [Tag and scope guide](ublue/skillet/README.md#tailscale-setup-for-skillet). Skillet mints enrollment keys; do not create per-host auth-key entries.","entries":[ {"path":"skillet/tailscale/provisioner-client-id","status":"required","help":"Copy the OAuth Client ID from the creation page; use Password, not Username."}, {"path":"skillet/tailscale/provisioner-client-secret","status":"required","help":"Copy the OAuth Client secret from the creation page."} diff --git a/ublue/skillet/crates/workstation/src/secrets.rs b/ublue/skillet/crates/workstation/src/secrets.rs index ddf0437b..742d5705 100644 --- a/ublue/skillet/crates/workstation/src/secrets.rs +++ b/ublue/skillet/crates/workstation/src/secrets.rs @@ -84,6 +84,7 @@ enum FieldRule { Port, Choice, Sender, + Tailnetusers, } impl FieldRequirement { @@ -97,6 +98,7 @@ impl FieldRequirement { FieldRule::Choice => self.values.iter().any(|allowed| allowed == value), FieldRule::Hostname => valid_hostname(value), FieldRule::Sender => skillet_smtp::valid_sender(value), + FieldRule::Tailnetusers => skillet_caddy::auth::Policy::parse_users(value).is_ok(), } } } @@ -210,6 +212,8 @@ fn selected(module: &RequirementGroup, profile: &HostProfile) -> bool { module.required_by.iter().any(|service| { if service == "storage" { true + } else if service == "tailnet-ui" { + profile.ui_services().iter().any(|ui| ui.tailnet_identity) } else if service == "ui" { !profile.ui_services().is_empty() } else { diff --git a/ublue/skillet/crates/workstation/src/secrets/tests.rs b/ublue/skillet/crates/workstation/src/secrets/tests.rs index 3b48d81d..dffd09f5 100644 --- a/ublue/skillet/crates/workstation/src/secrets/tests.rs +++ b/ublue/skillet/crates/workstation/src/secrets/tests.rs @@ -30,8 +30,8 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { &|_, _| Ok(None), ) .unwrap(); - assert_eq!(report.checked, 6); - assert_eq!(report.missing.len(), 6); + assert_eq!(report.checked, 7); + assert_eq!(report.missing.len(), 7); assert!(report.missing.iter().any( |entry| entry.path == "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name" )); @@ -58,7 +58,7 @@ fn manual_requirements_follow_profiles_environment_and_template_references() { }, ) .unwrap(); - assert_eq!(report.checked, 7); + assert_eq!(report.checked, 8); assert!(report.missing.is_empty()); } diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs index fb8b3b11..851e0e96 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -197,6 +197,7 @@ pub struct DisposableUiRequest<'a> { pub creator_token: &'a str, pub run_directory: &'a Path, pub addresses: &'a BTreeSet, + pub tailnet_users: &'a [String], } pub struct DisposableUiCredentials { @@ -286,7 +287,7 @@ fn verify_non_tailnet_denial( timeout: Duration, interval: Duration, ) -> Result<(), UiProvisioningError> { - let caddy_address = caddy_container_address(guest)?; + let caddy_address = caddy_container_address(guest, &sites.host)?; let mut verified_names = 0; for site in &sites.services { for hostname in std::iter::once(&site.hostname).chain(&site.aliases) { @@ -320,7 +321,8 @@ fn verify_non_tailnet_denial( } if started.elapsed() >= timeout { return Err(UiProvisioningError::Invalid(format!( - "Caddy did not return its explicit tailnet-denial response for UI name {hostname}" + "Caddy did not return its explicit tailnet-denial response for service {} (curl exit {:?}); inspect caddy.service for TLS/ACME diagnostics", + site.name, output.status.code() ))); } std::thread::sleep(interval); @@ -337,18 +339,13 @@ fn verify_non_tailnet_denial( fn caddy_container_address( guest: &impl GuestTransport, + network: &str, ) -> Result { + let format = format!("{{{{(index .NetworkSettings.Networks \"{network}\").IPAddress}}}}"); let output = guest.execute( &skillet_vm::transport::GuestCommand { program: "/usr/bin/sudo", - arguments: &[ - "-n", - "podman", - "inspect", - "--format", - "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", - "caddy", - ], + arguments: &["-n", "podman", "inspect", "--format", &format, "caddy"], }, None, )?; @@ -373,6 +370,7 @@ pub struct PersistentUiDelivery<'a> { pub zone_id: &'a str, pub relative_ui_domain: Option<&'a str>, pub creator_token: &'a str, + pub tailnet_users: &'a [String], } /// Resolve production or named-host UI configuration, reconcile its DNS, and @@ -395,13 +393,21 @@ pub fn deliver_persistent_ui( .policy .tailscale_tag(crate::provisioning_policy::DeviceClass::ProductionHost); let device = tailnet.find_device_by_hostname(request.host, tag)?; - let plan = build_ui_provisioning_plan( + let mut plan = build_ui_provisioning_plan( request.host, request.policy, &zone.name, request.relative_ui_domain, &device.addresses, )?; + plan.sites.tailnet_users = request.tailnet_users.to_vec(); + skillet_caddy::auth::Policy::new(plan.sites.tailnet_users.clone()).map_err(CaddyError::from)?; + if plan.sites.needs_identity_gate() && plan.sites.tailnet_users.is_empty() { + return Err(UiProvisioningError::Invalid( + "tailnet UI allowlist is required; create skillet/tailscale/ui-users in the vault" + .into(), + )); + } let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?.to_string(); let token_path = format!( "skillet/environments/{}/hosts/{}/cloudflare/acme-token", @@ -593,13 +599,21 @@ pub fn provision_disposable_ui( } cloudflare::validate_zone_id(request.zone_id)?; let zone = provider.zone(request.creator_token, request.zone_id)?; - let plan = build_ui_provisioning_plan( + let mut plan = build_ui_provisioning_plan( request.host, request.policy, &zone.name, request.relative_ui_domain, request.addresses, )?; + plan.sites.tailnet_users = request.tailnet_users.to_vec(); + skillet_caddy::auth::Policy::new(plan.sites.tailnet_users.clone()).map_err(CaddyError::from)?; + if plan.sites.needs_identity_gate() && plan.sites.tailnet_users.is_empty() { + return Err(UiProvisioningError::Invalid( + "tailnet UI allowlist is required; create skillet/tailscale/ui-users in the vault" + .into(), + )); + } let account_id = cloudflare::Cloudflare::account_id(&zone)?.to_string(); let marker = format!( "skillet:{}:{}:{}", diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs index bc75b917..85c3a225 100644 --- a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -275,6 +275,7 @@ fn disposable_request<'a>( policy: ProvisioningPolicy::new(Environment::Test), zone_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", relative_ui_domain: Some("smoke.ui"), + tailnet_users: test_users(), creator_token: "dummy creator token", run_directory, addresses, @@ -299,6 +300,7 @@ fn persistent_request() -> PersistentUiDelivery<'static> { policy: ProvisioningPolicy::new(Environment::Production), zone_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", relative_ui_domain: Some("ui"), + tailnet_users: test_users(), creator_token: "dummy creator token", } } @@ -766,3 +768,13 @@ fn disposable_ui_denial_failure_keeps_old_token_until_guest_acceptance_passes() .is_err()); assert!(provider.revoked.borrow().is_empty()); } + +fn test_users() -> &'static [String] { + static USERS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + vec![ + "alice@example.invalid".into(), + "irene@example.invalid".into(), + ] + }); + &USERS +} From 3e2915199d293c95f3581ceb5db5fdc5310f7715 Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 12 Oct 2026 12:49:21 +0200 Subject: [PATCH 251/252] feat(core): add injectable USB discovery and rule refresh --- ublue/skillet/crates/core/src/lib.rs | 1 + ublue/skillet/crates/core/src/recorder.rs | 14 +++++ ublue/skillet/crates/core/src/resource_op.rs | 4 ++ ublue/skillet/crates/core/src/system.rs | 12 +++- ublue/skillet/crates/core/src/test_utils.rs | 18 ++++++ ublue/skillet/crates/core/src/usb.rs | 64 ++++++++++++++++++++ ublue/skillet/crates/core/src/usb/tests.rs | 15 +++++ 7 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 ublue/skillet/crates/core/src/usb.rs create mode 100644 ublue/skillet/crates/core/src/usb/tests.rs diff --git a/ublue/skillet/crates/core/src/lib.rs b/ublue/skillet/crates/core/src/lib.rs index 020e1554..751f1869 100644 --- a/ublue/skillet/crates/core/src/lib.rs +++ b/ublue/skillet/crates/core/src/lib.rs @@ -8,6 +8,7 @@ pub mod system; pub mod templates; #[cfg(any(test, feature = "test-utils"))] pub mod test_utils; +pub mod usb; #[cfg(test)] #[path = "resource_contract_tests.rs"] diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs index bda2189c..0855d5fb 100644 --- a/ublue/skillet/crates/core/src/recorder.rs +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -259,3 +259,17 @@ impl AccountResource for Recorder { #[cfg(test)] #[path = "recorder_tests.rs"] mod tests; + +impl crate::usb::UsbResource for Recorder { + fn usb_device_present(&self, vendor: u16, product: u16) -> Result { + self.inner.usb_device_present(vendor, product) + } + fn refresh_usb_rules(&self, vendor: u16, product: u16) -> Result<(), SystemError> { + let result = self.inner.refresh_usb_rules(vendor, product); + self.record( + ResourceOp::RefreshUsbRules { vendor, product }, + succeeded(&result), + ); + result + } +} diff --git a/ublue/skillet/crates/core/src/resource_op.rs b/ublue/skillet/crates/core/src/resource_op.rs index 13ef3669..fc74d0e2 100644 --- a/ublue/skillet/crates/core/src/resource_op.rs +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -49,6 +49,10 @@ pub enum ResourceOp { name: String, }, DaemonReload, + RefreshUsbRules { + vendor: u16, + product: u16, + }, } #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs index 2f771780..8647f67a 100644 --- a/ublue/skillet/crates/core/src/system.rs +++ b/ublue/skillet/crates/core/src/system.rs @@ -108,12 +108,20 @@ pub trait ServiceResource { /// Aggregate retained for host composition and adapters while consumers move /// to narrower account, service, and Podman-secret capabilities. pub trait SystemResource: - AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + AccountLookupResource + + AccountResource + + PodmanSecretResource + + ServiceResource + + crate::usb::UsbResource { } impl SystemResource for T where - T: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + T: AccountLookupResource + + AccountResource + + PodmanSecretResource + + ServiceResource + + crate::usb::UsbResource { } diff --git a/ublue/skillet/crates/core/src/test_utils.rs b/ublue/skillet/crates/core/src/test_utils.rs index c732f02b..159dc1f7 100644 --- a/ublue/skillet/crates/core/src/test_utils.rs +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -10,6 +10,8 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; pub struct MockSystem { + pub usb_devices: Arc>>, + pub usb_refresh_count: Arc, pub groups: Arc>>, pub users: Arc>>, pub user_identities: Arc>>, @@ -27,6 +29,8 @@ pub struct MockSystem { impl MockSystem { pub fn new() -> Self { Self { + usb_devices: Arc::new(Mutex::new(HashSet::new())), + usb_refresh_count: Arc::new(AtomicUsize::new(0)), groups: Arc::new(Mutex::new(HashSet::new())), users: Arc::new(Mutex::new(HashSet::new())), user_identities: Arc::new(Mutex::new(HashMap::new())), @@ -574,3 +578,17 @@ impl FileMutationResource for MockFiles { Ok(f_removed || m_removed) } } + +impl crate::usb::UsbResource for MockSystem { + fn usb_device_present(&self, vendor: u16, product: u16) -> Result { + Ok(self + .usb_devices + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&(vendor, product))) + } + fn refresh_usb_rules(&self, _vendor: u16, _product: u16) -> Result<(), SystemError> { + self.usb_refresh_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} diff --git a/ublue/skillet/crates/core/src/usb.rs b/ublue/skillet/crates/core/src/usb.rs new file mode 100644 index 00000000..bafdee90 --- /dev/null +++ b/ublue/skillet/crates/core/src/usb.rs @@ -0,0 +1,64 @@ +//! USB observations and rule refresh owned by the concrete host adapter. +use crate::system::{LinuxSystemResource, SystemError}; +use std::{fs, io, path::Path, process::Command}; + +pub trait UsbResource { + fn usb_device_present(&self, vendor: u16, product: u16) -> Result; + fn refresh_usb_rules(&self, vendor: u16, product: u16) -> Result<(), SystemError>; +} + +impl UsbResource for LinuxSystemResource { + fn usb_device_present(&self, vendor: u16, product: u16) -> Result { + device_present(Path::new("/sys/bus/usb/devices"), vendor, product) + } + fn refresh_usb_rules(&self, vendor: u16, product: u16) -> Result<(), SystemError> { + for args in [ + vec![ + "control".into(), + "--timeout=10".into(), + "--reload-rules".into(), + ], + vec![ + "trigger".into(), + "--action=add".into(), + "--subsystem-match=usb".into(), + format!("--attr-match=idVendor={vendor:04x}"), + format!("--attr-match=idProduct={product:04x}"), + ], + vec!["settle".into(), "--timeout=10".into()], + ] { + if !Command::new("udevadm").args(args).status()?.success() { + return Err(SystemError::Command("USB rule refresh failed".into())); + } + } + Ok(()) + } +} + +fn device_present(root: &Path, vendor: u16, product: u16) -> Result { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + for entry in entries { + let entry = entry?; + let read_id = |name: &str| -> Result, SystemError> { + match fs::read_to_string(entry.path().join(name)) { + Ok(value) => u16::from_str_radix(value.trim(), 16) + .map(Some) + .map_err(|_| SystemError::Command("invalid USB identifier in sysfs".into())), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } + }; + if read_id("idVendor")? == Some(vendor) && read_id("idProduct")? == Some(product) { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(test)] +#[path = "usb/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/usb/tests.rs b/ublue/skillet/crates/core/src/usb/tests.rs new file mode 100644 index 00000000..94a7d6b1 --- /dev/null +++ b/ublue/skillet/crates/core/src/usb/tests.rs @@ -0,0 +1,15 @@ +use super::*; +#[test] +fn sysfs_device_lookup_is_exact_and_handles_absent_devices() { + let root = tempfile::tempdir().unwrap(); + let device = root.path().join("2-1"); + fs::create_dir(&device).unwrap(); + fs::write(device.join("idVendor"), "0764\n").unwrap(); + fs::write(device.join("idProduct"), "0501\n").unwrap(); + fs::create_dir(root.path().join("2-1:1.0")).unwrap(); + assert!(device_present(root.path(), 0x0764, 0x0501).unwrap()); + assert!(!device_present(root.path(), 0x0764, 0x0502).unwrap()); + assert!(!device_present(&root.path().join("absent"), 0x0764, 0x0501).unwrap()); + fs::write(device.join("idProduct"), "invalid").unwrap(); + assert!(device_present(root.path(), 0x0764, 0x0501).is_err()); +} From 5875f93ff939cf9178df8e636a63247cf8a9780f Mon Sep 17 00:00:00 2001 From: Giacomo Bagnoli Date: Mon, 12 Oct 2026 12:49:23 +0200 Subject: [PATCH 252/252] feat(skillet): configure optional local UPS protection for clamps --- .github/workflows/ci.yml | 7 + SECRETS.md | 4 + ublue/AGENTS.md | 5 + ublue/butane/ACCEPTANCE.md | 29 ++ ublue/design/ups.md | 33 +++ ublue/skillet/Cargo.lock | 13 + ublue/skillet/Cargo.toml | 2 + ublue/skillet/README.md | 24 ++ ublue/skillet/crates/host-profiles/Cargo.toml | 1 + .../crates/host-profiles/src/hosts_tests.rs | 35 +++ ublue/skillet/crates/host-profiles/src/lib.rs | 12 + .../crates/host-profiles/src/profile.rs | 22 +- ublue/skillet/crates/nut/Cargo.toml | 18 ++ ublue/skillet/crates/nut/src/lib.rs | 216 ++++++++++++++ ublue/skillet/crates/nut/src/tests.rs | 278 ++++++++++++++++++ ublue/skillet/crates/nut/tests/Containerfile | 2 + .../workstation/src/secret-requirements.json | 1 + 17 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 ublue/design/ups.md create mode 100644 ublue/skillet/crates/nut/Cargo.toml create mode 100644 ublue/skillet/crates/nut/src/lib.rs create mode 100644 ublue/skillet/crates/nut/src/tests.rs create mode 100644 ublue/skillet/crates/nut/tests/Containerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7891fdf8..02a8c148 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,13 @@ jobs: cd ublue/skillet cargo test -p skillet_mikochi real_mikochi_has_no_second_login_and_supports_owned_file_operations -- --ignored + - name: Test native UPS configuration without hardware + if: steps.filter.outputs.rust == 'true' + run: | + cd ublue/skillet + podman build -t localhost/skillet-nut-runtime crates/nut/tests + cargo test -p skillet_nut real_fedora_nut_accepts_config_and_monitors_a_safe_dummy_ups -- --ignored + - name: Run installation and bootstrap regressions if: steps.filter.outputs.rust == 'true' run: | diff --git a/SECRETS.md b/SECRETS.md index ec537e29..488d7d75 100644 --- a/SECRETS.md +++ b/SECRETS.md @@ -57,6 +57,10 @@ Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skil No application password; uses the shared Tailnet UI allowlist. [Setup](ublue/skillet/README.md#private-file-browsing). +## NUT + +No vault entry required. Skillet generates a local-only monitoring password on each opted-in host; no operator login. [UPS setup](ublue/skillet/README.md#local-ups-protection). + ## Pi-hole [Delivery](ublue/skillet/README.md#secret-setup-and-delivery). diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md index 43d4f367..bba39686 100644 --- a/ublue/AGENTS.md +++ b/ublue/AGENTS.md @@ -74,6 +74,11 @@ must preserve later operator password changes; routine apply must not reopen temporary privileged access or repeat password expiry. +- Hardware-dependent services use an injected observation boundary. Converge + configuration without absent-device startup failures, order startup after + device discovery, and preserve existing outage handling when hardware vanishes. + Validate simulated shutdown paths only in isolated disposable state. + - Optional services require explicit selection in the canonical host caller declaration. Shared baseline services retain their common defaults; the presence of environment configuration or credentials must not enable an diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md index dd06c41f..dc4968c4 100644 --- a/ublue/butane/ACCEPTANCE.md +++ b/ublue/butane/ACCEPTANCE.md @@ -1,3 +1,32 @@ +## Local NUT UPS protection — 2026-10-12 + +- The shared image now declares Fedora `nut` and `nut-client`, with NUT services + and enumeration disabled by default. Skillet's optional native module uses + `/etc/ups`. Only clamps currently selects it: CyberPower CP1500 AVR, + `usbhid-ups`, USB `0764:0501`, `MODE=standalone` and loopback-only port 3493. +- The root-only retained monitor password is generated locally; root:nut + configuration grants only `upsmon primary`. The packaged monitor uses normal + critical-battery handling and `/run/nut/killpower`. Driver/server/monitor + conditions and scoped udev activation tolerate an absent UPS in ordinary VMs. + An existing monitor is not stopped when the USB cable vanishes. +- Local musl-target workspace tests, formatting and strict all-target Clippy + passed. The Fedora joey base integration applied twice without start/restart + on repeat. Image recipe YAML and both repository whitespace checks passed. + Pure regressions cover exact USB IDs, no-device convergence, retained secrets, + repeat apply, interrupted activation and rejected declaration/secret inputs. +- The routine CI consumer check passed against Fedora NUT 2.8.5 in a disposable + rootless container. It verified the generated udev rule and systemd drop-ins, + packaged driver-instance lookup and a non-executing USB driver start plan. + A dummy UPS then answered `OL` over IPv4 and IPv6 loopback; the packaged + monitor started and accepted reload. The consumer used a harmless shutdown + command and no USB passthrough; no real host/UPS shutdown was attempted. +- Full BlueBuild image publication, a boot/rebase check of the rebuilt image, + real USB permissions/polling, battery shutdown, UPS load cycling and firmware + power-return startup remain **unverified**. BlueBuild CLI is unavailable + locally; the current published smoke image does not contain these new packages. + Consumer unit verification is not signed-uCore boot or hardware acceptance. + See [the UPS design](../design/ups.md). + ## Mikochi and shared tailnet identity — 2026-10-11 - Shared Syncthing composition selects Mikochi on clamps and joey. It serves diff --git a/ublue/design/ups.md b/ublue/design/ups.md new file mode 100644 index 00000000..dc4c6f77 --- /dev/null +++ b/ublue/design/ups.md @@ -0,0 +1,33 @@ +# Local UPS protection + +Decision, 2026-10-12: install Fedora `nut` and `nut-client` in the shared +uCore image, with their services/enumerator disabled by default. An optional +shared Skillet module owns `/etc/ups`, USB selection and native service +activation. Clamps selects the CyberPower CP1500 AVR (`0764:0501`) with +`usbhid-ups`; other host callers must explicitly opt in. + +Use `MODE=standalone`, with `upsd` listening only on IPv4/IPv6 loopback. +A generated, retained host-local monitor password grants only `upsmon primary`. +It needs no vault entry or operator login; root-only state and root:nut +configuration retain it through rebases and interrupted apply. Never silently +replace malformed retained credentials. This is local service authentication, +not a portable operator credential. + +Use the packaged driver/server/monitor and late shutdown hook. `upsmon` powers +off the host on critical battery state, not on every brief outage. Its power-down +flag lives under `/run/nut` so an old flag cannot persist across reboots. Actual +UPS turn-off delays and firmware restart-after-power-loss need hardware acceptance. + +The generic USB resource inspects sysfs and refreshes scoped udev rules. +A matching USB device gets a stable alias and starts the monitor dependency +chain on hotplug. Unit conditions skip absent hardware; configuration still +converges on ordinary smoke VMs. Losing the cable must not stop an already +running monitor: its last known on-battery state can require protective shutdown. +Datadog unit monitoring is selected only when the UPS is attached at agent apply. +Removing the declaration leaves existing resources unmanaged; explicit removal +is required to disable previously configured protection. + +Validate configuration with real Fedora NUT tools and a simulated driver whose +shutdown command is harmless. USB polling, real battery shutdown and UPS load +cycling remain physical acceptance; never invoke forced shutdown during routine +smoke tests. See [acceptance](../butane/ACCEPTANCE.md). diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock index 2d25afe9..f44ce751 100644 --- a/ublue/skillet/Cargo.lock +++ b/ublue/skillet/Cargo.lock @@ -2480,6 +2480,7 @@ dependencies = [ "skillet_ddns", "skillet_hardening", "skillet_mikochi", + "skillet_nut", "skillet_pihole", "skillet_podman", "skillet_smtp", @@ -2501,6 +2502,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "skillet_nut" +version = "0.1.0" +dependencies = [ + "sha2 0.10.9", + "skillet_core", + "tempfile", + "thiserror 1.0.69", + "tracing", + "uuid", +] + [[package]] name = "skillet_pihole" version = "0.1.0" diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml index 27bb3df3..f6dfa480 100644 --- a/ublue/skillet/Cargo.toml +++ b/ublue/skillet/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/unifi", "crates/syncthing", "crates/mikochi", + "crates/nut", "crates/btrbk", "crates/caddy", "crates/ddns", @@ -31,6 +32,7 @@ skillet_core = { path = "crates/core" } skillet_hardening = { path = "crates/hardening" } skillet_pihole = { path = "crates/pihole" } skillet_unifi = { path = "crates/unifi" } +skillet_nut = { path = "crates/nut" } skillet_mikochi = { path = "crates/mikochi" } skillet_syncthing = { path = "crates/syncthing" } skillet_btrbk = { path = "crates/btrbk" } diff --git a/ublue/skillet/README.md b/ublue/skillet/README.md index 96d3ab6f..da3baeda 100644 --- a/ublue/skillet/README.md +++ b/ublue/skillet/README.md @@ -745,3 +745,27 @@ builds a private USB-ready ISO without writing a block device. Follow [the hardware runbook](../butane/HARDWARE.md) for inputs, USB writing, live checks, MOK enrollment and cutover. Installation requires confirmation on the server; TPM prerequisites are checked there. The VM `/dev/vda` path remains supported. + +### Local UPS protection + +Clamps opts into the shared NUT module for the CyberPower CP1500 AVR USB UPS +(`0764:0501`). Rebase onto the rebuilt image containing `nut` and `nut-client`, +then run the usual full Skillet apply. Configuration is in `/etc/ups`, with +`MODE=standalone`; the server listens only on `127.0.0.1:3493` and `[::1]:3493`. + +The local monitor credential is generated automatically and retained under +`/var/lib/skillet/nut`; do not create a KeePass entry. Its configuration files +are root:nut readable. The packaged driver/server/monitor start when the +matching USB device is attached. Ordinary VMs without that device only converge +configuration. Other host callers can select `ServiceConfig::Nut` with their +own device name, USB IDs and description. + +Read status locally with `upsc cyberpower@localhost`. Inspect +`nut-driver@cyberpower.service`, `nut-server.service` and `nut-monitor.service` +with `systemctl status` or `journalctl -u`. After attaching hardware, rerun +Datadog apply to include its native units in monitoring. + +Before relying on protection, verify battery status and arrange a supervised +power-loss/shutdown/restart test on the physical machine. Routine smoke tests +must not run `upsmon -c fsd` or command the real UPS to turn off. +See [the UPS design](../design/ups.md). diff --git a/ublue/skillet/crates/host-profiles/Cargo.toml b/ublue/skillet/crates/host-profiles/Cargo.toml index befaaa29..08612791 100644 --- a/ublue/skillet/crates/host-profiles/Cargo.toml +++ b/ublue/skillet/crates/host-profiles/Cargo.toml @@ -15,6 +15,7 @@ skillet_pihole.workspace = true skillet_unifi.workspace = true skillet_syncthing.workspace = true skillet_mikochi.workspace = true +skillet_nut.workspace = true skillet_btrbk.workspace = true skillet_caddy.workspace = true skillet_ddns.workspace = true diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs index 47a651d7..0dbaa8fd 100644 --- a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -109,6 +109,7 @@ fn profile_capabilities_are_the_authority_for_services_credentials_network_and_s "pihole", "tailscale", "syncthing", + "nut", "unifi", "ddns", "datadog", @@ -661,3 +662,37 @@ fn every_syncthing_caller_has_matching_private_file_browsing() { } } } + +#[test] +fn ups_opt_in_is_hardware_scoped_without_vm_or_ui_requirements() { + let clamps = super::profile_for_name("clamps").unwrap(); + assert!(clamps.supports_service("nut")); + assert!(!super::profile_for_name("joey") + .unwrap() + .supports_service("nut")); + let config = clamps + .services + .iter() + .find_map(|service| { + if let ServiceConfig::Nut(config) = service.config { + Some(config) + } else { + None + } + }) + .unwrap(); + assert_eq!( + (config.name, config.vendor, config.product), + ("cyberpower", 0x0764, 0x0501) + ); + assert!(!clamps.ui_services().iter().any(|ui| ui.name == "nut")); + assert!(!clamps + .acceptance_plan() + .services + .iter() + .any(|service| service.unit.starts_with("nut-"))); + assert!(!clamps + .credential_consumers() + .iter() + .any(|consumer| consumer.unit.starts_with("nut-"))); +} diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs index 5df3983c..508c0f06 100644 --- a/ublue/skillet/crates/host-profiles/src/lib.rs +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -90,6 +90,8 @@ pub enum ApplyError { #[error("Syncthing apply error: {0}")] Syncthing(#[from] skillet_syncthing::SyncthingError), #[error(transparent)] + Nut(#[from] skillet_nut::NutError), + #[error(transparent)] Mikochi(#[from] skillet_mikochi::MikochiError), #[error("Btrbk apply error: {0}")] Btrbk(#[from] skillet_btrbk::BtrbkError), @@ -330,6 +332,9 @@ fn apply_profile( } ServiceConfig::Syncthing { .. } => apply_syncthing(system, files, profile, service)?, ServiceConfig::Mikochi { .. } => apply_mikochi(system, files, profile, service)?, + ServiceConfig::Nut(config) => { + skillet_nut::apply(system, files, *config)?; + } ServiceConfig::Unifi => skillet_unifi::apply(system, files)?, ServiceConfig::Tailscale { state_path, @@ -476,6 +481,13 @@ fn apply_datadog_host( .map(|unit| (*unit).to_string()), ); } + for service in &profile.services { + if let ServiceConfig::Nut(config) = service.config { + if system.usb_device_present(config.vendor, config.product)? { + monitored_units.extend(skillet_nut::monitored_units(config)); + } + } + } skillet_datadog::apply( system, files, diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs index 71f46a6e..f30a8f39 100644 --- a/ublue/skillet/crates/host-profiles/src/profile.rs +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -60,6 +60,7 @@ pub enum ServiceConfig { container_uid: u32, container_gid: u32, }, + Nut(skillet_nut::Config), Unifi, Smtp, Ddns, @@ -88,6 +89,7 @@ impl HostService { ServiceConfig::Pihole { .. } => "pihole", ServiceConfig::Syncthing { .. } => "syncthing", ServiceConfig::Mikochi { .. } => "mikochi", + ServiceConfig::Nut(_) => "nut", ServiceConfig::Unifi => "unifi", ServiceConfig::Ddns => "ddns", ServiceConfig::Datadog { .. } => "datadog", @@ -213,7 +215,7 @@ impl HostProfile { .filter(|service| { !matches!( service.config, - ServiceConfig::Datadog { .. } | ServiceConfig::Smtp + ServiceConfig::Datadog { .. } | ServiceConfig::Smtp | ServiceConfig::Nut(_) ) && (with_ddns || !matches!(service.config, ServiceConfig::Ddns)) }) .map(|service| acceptance_service(self.id.as_str(), service)) @@ -343,6 +345,15 @@ impl HostProfile { fn acceptance_service(profile: &str, service: &HostService) -> AcceptanceService { match service.config { + ServiceConfig::Nut(_) => AcceptanceService { + unit: "nut-monitor.service".into(), + container: None, + network_mode: None, + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + }, ServiceConfig::Smtp => smtp_acceptance(), ServiceConfig::Datadog { .. } => datadog_acceptance(), ServiceConfig::Ddns => AcceptanceService { @@ -500,6 +511,15 @@ fn clamps() -> HostProfile { }, ui: Some(SYNCTHING_UI), }, + HostService { + config: ServiceConfig::Nut(skillet_nut::Config { + name: "cyberpower", + vendor: 0x0764, + product: 0x0501, + description: "CyberPower CP1500 AVR", + }), + ui: None, + }, HostService { config: ServiceConfig::Unifi, ui: None, diff --git a/ublue/skillet/crates/nut/Cargo.toml b/ublue/skillet/crates/nut/Cargo.toml new file mode 100644 index 00000000..17c933ba --- /dev/null +++ b/ublue/skillet/crates/nut/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "skillet_nut" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +thiserror.workspace = true +sha2.workspace = true +uuid.workspace = true +tracing.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } +tempfile.workspace = true diff --git a/ublue/skillet/crates/nut/src/lib.rs b/ublue/skillet/crates/nut/src/lib.rs new file mode 100644 index 00000000..ff4ba785 --- /dev/null +++ b/ublue/skillet/crates/nut/src/lib.rs @@ -0,0 +1,216 @@ +//! Optional native, loopback-only USB UPS protection. +use sha2::{Digest as _, Sha256}; +use skillet_core::{ + activation::{activate, ActivationRequest, ConsumerKind}, + files::{FileError, FileMutationResource, FileReadResource, Ownership}, + system::{ServiceResource, SystemError}, + usb::UsbResource, +}; +use std::path::Path; +use thiserror::Error; + +pub const MONITORED_UNITS: &[&str] = &["nut-server.service", "nut-monitor.service"]; +const STATE: &str = "/var/lib/skillet/nut"; +const PASSWORD: &str = "/var/lib/skillet/nut/monitor-password"; + +pub fn monitored_units(config: Config) -> Vec { + MONITORED_UNITS + .iter() + .map(|unit| (*unit).to_string()) + .chain(std::iter::once(format!( + "nut-driver@{}.service", + config.name + ))) + .collect() +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Config { + pub name: &'static str, + pub vendor: u16, + pub product: u16, + pub description: &'static str, +} + +#[derive(Debug, Error)] +pub enum NutError { + #[error("invalid UPS declaration")] + InvalidConfig, + #[error("invalid retained NUT monitor credential; refusing replacement")] + InvalidCredential, + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Activation(#[from] skillet_core::activation::ActivationError), +} + +#[derive(Debug, Eq, PartialEq)] +pub enum Outcome { + ConfiguredWithoutDevice, + Active, +} + +impl Config { + fn validate(self) -> Result<(), NutError> { + if self + .name + .as_bytes() + .first() + .is_none_or(|b| !b.is_ascii_alphanumeric()) + || self.name.len() > 40 + || !self + .name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + || self.description.is_empty() + || !self + .description + .bytes() + .all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\' || b == b' ') + { + return Err(NutError::InvalidConfig); + } + Ok(()) + } + pub fn ups_config(self) -> Result { + self.validate()?; + Ok(format!("[{}]\n driver = usbhid-ups\n port = auto\n vendorid = {:04x}\n productid = {:04x}\n desc = \"{}\"\n", self.name, self.vendor, self.product, self.description)) + } +} + +pub fn apply(system: &S, files: &F, config: Config) -> Result +where + S: ServiceResource + UsbResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + config.validate()?; + let root = Ownership::named(Some("root"), Some("root")); + let nut = Ownership::named(Some("root"), Some("nut")); + files.ensure_directory(Path::new(STATE), Some(0o700), &root)?; + let password = monitor_password(files)?; + files.ensure_directory(Path::new("/etc/ups"), Some(0o750), &nut)?; + let ups = config.ups_config()?; + let server = "LISTEN 127.0.0.1 3493\nLISTEN ::1 3493\n"; + let users = format!("[skillet-monitor]\n password = {password}\n upsmon primary\n"); + let monitor = format!("MONITOR {}@127.0.0.1 1 skillet-monitor {password} primary\nMINSUPPLIES 1\nSHUTDOWNCMD \"/usr/bin/systemctl poweroff\"\nPOWERDOWNFLAG /run/nut/killpower\nRUN_AS_USER nut\nPOLLFREQ 5\nPOLLFREQALERT 5\nDEADTIME 15\nFINALDELAY 5\nNOTIFYFLAG ONLINE SYSLOG+WALL\nNOTIFYFLAG ONBATT SYSLOG+WALL\nNOTIFYFLAG LOWBATT SYSLOG+WALL\nNOTIFYFLAG COMMBAD SYSLOG\nNOTIFYFLAG COMMOK SYSLOG\nNOTIFYFLAG SHUTDOWN SYSLOG+WALL\n", config.name); + let mut changed = false; + for (name, content) in [ + ("nut.conf", "MODE=standalone\n"), + ("ups.conf", &ups), + ("upsd.conf", server), + ("upsd.users", &users), + ("upsmon.conf", &monitor), + ] { + changed |= files.ensure_file( + &Path::new("/etc/ups").join(name), + content.as_bytes(), + Some(0o640), + &nut, + )?; + } + let driver = format!("nut-driver@{}.service", config.name); + let units = unit_dropins(config, &driver); + for (unit, dropin) in &units { + let dir = Path::new("/etc/systemd/system").join(format!("{unit}.d")); + files.ensure_directory(&dir, Some(0o755), &root)?; + changed |= files.ensure_file( + &dir.join("skillet.conf"), + dropin.as_bytes(), + Some(0o644), + &root, + )?; + } + files.ensure_directory(Path::new("/etc/udev/rules.d"), Some(0o755), &root)?; + let rule = format!("SUBSYSTEM==\"usb\", ENV{{DEVTYPE}}==\"usb_device\", ATTR{{idVendor}}==\"{:04x}\", ATTR{{idProduct}}==\"{:04x}\", SYMLINK+=\"skillet-ups-{}\", TAG+=\"systemd\", ENV{{SYSTEMD_WANTS}}+=\"nut-monitor.service\"\n", config.vendor, config.product, config.name); + let rule_changed = files.ensure_file( + Path::new("/etc/udev/rules.d/70-skillet-ups.rules"), + rule.as_bytes(), + Some(0o644), + &root, + )?; + changed |= rule_changed; + if changed { + system.daemon_reload()?; + } + for unit in [ + &driver, + "nut-driver.target", + "nut-server.service", + "nut-monitor.service", + "nut.target", + ] { + system.service_enable(unit)?; + } + // Keep refresh pending until it succeeds, even if a previous apply wrote the rule. + let rule_revision = Sha256::digest(rule.as_bytes()).to_vec(); + let rule_state = Path::new(STATE).join("udev.applied"); + if files.read_file(&rule_state)?.as_deref() != Some(rule_revision.as_slice()) || rule_changed { + system.refresh_usb_rules(config.vendor, config.product)?; + files.ensure_file(&rule_state, &rule_revision, Some(0o600), &root)?; + } + if !system.usb_device_present(config.vendor, config.product)? { + // Never stop an existing monitor after unplug: its last on-battery state + // may require a protective shutdown on loss of communication. + tracing::info!( + ups = config.name, + "NUT configuration converged; matching USB UPS is not attached" + ); + return Ok(Outcome::ConfiguredWithoutDevice); + } + let mut hash = Sha256::new(); + for value in [&ups, server, &users, &monitor, &rule] { + hash.update(value); + } + for (unit, content) in &units { + hash.update(unit); + hash.update(content); + } + let revision = hash.finalize().to_vec(); + for unit in [&driver, "nut-server.service", "nut-monitor.service"] { + activate( + system, + files, + &ActivationRequest { + service: unit.to_string(), + state_path: Path::new(STATE).join(format!("{unit}.applied")), + revision: revision.clone(), + definition_changed: changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + } + Ok(Outcome::Active) +} + +fn monitor_password( + files: &F, +) -> Result { + let root = Ownership::named(Some("root"), Some("root")); + let password = match files.read_file(Path::new(PASSWORD))? { + Some(bytes) => String::from_utf8(bytes).map_err(|_| NutError::InvalidCredential)?, + None => uuid::Uuid::new_v4().simple().to_string(), + }; + if password.len() != 32 || !password.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(NutError::InvalidCredential); + } + // Persist first; interrupted configuration resumes with the same credential. + files.ensure_file(Path::new(PASSWORD), password.as_bytes(), Some(0o600), &root)?; + Ok(password) +} + +fn unit_dropins(config: Config, driver: &str) -> Vec<(String, String)> { + let condition = format!("ConditionPathExists=/dev/skillet-ups-{}\n", config.name); + [(driver, "Wants=nut-udev-settle.service\nAfter=nut-udev-settle.service\n".into()), + ("nut-server.service", format!("Requires={driver}\nAfter={driver}\n")), + ("nut-monitor.service", "Requires=nut-server.service\nAfter=nut-server.service\n".into())] + .into_iter().map(|(unit, dependencies)| (unit.to_string(), + format!("[Unit]\n{condition}{dependencies}\n[Service]\nExecStartPre=/usr/sbin/restorecon -RF /etc/ups\n"))) + .collect() +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/nut/src/tests.rs b/ublue/skillet/crates/nut/src/tests.rs new file mode 100644 index 00000000..d105179d --- /dev/null +++ b/ublue/skillet/crates/nut/src/tests.rs @@ -0,0 +1,278 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; +const CONFIG: Config = Config { + name: "cyberpower", + vendor: 0x0764, + product: 0x0501, + description: "CyberPower CP1500 AVR", +}; + +#[test] +fn absent_hardware_converges_without_starting_and_preserves_password() { + let files = MockFiles::new(); + let system = MockSystem::new(); + assert_eq!( + apply(&system, &files, CONFIG).unwrap(), + Outcome::ConfiguredWithoutDevice + ); + let before = files.read_file(Path::new(PASSWORD)).unwrap().unwrap(); + let all = files.files.lock().unwrap(); + assert_eq!(all["/etc/ups/nut.conf"], b"MODE=standalone\n"); + assert_eq!( + all["/etc/ups/upsd.conf"], + b"LISTEN 127.0.0.1 3493\nLISTEN ::1 3493\n" + ); + let driver = String::from_utf8(all["/etc/ups/ups.conf"].clone()).unwrap(); + assert!(driver.contains("vendorid = 0764")); + assert!(driver.contains("productid = 0501")); + let monitor = String::from_utf8(all["/etc/ups/upsmon.conf"].clone()).unwrap(); + assert!(monitor.contains(" primary\n")); + assert!(monitor.contains("POWERDOWNFLAG /run/nut/killpower")); + let meta = files.metadata.lock().unwrap(); + assert_eq!(meta["/etc/ups/upsd.users"].0, Some(0o640)); + assert_eq!(meta[PASSWORD].0, Some(0o600)); + drop(meta); + drop(all); + apply(&system, &files, CONFIG).unwrap(); + assert_eq!( + files.read_file(Path::new(PASSWORD)).unwrap().unwrap(), + before + ); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 0); + assert_eq!(system.start_count.load(Ordering::SeqCst), 0); + assert_eq!(system.usb_refresh_count.load(Ordering::SeqCst), 1); +} +#[test] +fn attached_hardware_activates_and_repeat_apply_does_not_restart() { + let files = MockFiles::new(); + let system = MockSystem::new(); + system + .usb_devices + .lock() + .unwrap() + .insert((CONFIG.vendor, CONFIG.product)); + assert_eq!(apply(&system, &files, CONFIG).unwrap(), Outcome::Active); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 3); + apply(&system, &files, CONFIG).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 3); + assert_eq!(system.start_count.load(Ordering::SeqCst), 0); + system.usb_devices.lock().unwrap().clear(); + apply(&system, &files, CONFIG).unwrap(); + assert!(system.service_is_active("nut-monitor.service").unwrap()); +} +#[test] +fn interrupted_activation_retries_without_replacing_credentials() { + let files = MockFiles::new(); + let system = MockSystem::new(); + system + .usb_devices + .lock() + .unwrap() + .insert((CONFIG.vendor, CONFIG.product)); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(apply(&system, &files, CONFIG).is_err()); + let before = files.read_file(Path::new(PASSWORD)).unwrap().unwrap(); + assert_eq!(apply(&system, &files, CONFIG).unwrap(), Outcome::Active); + assert_eq!( + files.read_file(Path::new(PASSWORD)).unwrap().unwrap(), + before + ); + files + .ensure_file( + Path::new(PASSWORD), + b"malformed", + Some(0o600), + &Ownership::default(), + ) + .unwrap(); + assert!(matches!( + apply(&system, &files, CONFIG), + Err(NutError::InvalidCredential) + )); + assert_eq!( + files.read_file(Path::new(PASSWORD)).unwrap().unwrap(), + b"malformed" + ); +} +#[test] +fn caller_inputs_cannot_inject_units_or_configuration() { + for config in [ + Config { + name: "-ups", + ..CONFIG + }, + Config { + name: "ups\n[evil]", + ..CONFIG + }, + Config { + description: "bad\"\nsetting", + ..CONFIG + }, + ] { + assert!(config.ups_config().is_err()); + let files = MockFiles::new(); + let system = MockSystem::new(); + assert!(apply(&system, &files, config).is_err()); + assert!(files.files.lock().unwrap().is_empty()); + } +} + +use std::process::Command; +struct Container(String); +impl Drop for Container { + fn drop(&mut self) { + let _ = Command::new("podman").args(["rm", "-f", &self.0]).output(); + } +} +fn command(args: &[&str]) -> Vec { + let result = Command::new("podman").args(args).output().unwrap(); + assert!( + result.status.success(), + "podman operation failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + result.stdout +} + +fn write_consumer_configuration(root: &Path) { + let files = MockFiles::new(); + let system = MockSystem::new(); + apply(&system, &files, CONFIG).unwrap(); + for (path, bytes) in files.files.lock().unwrap().iter() { + if !path.starts_with("/etc/") { + continue; + } + let relative = Path::new(path).strip_prefix("/etc/").unwrap(); + let local = root.join(relative); + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + std::fs::write(&local, bytes).unwrap(); + } +} + +fn simulate_consumer_configuration(root: &Path) { + std::fs::write( + root.join("ups/ups.conf"), + "[cyberpower]\n driver = dummy-ups\n port = /etc/ups/simulated.dev\n mode = dummy-once\n", + ) + .unwrap(); + std::fs::write( + root.join("ups/simulated.dev"), + "ups.status: OL\nbattery.charge: 100\n", + ) + .unwrap(); + let monpath = root.join("ups/upsmon.conf"); + let monitor = std::fs::read_to_string(&monpath) + .unwrap() + .replace("/usr/bin/systemctl poweroff", "/usr/bin/true"); + std::fs::write(monpath, monitor).unwrap(); +} + +#[test] +#[ignore = "requires the Fedora NUT test image and Podman"] +fn real_fedora_nut_accepts_config_and_monitors_a_safe_dummy_ups() { + use std::time::{Duration, Instant}; + let temp = tempfile::tempdir().unwrap(); + let name = format!("skillet-nut-consumer-{}", uuid::Uuid::new_v4().simple()); + let _cleanup = Container(name.clone()); + command(&[ + "run", + "-d", + "--name", + &name, + "localhost/skillet-nut-runtime", + "sleep", + "300", + ]); + write_consumer_configuration(temp.path()); + let source = format!("{}/.", temp.path().display()); + let dest = format!("{name}:/etc/"); + command(&["cp", &source, &dest]); + command(&["exec", &name, "chown", "-R", "root:nut", "/etc/ups"]); + command(&["exec", &name, "chmod", "-R", "u=rwX,g=rX,o=", "/etc/ups"]); + command(&[ + "exec", + &name, + "systemd-tmpfiles", + "--create", + "/usr/lib/tmpfiles.d/nut-common-tmpfiles.conf", + ]); + command(&[ + "exec", + &name, + "udevadm", + "verify", + "/etc/udev/rules.d/70-skillet-ups.rules", + ]); + command(&[ + "exec", + &name, + "systemd-analyze", + "--man=no", + "verify", + "nut-driver@cyberpower.service", + "nut-server.service", + "nut-monitor.service", + ]); + command(&["exec", &name, "upsdrvctl", "-t", "start", "cyberpower"]); + let selected = command(&[ + "exec", + &name, + "/usr/libexec/nut-driver-enumerator.sh", + "--get-device-for-service", + "cyberpower", + ]); + assert_eq!(String::from_utf8_lossy(&selected).trim(), "cyberpower"); + // Only this disposable consumer receives simulation input. It has no USB + // device passthrough; shutdown is replaced by a harmless command. + simulate_consumer_configuration(temp.path()); + command(&["cp", &source, &dest]); + command(&["exec", &name, "chown", "-R", "root:nut", "/etc/ups"]); + command(&["exec", &name, "chmod", "-R", "u=rwX,g=rX,o=", "/etc/ups"]); + command(&[ + "exec", + &name, + "/usr/libexec/nut/dummy-ups", + "-a", + "cyberpower", + ]); + command(&["exec", &name, "upsd"]); + let start = Instant::now(); + loop { + let output = Command::new("podman") + .args(["exec", &name, "upsc", "cyberpower@127.0.0.1", "ups.status"]) + .output() + .unwrap(); + if output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "OL" { + break; + } + assert!( + start.elapsed() < Duration::from_secs(20), + "dummy UPS never became readable" + ); + std::thread::sleep(Duration::from_millis(200)); + } + assert_eq!( + String::from_utf8_lossy(&command(&[ + "exec", + &name, + "upsc", + "cyberpower@[::1]", + "ups.status" + ])) + .trim(), + "OL" + ); + command(&["exec", &name, "upsmon"]); + std::thread::sleep(Duration::from_secs(1)); + command(&["exec", &name, "upsmon", "-c", "reload"]); + let logs = command(&["exec", &name, "upsdrvctl", "status", "cyberpower"]); + assert!(String::from_utf8_lossy(&logs).contains("cyberpower")); + // Server startup refuses invalid syntax/listeners; verify only declared + // loopback addresses remain configured, never publish a container host port. + assert_eq!( + std::fs::read_to_string(temp.path().join("ups/upsd.conf")).unwrap(), + "LISTEN 127.0.0.1 3493\nLISTEN ::1 3493\n" + ); +} diff --git a/ublue/skillet/crates/nut/tests/Containerfile b/ublue/skillet/crates/nut/tests/Containerfile new file mode 100644 index 00000000..7f05b92f --- /dev/null +++ b/ublue/skillet/crates/nut/tests/Containerfile @@ -0,0 +1,2 @@ +FROM registry.fedoraproject.org/fedora:latest +RUN dnf -y install nut nut-client policycoreutils && dnf clean all diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json index 70d859ec..1cb3471d 100644 --- a/ublue/skillet/crates/workstation/src/secret-requirements.json +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -18,6 +18,7 @@ {"path":"skillet/environments/prod/hosts//cloudflare/ddns-token","status":"generated","help":"Skillet creates the persistent scoped token; disposable tokens are lifecycle-owned."} ]}, {"module":"Mikochi","required_by":["mikochi"],"description":"No application password; uses the shared Tailnet UI allowlist. [Setup](ublue/skillet/README.md#private-file-browsing).","entries":[]}, + {"module":"NUT","required_by":["nut"],"description":"No vault entry required. Skillet generates a local-only monitoring password on each opted-in host; no operator login. [UPS setup](ublue/skillet/README.md#local-ups-protection).","entries":[]}, {"module":"Pi-hole","required_by":["pihole"],"description":"[Delivery](ublue/skillet/README.md#secret-setup-and-delivery).","entries":[ {"path":"skillet/hosts//pihole/web-password","status":"generated","help":"Skillet generates and saves the web admin password for a fresh host; optionally create it to choose a password. For an existing host, restore its entry rather than inventing a replacement."} ]},