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 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..70f40e8e --- /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/skillet apply --host-file /etc/hostname + RemainAfterExit=yes + + [Install] + WantedBy=multi-user.target 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..b3791ba7 --- /dev/null +++ b/ublue/skillet/AGENTS.md @@ -0,0 +1,75 @@ +# 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. **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. + +## 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: + +```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 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 +- **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..322ec76a --- /dev/null +++ b/ublue/skillet/Cargo.lock @@ -0,0 +1,1815 @@ +# 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 0.61.2", +] + +[[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 0.61.2", +] + +[[package]] +name = "anyhow" +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" +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 = "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" +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 = "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 = "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" +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" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +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" +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 = "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" +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 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]] +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "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" +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 = "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" +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" +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 = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[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" +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 = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +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" +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 = "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 = "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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "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" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[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" +dependencies = [ + "serde", + "serde_core", +] + +[[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_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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "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" +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 = "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" +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]] +name = "skillet-beezelbot" +version = "0.1.0" +dependencies = [ + "anyhow", + "skillet_cli_common", + "skillet_hardening", +] + +[[package]] +name = "skillet-clamps" +version = "0.1.0" +dependencies = [ + "anyhow", + "skillet_cli_common", + "skillet_core", + "skillet_hardening", + "skillet_pihole", + "skillet_podman", +] + +[[package]] +name = "skillet_cli_common" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_yml", + "skillet_core", + "skillet_hardening", + "tempfile", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "skillet_core" +version = "0.1.0" +dependencies = [ + "askama", + "hex", + "nix", + "serde", + "sha2", + "tempfile", + "thiserror 1.0.69", + "tracing", + "users", + "zbus", +] + +[[package]] +name = "skillet_hardening" +version = "0.1.0" +dependencies = [ + "skillet_core", + "tempfile", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "skillet_pihole" +version = "0.1.0" +dependencies = [ + "askama", + "skillet_core", + "skillet_podman", + "thiserror 1.0.69", + "tracing", + "users", +] + +[[package]] +name = "skillet_podman" +version = "0.1.0" +dependencies = [ + "askama", + "serde", + "skillet_core", + "thiserror 1.0.69", + "tracing", + "users", +] + +[[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" +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 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +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", +] + +[[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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +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" +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 = "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 = "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" +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 = "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 = "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" +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.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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" +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 = "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", + "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/Cargo.toml b/ublue/skillet/Cargo.toml new file mode 100644 index 00000000..125d09e5 --- /dev/null +++ b/ublue/skillet/Cargo.toml @@ -0,0 +1,42 @@ +[workspace] +resolver = "2" +members = [ + "crates/core", + "crates/hardening", + "crates/pihole", + "crates/podman", + "crates/cli", + "crates/hosts/beezelbot", + "crates/hosts/clamps", + "crates/cli-common", +] + +[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" +sha2 = "0.10" +users = "0.11" +nix = { version = "0.29", 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" +serde_yml = "0.0.12" +hex = "0.4" +zbus = { version = "4.4", features = ["blocking"] } +askama = "0.12" + +[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/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`. diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml new file mode 100644 index 00000000..3b76ec3d --- /dev/null +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "skillet_cli_common" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_hardening.workspace = true +clap.workspace = true +tracing.workspace = true +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 new file mode 100644 index 00000000..81f9dc35 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -0,0 +1,109 @@ +use clap::Parser; +use skillet_core::files::{FileResource, LocalFileResource}; +use skillet_core::recorder::Recorder; +use skillet_core::system::{LinuxSystemResource, SystemResource}; +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("Configuration error: {0}")] + Config(String), + #[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}")] + Io(#[from] std::io::Error), + #[error("Serialization error: {0}")] + Yaml(#[from] serde_yml::Error), +} + +#[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, apply_fn: F) -> Result<(), CliCommonError> +where + F: Fn(&dyn SystemResource, &dyn FileResource) -> Result<(), String>, +{ + 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)?; + + match args.command { + HostCommands::Apply { record } => handle_apply(hostname, record, apply_fn), + } +} + +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(); + 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).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(".")), + )?; + 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)?; + } + + info!("Configuration applied successfully."); + Ok(()) +} diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml new file mode 100644 index 00000000..ba9a5b10 --- /dev/null +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "skillet" +version = "0.1.0" +edition = "2021" + +[lints] +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/host_applies.rs b/ublue/skillet/crates/cli/src/host_applies.rs new file mode 100644 index 00000000..ad2b3c20 --- /dev/null +++ b/ublue/skillet/crates/cli/src/host_applies.rs @@ -0,0 +1,110 @@ +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; + 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<(), 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 + 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, + )?; + } + _ => { + // 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 new file mode 100644 index 00000000..8226c18f --- /dev/null +++ b/ublue/skillet/crates/cli/src/main.rs @@ -0,0 +1,371 @@ +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; +use std::io::Write; +use std::path::{Path, PathBuf}; +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 { + #[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: 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, + }, + /// 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 + } else { + Level::INFO + }) + .finish(); + + tracing::subscriber::set_global_default(subscriber) + .context("setting default subscriber failed")?; + + match args.command { + 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) + .map_err(|e| e.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)?; + + let result = (|| -> Result<()> { + prepare_and_run_skillet(&container_name)?; + 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 { + let host_binary_name = format!("skillet-{hostname}"); + let root = find_workspace_root()?; + + let binary_path = [ + 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()) + .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}..."); + + 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("test_secret"), "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) -> 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..."); + 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 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." + )); + } + } + Ok(()) +} diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh new file mode 100755 index 00000000..ce92a99a --- /dev/null +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -0,0 +1,40 @@ +#!/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 + +# 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/Cargo.toml b/ublue/skillet/crates/core/Cargo.toml new file mode 100644 index 00000000..801fa94e --- /dev/null +++ b/ublue/skillet/crates/core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "skillet_core" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[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 +zbus.workspace = true +askama.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[features] +test-utils = [] diff --git a/ublue/skillet/crates/core/src/credentials.rs b/ublue/skillet/crates/core/src/credentials.rs new file mode 100644 index 00000000..5002bb4b --- /dev/null +++ b/ublue/skillet/crates/core/src/credentials.rs @@ -0,0 +1,38 @@ +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))?; + // 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 new file mode 100644 index 00000000..450aa4d6 --- /dev/null +++ b/ublue/skillet/crates/core/src/files.rs @@ -0,0 +1,329 @@ +use nix::unistd::{chown, fchown, Gid, Uid}; +use sha2::{Digest, Sha256}; +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; +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), + #[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), +} + +pub trait FileResource { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + mode: Option, + 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; +} + +pub struct LocalFileResource; + +impl LocalFileResource { + pub fn new() -> Self { + Self + } + + fn check_metadata( + 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() & 0o7777) != 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.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_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, + 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(()) + } + + 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 { + 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 metadata = fs::symlink_metadata(path) + .map_err(|e| FileError::Read(path.display().to_string(), e))?; + + // 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), we will delete and replace + self.delete_file(path)?; + true + } + } 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)?; + // 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()); + } 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) + } + + fn ensure_directory( + &self, + path: &Path, + mode: Option, + owner: Option<&str>, + group: Option<&str>, + ) -> Result { + let mut changed = false; + + 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_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())); + } + } + } + } + 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)); + } + } + + 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() { + 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) + } + } +} + +#[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..c267ad7e --- /dev/null +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -0,0 +1,172 @@ +use super::*; +use std::fs; +use std::os::unix::fs::PermissionsExt; +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_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_ensure_file_replaces_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 content = b"new content"; + let changed = resource + .ensure_file(&link_path, content, None, None, None) + .unwrap(); + + assert!(changed); + assert!(link_path.exists()); + assert!(fs::symlink_metadata(&link_path).unwrap().is_file()); + assert_eq!(fs::read(&link_path).unwrap(), content); +} + +#[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_ensure_directory_follows_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 changed = resource + .ensure_directory(&link_path, None, None, None) + .unwrap(); + + // 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] +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..9b606425 --- /dev/null +++ b/ublue/skillet/crates/core/src/lib.rs @@ -0,0 +1,8 @@ +pub mod credentials; +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 new file mode 100644 index 00000000..6c222600 --- /dev/null +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -0,0 +1,159 @@ +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_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + pub fn shared_ops(&self) -> Arc>> { + self.ops.clone() + } + + fn record(&self, op: ResourceOp) { + self.ops + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .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: 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(), + }); + self.inner.delete_file(path) + } +} + +impl SystemResource 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) + } + + 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(), + }); + 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) + } + + fn service_reload(&self, name: &str) -> Result<(), SystemError> { + self.record(ResourceOp::ServiceReload { + name: name.to_string(), + }); + 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 new file mode 100644 index 00000000..1f40ea93 --- /dev/null +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub enum ResourceOp { + EnsureFile { + path: String, + content_hash: String, + mode: Option, + owner: Option, + group: Option, + }, + DeleteFile { + path: String, + }, + EnsureDirectory { + path: String, + mode: Option, + owner: Option, + group: Option, + }, + EnsureGroup { + name: String, + }, + EnsureUser { + name: String, + uid: Option, + gid: Option, + }, + EnsurePodmanSecret { + name: String, + payload_hash: String, + }, + ServiceStart { + name: String, + }, + ServiceStop { + name: String, + }, + ServiceRestart { + name: String, + }, + ServiceReload { + name: String, + }, + DaemonReload, +} diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs new file mode 100644 index 00000000..353db4de --- /dev/null +++ b/ublue/skillet/crates/core/src/system.rs @@ -0,0 +1,360 @@ +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, get_user_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", + 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; + fn reload_unit(&self, name: &str, mode: &str) -> zbus::Result; + fn reload(&self) -> zbus::Result<()>; +} + +#[derive(Error, Debug)] +pub enum SystemError { + #[error("Group check error: {0}")] + 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), +} + +pub trait SystemResource { + fn ensure_group(&self, name: &str, gid: Option) -> Result; + fn ensure_user( + &self, + name: &str, + 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>; + fn service_reload(&self, name: &str) -> Result<(), SystemError>; + fn daemon_reload(&self) -> Result<(), SystemError>; +} + +pub struct LinuxSystemResource { + conn: Option, +} + +impl LinuxSystemResource { + pub fn new() -> 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(&self, action: &str, name: &str) -> Result<(), SystemError> { + let name_with_suffix = ensure_systemd_suffix(name); + + 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"), + "reload" => proxy.reload_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("--no-block") + .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_with_suffix} failed: {stderr}" + ))); + } + 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 { + fn default() -> Self { + Self::new() + } +} + +const EXIT_CODE_GROUP_EXISTS: i32 = 9; +const EXIT_CODE_USER_EXISTS: i32 = 9; + +impl SystemResource 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"); + if let Some(desired_gid) = gid { + if grp.gid() != desired_gid { + return Err(SystemError::GroupCheck(format!( + "Group {name} exists but GID {} does not match desired {desired_gid}", + grp.gid() + ))); + } + } + return Ok(false); + } + + info!("Creating group {name}"); + 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() { + if output.status.code() == Some(EXIT_CODE_GROUP_EXISTS) { + 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}"))); + } + + info!("Created group {name}"); + Ok(true) + } + + fn ensure_user( + &self, + name: &str, + uid: Option, + gid: Option, + ) -> Result { + 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 { + 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 { + return Err(SystemError::Command(format!( + "User {name} exists but GID {} does not match desired {desired_gid}", + user.primary_group_id() + ))); + } + } + return Ok(false); + } + + if let Some(gid_val) = gid { + self.ensure_group(name, Some(gid_val))?; + } + + 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 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", + "--format", + "{{ index .Spec.Labels \"skillet.payload_hash\" }}", + 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() { + 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}" + ))); + } + } + } + + info!("Creating podman secret {name}"); + let mut child = Command::new("podman") + .args([ + "secret", + "create", + "--label", + &format!("skillet.payload_hash={hash}"), + name, + "-", + ]) + .stdin(Stdio::piped()) + .spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write as _; + 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) + } + + 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) + } + + fn service_reload(&self, name: &str) -> Result<(), SystemError> { + self.run_systemctl("reload", name) + } + + fn daemon_reload(&self) -> Result<(), SystemError> { + self.daemon_reload() + } +} + +#[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..389035c8 --- /dev/null +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -0,0 +1,46 @@ +use super::*; +#[cfg(feature = "test-utils")] +use crate::test_utils::MockSystem; + +#[test] +#[cfg(feature = "test-utils")] +fn test_mock_system_resource() { + let system = MockSystem::new(); + let changed = system.ensure_group("syslog", None).unwrap(); + assert!(changed); + assert!(system + .groups + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains("syslog")); + + let changed_again = system.ensure_group("syslog", None).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(std::sync::PoisonError::into_inner) + .get("test-service") + .unwrap(), + "started" + ); + + system.service_restart("test-service").unwrap(); + assert_eq!( + system + .services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get("test-service") + .unwrap(), + "restarted" + ); +} diff --git a/ublue/skillet/crates/core/src/templates.rs b/ublue/skillet/crates/core/src/templates.rs new file mode 100644 index 00000000..48d0e0c9 --- /dev/null +++ b/ublue/skillet/crates/core/src/templates.rs @@ -0,0 +1,24 @@ +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::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 new file mode 100644 index 00000000..3d998e35 --- /dev/null +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -0,0 +1,205 @@ +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>>, + pub users: Arc>>, + pub podman_secrets: Arc>>, + pub services: Arc>>, // name -> state (started, stopped, restarted) +} + +impl MockSystem { + pub fn new() -> Self { + 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())), + } + } +} + +impl Default for MockSystem { + fn default() -> Self { + Self::new() + } +} + +impl SystemResource for MockSystem { + 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) + } + } + + 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 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() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(name.to_string(), "started".to_string()); + Ok(()) + } + + fn service_stop(&self, name: &str) -> Result<(), SystemError> { + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(name.to_string(), "stopped".to_string()); + Ok(()) + } + + fn service_restart(&self, name: &str) -> Result<(), SystemError> { + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .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(()) + } + + 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); + +pub struct MockFiles { + pub files: Arc>>>, + pub metadata: Arc>>, + pub directories: Arc>>, +} + +impl MockFiles { + pub fn new() -> Self { + Self { + files: Arc::new(Mutex::new(HashMap::new())), + metadata: Arc::new(Mutex::new(HashMap::new())), + directories: Arc::new(Mutex::new(HashSet::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(std::sync::PoisonError::into_inner); + let mut metadata = self.metadata.lock().unwrap_or_else(std::sync::PoisonError::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(ToString::to_string), + group.map(ToString::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 ensure_directory( + &self, + path: &Path, + _mode: Option, + _owner: Option<&str>, + _group: Option<&str>, + ) -> Result { + 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 { + 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 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 new file mode 100644 index 00000000..6d818c18 --- /dev/null +++ b/ublue/skillet/crates/hardening/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "skillet_hardening" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } +tempfile.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/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..47c47a6d --- /dev/null +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -0,0 +1,102 @@ +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(system, files)?; + + // 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)?; + + // 4. Include 'ssh-hardening::client' + apply_ssh_hardening_client(system, files)?; + + Ok(()) +} + +fn apply_sysctl_hardening(system: &S, files: &F) -> Result<(), HardeningError> +where + S: SystemResource + ?Sized, + 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 = 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")?; + } + + Ok(()) +} + +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, + F: FileResource + ?Sized, +{ + info!("Applying ssh-hardening::server"); + 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, files: &F) -> Result<(), HardeningError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Applying ssh-hardening::client"); + 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(()) +} + +#[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..6ef71625 --- /dev/null +++ b/ublue/skillet/crates/hardening/src/tests.rs @@ -0,0 +1,58 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; + +#[test] +fn test_hardening_applies_sysctl() { + let system = MockSystem::new(); + let files = MockFiles::new(); + apply(&system, &files).unwrap(); + assert!(files + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key("/etc/sysctl.d/99-hardening.conf")); + assert_eq!( + system + .services + .lock() + .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 new file mode 100644 index 00000000..6320f220 --- /dev/null +++ b/ublue/skillet/crates/hosts/beezelbot/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "skillet-beezelbot" +version = "0.1.0" +edition = "2021" + +[lints] +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 new file mode 100644 index 00000000..cd1ad8cd --- /dev/null +++ b/ublue/skillet/crates/hosts/beezelbot/src/main.rs @@ -0,0 +1,9 @@ +use anyhow::Result; +use skillet_cli_common::run_host; + +fn main() -> Result<()> { + 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 new file mode 100644 index 00000000..6e623ef1 --- /dev/null +++ b/ublue/skillet/crates/hosts/clamps/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "skillet-clamps" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +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 new file mode 100644 index 00000000..aad29aa3 --- /dev/null +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -0,0 +1,49 @@ +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: 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(()) + })?; + Ok(()) +} diff --git a/ublue/skillet/crates/pihole/Cargo.toml b/ublue/skillet/crates/pihole/Cargo.toml new file mode 100644 index 00000000..c33bc683 --- /dev/null +++ b/ublue/skillet/crates/pihole/Cargo.toml @@ -0,0 +1,15 @@ +[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 +users.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..56482174 --- /dev/null +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -0,0 +1,139 @@ +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 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 struct PiholeUser { + pub uid: Option, + pub gid: Option, + pub name: String, + pub group_name: String, +} + +pub fn apply( + system: &S, + files: &F, + user_config: PiholeUser, + secrets: Vec, +) -> Result<(), PiholeError> +where + S: SystemResource + ?Sized, + F: FileResource + ?Sized, +{ + info!("Applying pihole configuration..."); + let root = "/etc/pihole"; + let logs = "/var/log/pihole"; + + // 1. Ensure user and group + 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"))?; + 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"))?; + + // 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 }; + ensure_templated_file( + files, + &Path::new(root).join("conf/custom.list"), + &template, + Some(0o640), + Some("root"), + Some("root"), + )?; + + // 4. Define container + let user = ContainerUser { + container_uid: 1000, + container_gid: 1000, + host_user: Some(HostUser::Name(user_config.name)), + }; + + 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, + secrets, + extra_config, + }, + )?; + + Ok(()) +} diff --git a/ublue/skillet/crates/pihole/templates/pihole/custom.list.j2 b/ublue/skillet/crates/pihole/templates/pihole/custom.list.j2 new file mode 100644 index 00000000..f5f7ae67 --- /dev/null +++ b/ublue/skillet/crates/pihole/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..c07f1a8f --- /dev/null +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -0,0 +1,286 @@ +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; +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 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>, +} + +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_info = resolve_host_user(system, &config.user, config.create_host_user)?; + + // 2. Calculate mappings + 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 + let container_section = extra_config.entry("Container".to_string()).or_default(); + 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)?; + + let mut vol_line = format!("Volume={}:{}", vol.host_path, vol.container_path); + if let Some(opt) = vol.options { + 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(); + } + + // 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, + 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", 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}")); + + // UIDMap + if uid_container > 0 { + 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; + if rem_u > 0 { + container_section.push(format!( + "UIDMap={}:{}:{rem_u}", + uid_container + 1, + sub_uid_base + uid_container + 1 + )); + } + + // GIDMap + if gid_container > 0 { + 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; + if rem_g > 0 { + container_section.push(format!( + "GIDMap={}:{}:{rem_g}", + gid_container + 1, + sub_gid_base + gid_container + 1 + )); + } +} + +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); + + 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)); + } + } + None +} + +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::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.daemon_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 new file mode 100644 index 00000000..7cc20f21 --- /dev/null +++ b/ublue/skillet/integration_tests/recordings/beezelbot.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 diff --git a/ublue/skillet/integration_tests/recordings/clamps.yaml b/ublue/skillet/integration_tests/recordings/clamps.yaml new file mode 100644 index 00000000..121b4284 --- /dev/null +++ b/ublue/skillet/integration_tests/recordings/clamps.yaml @@ -0,0 +1,95 @@ +- !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