diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eac3b627..02a8c148 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main, master ] + branches: [ main, master, skillet ] pull_request: - branches: [ main, master ] + branches: [ main, master, skillet ] jobs: chef-lint: @@ -47,5 +47,87 @@ jobs: - uses: actions/checkout@v4 - name: Run ShellCheck run: | - find . -name "*.sh" -not -path "./.venv/*" -not -path "./berks-cookbooks/*" -exec shellcheck -x {} + - shellcheck -x run + find . -type f \ + \( -name '*.sh' -o -name run -o -name butane -o -name flatpak-virt \ + -o -name test-vm -o -name test-vm-ready -o -name virsh -o -name virt-install \ + -o -name install-hardware \ + -o -name setup-llama-server -o -name update-llama \) \ + -not -path './.git/*' -not -path './.venv/*' -not -path './berks-cookbooks/*' \ + -not -path '*/target/*' -not -path './ublue/llama/files/update-llama' \ + -exec shellcheck -x {} + + # This desktop helper intentionally ignores a failed best-effort stop. + shellcheck -x --exclude=SC2015 ublue/llama/files/update-llama + + skillet-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + rust: + - 'ublue/skillet/**' + - 'ublue/butane/**' + - 'ublue/AGENTS.md' + - '.github/workflows/ci.yml' + + - name: Set up Rust + if: steps.filter.outputs.rust == 'true' + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + targets: x86_64-unknown-linux-musl + + - name: Install musl linker + if: steps.filter.outputs.rust == 'true' + run: sudo apt-get update && sudo apt-get install --yes musl-tools + + - name: Rust Cache + if: steps.filter.outputs.rust == 'true' + uses: Swatinem/rust-cache@v2 + with: + workspaces: ublue/skillet + + - name: Run Clippy + if: steps.filter.outputs.rust == 'true' + run: cd ublue/skillet && cargo clippy --workspace --all-targets -- -D warnings + + - name: Check formatting + if: steps.filter.outputs.rust == 'true' + run: cd ublue/skillet && cargo fmt --all -- --check + + - name: Run Unit Tests + if: steps.filter.outputs.rust == 'true' + run: cd ublue/skillet && cargo test --workspace --all-targets + + - name: Run Integration Tests + if: steps.filter.outputs.rust == 'true' + run: | + cd ublue/skillet + cargo run --bin skillet -- test run joey --phase base --image fedora:latest + + - name: Validate private UI Caddy configuration + if: steps.filter.outputs.rust == 'true' + run: | + cd ublue/skillet + cargo test -p skillet_caddy generated_identity_caddyfile_validates_with_the_real_consumer -- --ignored + + - name: Test Mikochi file operations without external credentials + if: steps.filter.outputs.rust == 'true' + run: | + cd ublue/skillet + cargo test -p skillet_mikochi real_mikochi_has_no_second_login_and_supports_owned_file_operations -- --ignored + + - name: Test native UPS configuration without hardware + if: steps.filter.outputs.rust == 'true' + run: | + cd ublue/skillet + podman build -t localhost/skillet-nut-runtime crates/nut/tests + cargo test -p skillet_nut real_fedora_nut_accepts_config_and_monitors_a_safe_dummy_ups -- --ignored + + - name: Run installation and bootstrap regressions + if: steps.filter.outputs.rust == 'true' + run: | + bash ublue/butane/tests/bootstrap.sh + bash ublue/butane/tests/hardware.sh diff --git a/SECRETS.md b/SECRETS.md new file mode 100644 index 00000000..488d7d75 --- /dev/null +++ b/SECRETS.md @@ -0,0 +1,112 @@ +# Skillet secrets checklist + +Vault: `$XDG_DATA_HOME/skillet/secrets.kdbx` (default: +`~/.local/share/skillet/secrets.kdbx`). Paths are **group path + entry title**. +Unless an entry documents other fields, put its value in **Password** and +leave **Username** empty. `` is `prod` or `test`; `` is the +canonical host name. Only prepare entries for enabled modules. + +Audit with `skillet secrets check` (all declared hosts, production), or +`skillet secrets check --host clamps --environment test`. It checks required fields and their formats, without provider calls or generating secrets. +Missing optional/generated/planned entries do not fail the check. + + + +## Btrbk + +No vault entries for the current local snapshot configuration. + +## Caddy + +Prepare the shared Cloudflare entries. [UI setup](ublue/skillet/README.md#caddy-ui-credentials). + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/environments//hosts//cloudflare/acme-token` | Generated | Skillet creates the persistent scoped token; do not create disposable VM tokens manually. | +| `skillet/hosts//cloudflare/acme-token` | Generated | Legacy production token read during migration. Do not create new entries at this path. | + +## Cloudflare + +Shared inputs for Caddy and DDNS. + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/cloudflare/token-creator` | Required | Create an account-owned Cloudflare API token with Account API Tokens Write and Zone Read, restricted to the selected zones. [Permissions guide](ublue/skillet/README.md#secret-setup-and-delivery). | +| `skillet/environments//dns/cloudflare-zone-id` | Required | Copy the Zone ID from the selected zone's Cloudflare Overview page. | +| `skillet/environments//dns/ui-domain` | Optional | Choose a relative UI prefix, such as ui; absent defaults to ui. Skillet appends the zone domain. Do not enter a full domain. | + +## Datadog + +One shared key serves production and test. No application key required for ingestion. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog). + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/datadog/api-key` | Required | Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | +| `skillet/datadog/monitor-setup-key` | Optional | For workstation monitor administration only: create a scoped Application Key in Datadog Organization Settings → Application Keys with monitors_read and monitors_write. Never deliver this key to hosts. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/). | + +## DDNS + +Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skillet/README.md#optional-cloudflare-ddns). + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/environments//hosts//cloudflare/ddns-dns-name` | Required | Choose the relative DNS record name, not a full domain. | +| `skillet/environments/prod/hosts//cloudflare/ddns-token` | Generated | Skillet creates the persistent scoped token; disposable tokens are lifecycle-owned. | + +## Mikochi + +No application password; uses the shared Tailnet UI allowlist. [Setup](ublue/skillet/README.md#private-file-browsing). + +## NUT + +No vault entry required. Skillet generates a local-only monitoring password on each opted-in host; no operator login. [UPS setup](ublue/skillet/README.md#local-ups-protection). + +## Pi-hole + +[Delivery](ublue/skillet/README.md#secret-setup-and-delivery). + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/hosts//pihole/web-password` | Generated | Skillet generates and saves the web admin password for a fresh host; optionally create it to choose a password. For an existing host, restore its entry rather than inventing a replacement. | + +## SMTP + +Production-only native Postfix relay credentials; smoke machines use a local sink without these values. [SMTP plan](ublue/plan/CLAMPS-EMAIL.md). + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/smtp` | Required | Create one entry using your Mailjet SMTP credentials: Username = SMTP username; Password = SMTP password. Custom fields: host = in-v3.mailjet.com; port = 587; tls = starttls (required TLS with certificate verification); sender = a Mailjet-verified sender email address. [Mailjet credentials](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters). | + +## Storage + +Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet/README.md#root-recovery-keys). Password contains the printable passphrase; custom luks-uuid and recovery-state fields and the protected root-luks.header attachment are managed by Skillet. + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/hosts//storage/root-recovery-key` | Generated | Run skillet secrets root-recovery-key before finishing production provisioning. The command unlocks the vault, saves before enrollment, and verifies the key against this volume. | +| `skillet/environments/test/hosts//instances//storage/root-recovery-key` | Generated | Run skillet test vm recovery-key HOST INSTANCE. Archived test keys remain in the vault after VM disposal; delete the instance entry before reusing its name for a new disk. | + +## Syncthing + +No vault entries currently required; Skillet manages the private UI configuration. + +## Tailnet UI + +Identity authorization for caller-declared private UIs. Uses the existing host-network daemon; no second login. [Setup](ublue/skillet/README.md#private-file-browsing). + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/tailscale/ui-users` | Required | Password = complete Tailscale LoginName values, separated by commas or newlines; Username is unused. Obtain the identities from tailscale status --json on an authenticated client. Both environments share this explicit allowlist. | + +## Tailscale + +Create an OAuth client in Tailscale Trust credentials with Auth Keys Write and Devices Core Read/Write, restricted to the provisioner tag. [Tag and scope guide](ublue/skillet/README.md#tailscale-setup-for-skillet). Skillet mints enrollment keys; do not create per-host auth-key entries. + +| Entry | Status | Fields / how to obtain them | +| --- | --- | --- | +| `skillet/tailscale/provisioner-client-id` | Required | Copy the OAuth Client ID from the creation page; use Password, not Username. | +| `skillet/tailscale/provisioner-client-secret` | Required | Copy the OAuth Client secret from the creation page. | + +## UniFi + +No vault entries currently required. Controller/UI accounts are managed in UniFi, separately from Skillet provisioning. diff --git a/hooks/pre-push.sh b/hooks/pre-push.sh index 8bb205ee..be213596 100755 --- a/hooks/pre-push.sh +++ b/hooks/pre-push.sh @@ -3,10 +3,14 @@ set -eu pushd "$(git rev-parse --show-toplevel)" >/dev/null -cinc exec cookstyle site-cookbooks roles +if command -v cinc >/dev/null 2>&1 && cinc --version >/dev/null 2>&1; then + cinc exec cookstyle site-cookbooks roles +else + echo "Skipping Cookstyle: cinc is not installed" >&2 +fi uv run ruff check --diff . uv run ruff format --diff . uv run mypy --ignore-missing-imports site-cookbooks fabfile.py -find . -name "*.sh" -not -path "./.venv/*" -not -path "./berks-cookbooks/*" -exec shellcheck -x {} + +git ls-files -z -- "*.sh" | xargs -0 -r shellcheck -x shellcheck -x run popd >/dev/null diff --git a/ublue/AGENTS.md b/ublue/AGENTS.md new file mode 100644 index 00000000..6955116a --- /dev/null +++ b/ublue/AGENTS.md @@ -0,0 +1,187 @@ +# uBlue agent instructions + +## Current priority + +- Complete the seven workstreams in [the Skillet refactoring roadmap](plan/SKILLET-REFACTOR.md) + before proceeding with further service migrations, encryption features, or + production cutover. VM management's Bash-to-Rust migration is included. +- Follow the roadmap's ordering and validation gates. Existing feature plans + retain their unfinished work but are gated by this prerequisite. Fixes and + acceptance work necessary to complete the refactoring are in scope. + +## Design documentation + +- Maintain the repository-root [secrets checklist](../SECRETS.md) whenever a + vault entry or secret requirement is added, changed, or removed. Group entries + by module in alphabetical order. Keep explanations brief; include the exact + path, required/optional/generated status, where to obtain the value or a short + setup link, and whether it belongs in Username or Password. Distinguish planned + requirements from implemented ones; never include real credentials or private + identifiers. +- The checklist and `secrets check` share + `skillet/crates/workstation/src/secret-requirements.json`. Update that metadata + and regenerate `SECRETS.md` with `cargo run -p skillet_workstation --example + secrets_documentation` from the Skillet workspace (redirect output to the + repository-root file). Keep template-reference coverage and the documentation + equality test passing. Required checks follow selected host capabilities and the actual environment + delivery policy; isolated test modes must not require unused provider credentials. + Structured vault entries must declare each required standard/custom field and + its validation in the shared audit metadata; keep single-value entry behavior + compatible, and never include field values in audit output or errors. + Unused-entry auditing must consider all declared hosts and both environments, + including optional/generated entries and retained migration readers. + +- Read the relevant documents in [design/](design/) before changing architecture + or behavior they describe, including [secrets](design/secrets.md) and + [smoke VM lifecycle](design/smoke-vms.md). +- Keep those documents updated in the same change as the implementation or + decision they describe. Clearly distinguish planned from implemented behavior. +- Record new architectural decisions in `ublue/design/`, updating an existing + document when appropriate or adding a short document for a new topic. +- Keep design documents brief: describe the decision, why it was chosen, and + material consequences. Keep setup and usage instructions in READMEs, with + links to the relevant designs instead of duplicated rationale. + +## Implementation plans + +- Keep implementation plans in [plan/](plan/), not in the `ublue/` root. +- Read relevant plans before starting work and update their remaining steps, + status, and links as work progresses. Plans describe tasks and acceptance + criteria; architectural decisions and rationale belong in `design/`. +- Remove completed or superseded plans after preserving any unfinished work + in an active plan. Retain implementation evidence in the relevant acceptance + record; do not label unverified checks as passed. +- Each refactoring handoff must name the implemented slice, remaining work, + interface changes, and validation evidence. Record the general prevention + rule in the applicable `AGENTS.md` when an architectural defect is corrected; + keep incident-specific details in designs, plans, or acceptance records. + +## Shared infrastructure boundaries + +- Disabling application authentication requires explicit proxy authorization + and a private backend boundary. Preserve the actual connection peer, replace + untrusted identity headers, and deny unknown/tagged sources or lookup failures. + Sharing a network address range is not user authentication. Test policy + revocation, daemon outages, browser origin checks, and direct backend isolation. + Verify effective forwarding isolation in both IP families; network flags can + change whether the runtime generates firewall rules at all. +- Identity/private policy follows the same canonical UI declaration and encrypted + delivery path as the service. Give proxy processes only the API surface they + consume; a read-only Unix socket bind does not limit API methods. + +- Authentication finalization must follow explicit provisioning completion, not + base readiness. One-time account mutations need durable recovery state and + must preserve later operator password changes; routine apply must not reopen + temporary privileged access or repeat password expiry. + +- Hardware-dependent services use an injected observation boundary. Converge + configuration without absent-device startup failures, order startup after + device discovery, and preserve existing outage handling when hardware vanishes. + Validate simulated shutdown paths only in isolated disposable state. + +- Optional services require explicit selection in the canonical host caller + declaration. Shared baseline services retain their common defaults; the + presence of environment configuration or credentials must not enable an + optional service implicitly. +- Service modules own their monitoring declarations. Monitoring consumes the + same selected services as application composition; do not maintain an + independent monitoring inventory or place credentials in discovery metadata. +- Generated unit executable paths must agree with the actual bootstrap and + update delivery paths. Cover that agreement in regression checks; consumer + placeholders alone cannot establish deployment correctness. +- Native metric collectors use bounded read-only queries and a finite metric/tag + inventory. Failed observations must report failure rather than synthetic zero + readings. Keep ingestion credentials in the Agent and explicitly scope local + submission endpoints. Validate collector output with the actual receiving + implementation; local submission does not establish remote ingestion. + +- Shared helpers receive identity and configuration from validated inputs or + declarations. Keep deployment-specific assumptions in the selected profile; + do not bake them into generic readiness, delivery, or lifecycle code. +- Keep clear configuration policy in reviewed source templates and secret + leaves in the vault. Resolve secrets through typed references at the + workstation boundary; do not store whole configuration documents in the + vault or interpolate secret text into configuration strings. Record template + behavior and schema changes in the relevant design document. +- Scope vault credentials to their actual provider/account needs; do not + require separate copies by environment when a shared credential is intended. + Keep public endpoint/site choices in configuration, with documented defaults. +- Give each resource lifecycle one owner. Every public entry point must use + the same ownership validation, recovery, and cleanup policy; transport + adapters must not provide a shortcut that bypasses those guarantees. +- Removing an optional generated configuration section must tolerate its + absence, while rejecting malformed present sections. Cover fresh inputs and + retained compatibility inputs through the actual staging/compiler path. +- Physical installation media requires explicit destination disk identity and + private bootstrap access. Keep media generation separate from block-device + writes; validate hardware prerequisites and confirm destruction on the target. + Respect installer metadata size limits; bind separately delivered installation + payloads to the prepared media and verify them before destructive operations. + Preserve virtual installation support without leaking virtual disk assumptions + into physical profiles. Never describe generated media as hardware acceptance. +- Installation profiles are explicit and independent of host identity. Preserve + compatible defaults and reject missing prerequisites rather than silently + weakening a requested security policy. Convergence never reformats live data. +- Preserve downstream schema types when parsing and re-emitting configuration. + Validate staged output with the actual consumer/compiler where possible, not + only with a parser round trip. +- Do not assume package directories under mutable OS paths are initialized by + an image build or rebase. Converge required state directories, ownership and + security labels before confined service startup; test from absent package state. +- Before activating a unit with multiple credential prerequisites, deliver the + complete credential set first. Cover activation from a fresh host where the + consumer service and its containers do not yet exist. +- Keep shell entry points as narrow argument adapters when Rust owns a + lifecycle. Backend selection, resource inspection, retries, and cleanup + belong to the shared Rust owner rather than being duplicated in wrappers. +- VM guest operations hold the per-run lock and revalidate the recorded domain + identity immediately before and after each remote operation. Consumers load + target details from the typed manifest; they do not launch compatibility + wrappers for status checks or reparse legacy run files. +- Keep host profile, environment, deployment instance, runtime identity, and + connection target distinct. Derive conventions in one place and persist the + resolved values needed for recovery. +- File and directory mutations use the same typed ownership contract for + named and numeric UID/GID values. Numeric ownership must not require a host + account lookup; preserve omitted metadata fields on existing paths. +- Recovery acceptance preserves the original unlock path and uses independently + owned disposable state for destructive tests. Disable diagnostic capture on + credential-capable boot consoles; keep recovery backups outside the protected + resource and verify their bytes after encrypted persistence. +- Persist independently usable recovery credentials before enrolling them. Bind + recovery records to the resource identity, retain pending state for retries, + and refuse silent replacement or re-enrollment of verified credentials. +- Persist ownership before mutations and preserve it across partial failures. + Cleanup must tolerate already-absent owned resources while refusing ambiguous + or unrelated resources. Remove recovery metadata only after cleanup completes. +- Compatibility-sensitive refactors must preserve retained artifacts and + manifests through an explicit migration; never infer that existing state is + disposable merely because its format is old. + +## Local validation before commit + +- Compare CI and local Rust/Clippy versions when diagnosing lint failures. + Validate fixes with CI's toolchain version; an older local toolchain passing + does not verify lints added in a newer release. +- Before committing, run the CI checks that apply to the changed paths locally + and resolve failures first. For Skillet changes, run + `cargo fmt --all -- --check`, + `cargo clippy --workspace --all-targets -- -D warnings`, + `cargo test --workspace --all-targets`, and + `cargo run --bin skillet -- test run joey --phase base --image fedora:latest` + from `.github/workflows/ci.yml`. For shell changes, run the workflow's + ShellCheck commands; for Python or Chef changes, run the corresponding + Ruff/Mypy or Cookstyle commands. +- When Butane bootstrap inputs or tests change, also run + `bash ublue/butane/tests/bootstrap.sh` from the repository root. CI must + execute state-regression scripts as tests, not only lint their source. +- For changes to container behavior, also run the affected host's real-runtime + smoke test against a named disposable VM as required by `skillet/AGENTS.md`. +- If a required check cannot run because a tool, runtime, or external service + is unavailable, record the exact reason and report the check as unverified; + do not describe it as passing or commit as though it passed. + +- Keep canonical host spelling consistent across profile, package, binary, + installation templates and image. Identity renames require an explicit + compatibility decision; never silently rewrite retained manifests or vault + entries. Preserve historical acceptance evidence as recorded. diff --git a/ublue/butane/.gitignore b/ublue/butane/.gitignore new file mode 100644 index 00000000..620db420 --- /dev/null +++ b/ublue/butane/.gitignore @@ -0,0 +1,4 @@ +/runs/ +/images/ +/ignition/*.ign +/ignition/includes/ diff --git a/ublue/butane/ACCEPTANCE.md b/ublue/butane/ACCEPTANCE.md new file mode 100644 index 00000000..213a9ede --- /dev/null +++ b/ublue/butane/ACCEPTANCE.md @@ -0,0 +1,2584 @@ +## NUT collector delivery-path correction — 2026-10-12 + +- Corrected the timer to invoke `/var/usrlocal/bin/skillet`, matching both + shared Ignition injection and verified VM binary delivery. A regression + compares its executable with the actual Butane include; consumer verification + now creates its harmless placeholder at that same path. +- Formatting, strict workspace Clippy, all 403 workspace tests, the Fedora joey + base repeat-apply scenario and the real Fedora NUT/systemd consumer passed. + Native uCore timer execution remains pending; this is delivery-contract and + consumer validation, not live timer or cloud ingestion acceptance. + +## Native NUT metrics and local DogStatsD — 2026-10-12 + +- Implemented `skillet metrics nut` in the shared static musl guest binary. + It performs only `LIST VAR`, with a bounded whole-query deadline and response + sizes, and emits a fixed gauge inventory plus independent UPS status flags. + Optional absent readings are omitted; query failure submits only a failed + `nut.communication` check and exits nonzero. No Python, `upsc`, runtime plugin + downloads, derived Agent image or additional credential is required. +- Successful Datadog apply configures the same caller-selected NUT module's + `skillet-nut-metrics.timer`, polling every 30 seconds. The collector is + conditioned on the USB alias and Agent socket. Its unit orders startup + without activating optional consumers. Repeat apply and interrupted timer + activation are covered. Driver-instance unit names are now accepted by the + Agent's monitored-unit validation. +- The official pinned Agent uses a Unix datagram socket under root:root `0700` + `/run/skillet-dogstatsd`; tmpfiles recreates the directory at boot. UDP port + is explicitly disabled. Only this socket directory is a writable additional + mount; host monitoring mounts retain their existing policy. +- Local validation passed: 403 workspace tests, formatting, strict all-target + Clippy, whitespace checks and the Fedora joey base scenario, with no service + start/restart on repeat apply. Routine CI needs no vault/provider access: + parsing tests, local TCP/Unix datagram transports, real CLI success/failure, + status transitions and canonical profile composition use isolated fixtures. +- Named VM regression `test smoke clamps --instance files` passed its fixture + checks before/after reboot and restored signed-boot/base/user-environment + readiness on `clamps-test-files`. This is fixture/runtime evidence, not UPS + timer or Datadog cloud acceptance. +- The explicit ignored CLI consumer test passed against official Agent 7.84.1 + with a dummy key and `--network none`. The Agent parsed expected gauges and + both service checks with zero parse errors; actual metric names/tags were + visible in DogStatsD statistics. Both UDP listener tables were empty. The + disposable container was removed after the test. +- Read-only live collection passed against the forwarded UPS on both IPv4 and + IPv6 loopback. It reported model BR1000ELCD, status `OL`, charge 100%, runtime + 3301 seconds and load 11%. A separate network-isolated Agent parsed its + 15 gauges and two service checks, with zero parse errors and no UDP listener. + No telemetry was sent to Datadog and no UPS control command was issued. +- The safe Fedora NUT consumer check also passed real systemd verification of + generated collector service/timer syntax. Verification uses a harmless + executable placeholder; actual collector execution is covered separately. + Native uCore timer execution, socket permission/SELinux behavior and recovery + across reboot, cloud ingestion, and physical power-loss acceptance remain + **unverified**; local receipt is not evidence of those checks. + +## Local NUT UPS protection — 2026-10-12 + +- The shared image now declares Fedora `nut` and `nut-client`, with NUT services + and enumeration disabled by default. Skillet's optional native module uses + `/etc/ups`. Only clamps currently selects it: CyberPower CP1500 AVR, + `usbhid-ups`, USB `0764:0501`, `MODE=standalone` and loopback-only port 3493. +- The root-only retained monitor password is generated locally; root:nut + configuration grants only `upsmon primary`. The packaged monitor uses normal + critical-battery handling and `/run/nut/killpower`. Driver/server/monitor + conditions and scoped udev activation tolerate an absent UPS in ordinary VMs. + An existing monitor is not stopped when the USB cable vanishes. +- Local musl-target workspace tests, formatting and strict all-target Clippy + passed. The Fedora joey base integration applied twice without start/restart + on repeat. Image recipe YAML and both repository whitespace checks passed. + Pure regressions cover exact USB IDs, no-device convergence, retained secrets, + repeat apply, interrupted activation and rejected declaration/secret inputs. +- The routine CI consumer check passed against Fedora NUT 2.8.5 in a disposable + rootless container. It verified the generated udev rule and systemd drop-ins, + packaged driver-instance lookup and a non-executing USB driver start plan. + A dummy UPS then answered `OL` over IPv4 and IPv6 loopback; the packaged + monitor started and accepted reload. The consumer used a harmless shutdown + command and no USB passthrough; no real host/UPS shutdown was attempted. +- Full BlueBuild image publication, a boot/rebase check of the rebuilt image, + real USB permissions/polling, battery shutdown, UPS load cycling and firmware + power-return startup remain **unverified**. BlueBuild CLI is unavailable + locally; the current published smoke image does not contain these new packages. + Consumer unit verification is not signed-uCore boot or hardware acceptance. + See [the UPS design](../design/ups.md). + +## Mikochi and shared tailnet identity — 2026-10-11 + +- Shared Syncthing composition selects Mikochi on clamps and joey. It serves + only the declared user-file directory, using Syncthing's process/host mapping, + without a native login or published port. Caddy receives the authorization + socket; the gate alone receives daemon access. Encrypted version 3 UI payloads + carry the shared allowlist from `skillet/tailscale/ui-users` (Password, commas + or newlines). The unlocked vault audit passed: seven required, none missing, + invalid or unused. No private identifiers or credential values are recorded here. +- Local musl-target formatting, strict all-target Clippy and all 385 workspace + tests passed. The Fedora joey base integration applied twice without service + activation on repeat apply. Bootstrap and hardware regressions passed. + New CI checks validate generated identity Caddy configuration using the real + Caddy image and exercise pinned Mikochi 1.12.0 without a vault/provider API: + no app authentication, 3-MiB upload/download, spaced filenames, mkdir, rename, + delete, preserved host ownership and denial of an unmounted host symlink target. +- The first disposable `clamps / files` probe rejected `Internal=true` as unsafe: + Netavark 1.17.2 skips firewall generation for internal networks, allowing direct + backend access. Mikochi was stopped and that VM disposed through the owner. + The final profile uses a dedicated **non-internal**, strictly isolated bridge; + Caddy forwarding is disabled. Runtime smoke now requires positive IPv4/IPv6 + readiness and negative sibling connections, rejecting broken probe commands. +- Fresh `clamps / files`, native `qemu:///session`, SSH port 2203, captured the + corrected binaries and passed signed image/bootstrap/base/user readiness. + A temporary Rust driver held the per-run lock and checked domain ownership + around every guest command: positive readiness passed over IPv4 and IPv6, + while both sibling bridge connections timed out. There was one Mikochi backend + network and no host publication. After the synthetic reboot, independent full + apply and Caddy apply preserved Mikochi's container ID and start timestamp. Real LocalAPI lookup + matched this workstation's untagged user to the allowlist; the authorization + endpoint returned 204. This does not establish two-person browser acceptance. +- A preliminary VM obtained four staging ACME certificates and returned its + explicit non-tailnet 403 after a diagnostic public-resolver change. Public + ACME resolvers are now in shared rendering, separately from upstream Podman + DNS. On the fresh VM, provisioning still failed its HTTPS denial/readiness + check: Let's Encrypt reported **secondary-validation DNS networking errors**. + Certificate issuance is retrying; fresh live ACME acceptance remains pending. +- End-to-end user HTTPS/file operations remain pending: this workstation's + connection to the tailnet VM returned `Host is unreachable`. The operator was + asked to verify human-user TCP 443 grants to the smoke tag. Do not equate + direct authorization-socket success or direct-container CI file operations + with successful HTTPS file operations. Irene, unknown-user live requests, + live revocation/outage, broader symlink behavior and Headscale remain unverified. + Gokapi mapping and donbot implementation remain planned. +- Synthetic smoke passed first apply, repeat apply and reboot checks. The full + `--with-applications` run failed because the fresh UniFi controller did not + listen on UDP 10001 within 120 seconds; it stopped before the Mikochi roster + probe. The independent Mikochi checks above passed afterward. Full-profile + application acceptance remains failed; no UniFi requirement was relaxed. + +## Fleet rename — 2026-10-11 + +Current profiles/artifacts use `joey` (formerly beelzebot) and `donbot` +(formerly bender). Dated commands, VM identities and evidence paths below retain +the values actually used; they are not acceptance of freshly renamed machines. +Retained run manifests, provider ownership and vault entries were not mutated. +New image publication and a fresh joey boot/application check remain pending; +see [the joey migration plan](../plan/JOEY-MIGRATION.md). +Rename validation passed: offline locked workspace/all-target compile check, +strict all-target Clippy, formatting and all 372 workspace tests. Nine bootstrap +regressions (including joey/donbot image selection), both hardware refusal cases, +and changed-script ShellCheck passed. The Fedora CI base integration for joey +applied twice, issued no service start/restart on repeat apply and cleaned its +container (exit 0). Document/include links and the image recipe matrix/base-build +dependency resolve. Fresh named-VM acceptance and image builds were not run for +this rename; previous dated results remain historical evidence. + +## Live vault-backed root recovery — 2026-10-10 + +- Fresh native `clamps / vault-recovery`, SSH port 2202, explicitly selected + `--root-profile tpm`. Native Ignition created LUKS2/Btrfs; both bootstrap + rebases unlocked unattended and signed-uCore/base/user readiness passed. + Creation printed the expected `test vm recovery-key` follow-up. +- The first enrollment attempt failed while creating the vault write lock: the + synced parent directory was read-only for the operator despite a writable + database file. An owner-checked guest probe confirmed only the original TPM + slot remained. After the operator granted a narrow directory ACL, enrollment + succeeded and the test-scoped vault record was saved with verified state and + the matching LUKS UUID. No production recovery entry was created. +- Repeated `test vm recovery-key clamps vault-recovery` succeeded without + changing encrypted vault bytes. Guest inspection confirmed exactly two slots: + TPM plus independent printable recovery passphrase. +- A temporary Rust acceptance driver used the per-run lock, manifest/backend + ownership validation and guarded SSH transport. It flushed a Btrfs data marker, + checked a complete cold stop/start, then normal `test vm ready` passed with + unattended unlock. The boot ID changed; volume UUID, two slots and data marker + survived. Direct cryptsetup verification accepted the vault passphrase and + rejected an invalid passphrase. No transient recovery file remained in `/run`. +- Lifecycle disposal removed the test domain and its owned run artifacts; + encrypted vault bytes stayed unchanged, preserving the test archive. The + retained `clamps / smoke` VM remained running on port 2201. +- This establishes the live test-scoped vault enrollment/retry/cold-boot slice. + It does not establish physical production enrollment, header export or recovery + after simulated TPM loss; those remain pending. No workstation packages installed. + +## Recovery-key vault commands — 2026-10-10 + +- Implemented `secrets root-recovery-key HOST` for production and lifecycle-owned + `test vm recovery-key HOST INSTANCE` for separately scoped vault archives. + TPM VM creation prints the follow-up. Vault unlock uses the existing prompt/cache. +- Unit tests cover interrupted pending enrollment, reuse without rotation, + refusal of mismatched host/volume, persistence failure before enrollment, + verify-only failure for previously verified keys, CLI argument separation, + instance-aware unused-entry auditing, and real encrypted KDBX round trips + preserving recovery identity/state through atomic saves. +- Local fmt, pedantic Clippy, workspace tests, CI ShellCheck and Fedora base/repeat + apply passed. A private regular-file LUKS2 fixture accepted and verified the + 128-character printable recovery passphrase; no real host disks were touched. +- Not run: new SSH-to-TPM-VM vault archive acceptance or production enrollment. + No production vault key was created. Header export and physical recovery remain + pending. This slice does not simulate TPM loss or replace the earlier disposable + cold-boot acceptance; the two enrollment commands have separate key lifetimes. + +## Native optional TPM root — 2026-10-10 + +- A fresh `clamps / tpm-native` disposable VM used native `qemu:///session`, + q35 UEFI Secure Boot with enrolled keys, independent NVRAM and an emulated + TPM2 with SHA256 PCR bank. No workstation packages or physical disks changed. +- Native Ignition created LUKS2 beneath Btrfs; the FCOS 44.20260829.3.1 boot + and both unsigned/signed uCore bootstrap reboots unlocked unattended. The + final signed deployment was 44.20260913.3.2, kernel 7.2.5, following the + initial FCOS kernel 7.1.10. Secure Boot remained enabled. Clevis reported one + binding to SHA256 PCR 7. No signing/MOK-policy change was performed. +- `test vm encryption-check clamps tpm-native` passed: a generated independent + 64-byte recovery key opens this volume through the passphrase path; only TPM + and recovery keyslots remain; Btrfs `/var` and `/var/lib/data` share the same + filesystem, with `/data` mounted at the latter. Root label matches that UUID. + The flushed data marker and recovery key worked after an owned cold stop/start + without an unlock prompt. Private recovery directory is 0700; key and 16-MiB + header backup are 0600 and belong to the disposable run. +- Formatting, pedantic workspace Clippy, all 347 workspace tests, ShellCheck + for the adapter/acceptance helper/extracted storage helpers, bootstrap state + regressions, and Fedora base/repeat-apply container checks passed. Routine CI + needs no vault or provider API. New tests cover retained defaults, capability + rejection, firmware/TPM ownership, conflicting formatting, account-only input + staging, symlink refusal and wrong Secure Boot/PCR policy. +- `test smoke clamps --instance tpm-native` passed the real-systemd fixture: + repeated apply, native Podman/network behavior, stored test credentials/data + and another unattended reboot. A fresh `plain-native` VM also passed signed + bootstrap/base/user readiness; `/var` remained plain `/dev/vda4` Btrfs. + Both original test runs were disposed through the owner, including the + encrypted disk, TPM/NVRAM and private recovery artifacts. Recreation used a + different UUID and fresh firmware/TPM state, created a nonempty 0600 serial + log, reached SSH, and passed the final label/UUID data-subvolume guard. Its + serial log confirmed successful data-top preparation and data mount. This + final lifecycle probe was intentionally stopped before another complete + image bootstrap and disposed; the retained original smoke VM was untouched. +- Flatpak, physical hardware, + network-disconnected boot, post-bootstrap rollback, TPM-loss/PCR-negative + boot tests and production vault-backed recovery remain unverified. This is + the accepted disk-loss/removal scope, not authenticated whole-machine boot. + +## Structured SMTP vault audit — 2026-10-09 + +- `skillet secrets check --host clamps --environment test` opened the unlocked + vault and passed: seven required entries, zero missing/invalid, zero unused. + The shared `skillet/smtp` entry includes required Username/Password and custom + host/port/tls fields. The audit checks format and required STARTTLS policy; + it makes no provider calls and prints no field values. +- Shared metadata drives field validation, missing-field instructions and the + root checklist. SMTP is a shared fleet preparation requirement in prod/test; + credential-free synthetic agent profiles are excluded. Runtime SMTP delivery + is still planned. Existing single-value readers retain Password semantics. +- Formatting, pedantic workspace Clippy and all 311 workspace tests passed. + Added coverage for missing/invalid structured fields, protected custom reads, + exact-path ambiguity, deduplication and both-environment unused auditing. + Fedora base/repeat check passed with no service start/restart on the second apply. +- No container behavior changed; no additional VM or Mailjet delivery acceptance + is claimed. Postfix provisioning and verified-sender setup remain pending. + +## UniFi isolated restore completed — 2026-10-09 + +**Passed: cross-architecture backup restore, operator UI inspection, repeat +module apply, reboot/data persistence and owned disposable cleanup.** + +- Operator successfully logged in and confirmed the restored devices and + configuration look correct. This completes the authenticated inspection + left pending in the entry below; no password/database reset was needed. +- Restored state and configuration persistence checks are recorded below. + This remains module-only acceptance; production adoption and Site Manager + connectivity were intentionally excluded by the persistent isolation fence. +- Stopped `unifi.service` through the ownership-checked transport, then ran + `skillet test vm destroy clamps unifi-restore`; disposal succeeded. + Owned `test vm list clamps` shows only the existing smoke instance, and + the restore run directory is absent. The original and protected independent + backup copies remain present. Existing smoke VM was not modified. +- No production controller/device changes or outgoing recovery emails occurred. + +## UniFi restored-data persistence — 2026-10-09 + +**UI import and restored-data persistence verified; authenticated UI acceptance +and final disposable cleanup remain pending.** + +- Operator uploaded the supplied backup using the setup UI. Aggregate database + state now contains two sites, one network, four WLANs, three devices and one + admin, versus one WLAN/zero devices/zero admins before UI import. Source + inventory equivalence has not been independently verified. +- The actual UniFi module-only driver passed repeat apply before and after an + owned reboot. Network/WLAN collection hashes matched the post-import baseline + throughout. No import occurs during ordinary apply. +- After boot startup completed, UniFi was active/running with Result=success, + NRestarts=0 and local HTTPS HTTP 200. Rootful host networking and image user + `unifi` remain configured; data-root metadata was 999:999, mode 0750. +- The persistent fence was active after reboot; UniFi Requires/After still + includes it. Both-family drop policies remained installed, with 48 dropped + output packets observed. A subsequent public IPv4 HTTPS probe timed out. + Dedicated LAN/tailnet/IPv6 endpoint probes remain unverified. +- Operator cannot log in with the recorded password; password age is uncertain + and SMTP is not configured. No password reset, database credential edits or + cloud enrollment were attempted. Authenticated inspection is still pending. +- Retain the isolated `clamps-test-unifi-restore` VM for login investigation. + Do not remove its fence. Final controller stop/disposal is deferred until the + operator completes that inspection; independent private backups are retained. +- This is module-only acceptance, not full-host/device/Site Manager acceptance. + No production controller or device was modified. Only documentation changed + in this follow-up; existing code validation is recorded below. + +Email delivery follow-up: [server email plan](../plan/CLAMPS-EMAIL.md). + +## UniFi restore preparation — 2026-10-09 + +**Preparation passed; backup restore remains unverified.** + +- User supplied an October 1 `.unf` Network backup, 212,880 bytes. Its original + automatic-backup filename identifies UniFi 10.0.162. The private original is + retained and ignored by Git; a mode-0600 working copy is outside the checkout + in a mode-0700 directory. Production SSH rejected this workstation's key; + current production runtime/version and source inventory counts are unverified. +- A dedicated native `clamps-test-unifi-restore` VM on SSH port 2202 passed + fresh signed-image, base and user-environment readiness. The retained smoke + VM's configuration was untouched. No provider credentials were provisioned. +- The real UniFi recipe was applied alone using the `acceptance_apply` test + driver. Observed package `10.0.162-32076-1`, MongoDB 3.6.8 x86_64, rootful + host networking and numeric data-root ownership 999:999, mode 0750. + Local HTTPS returned 200. +- First isolation attempt blocked SSH before any controller or backup import; + that empty VM was disposed successfully and recreated. The revised nftables + policy uses standard filter priority, preserves loopback/SSH/DHCP/IPv6 + neighbour discovery, and drops other input/output/forwarding for both IP + families. Its empty-guest rollback timer was stopped before backup delivery. +- The revised policy passed fresh management access. Direct HTTPS to a public + IPv4 address timed out, with the output drop counter showing blocked packets. + An empty-controller reboot preserved SSH and both active fence/controller + services; the fence remained installed and counted 67 dropped output packets + when inspected. The controller's unit Requires/After includes the fence. + This is not an individual live IPv6/LAN/tailnet reachability probe. +- The provided backup was copied privately only after those checks. Installed + command help lists `restore `; attempting it while live reports + `Already running`. A stopped-controller restore using the same image/data + paths in a one-off `network=none` container exits unsuccessfully with a + missing Spring bean (`com.ubnt.service.system.status.o0OO`). No successful + import or backup validity is established. +- Normal service restarted behind the fence and HTTPS returned 200. Database + aggregate observations were two sites, one network, one WLAN, zero devices + and zero admins. Awaiting setup/UI restore via owned localhost forwarding; + post-restore configuration, repeat apply, reboot/data survival and final + VM cleanup remain pending. Site Manager/adoption remain cutover checks. +- Formatting, pedantic Clippy and all 307 workspace tests passed, including + compilation of the UniFi-only driver. Cargo reported the deployed driver + artifact at the selected musl release path. Fedora base/repeat validation + and Butane bootstrap regression checks passed. No workstation packages + were installed. + +Private diagnostics are under `/tmp/skillet-unifi-restore-isolation/`; readiness, +image-pull and fence preparation logs use `/tmp/skillet-unifi-*` paths. Do not +publish their contents or treat a failed restore command as acceptance. + +## Owned localhost UI forwarding — 2026-10-08 + +- `test vm ssh HOST INSTANCE --forward LOCAL:GUEST` retains the existing run + lock and domain UUID/disk checks. Both listener and destination are IPv4 + loopback; duplicate ports and malformed/nonlocal targets are rejected. +- Native live check used an interactive, read-only session to the retained + `clamps-test-smoke`, forwarding workstation port 18443 to guest port 22. + Curl received `SSH-2.0-OpenSSH_10.2`; its two-second timeout was expected + because no SSH negotiation was sent. On successful shell exit the local + listener disappeared. Guest configuration was not changed. +- SSH multiplexing is disabled for forwarded sessions so a persistent master + cannot retain the listener. Bind failure is fatal. Unit tests cover the + arguments, invalid ports/addresses, duplicate ports and CLI parsing. +- Formatting, pedantic workspace Clippy, all 307 workspace tests and the + Fedora base/repeat container check passed. Flatpak backend acceptance remains + parked; this live check used native libvirt. + +# Clamps VM acceptance log + +## Datadog NPM/USM and lab isolation, 2026-10-08 + +- Created fresh `clamps-test-datadog`, native `qemu:///session`, SSH 2202, from + `69e908d`. Signed boot, base/user-environment readiness, Pi-hole/Tailscale/full + application provisioning and `--with-datadog` succeeded with the cached vault. + Caddy and DDNS were outside this scoped run. The retained smoke VM was untouched. +- Baseline Agent had `SYS_ADMIN` but no `PERFMON`. A software perf-event syscall + returned ENOSYS (38); tracer logs reproduced unsupported perf attachment and + read-only fallback tracing failures. The installed seccomp profile explicitly + gates the perf-event allow rule on `CAP_PERFMON`. +- Deployed the working-tree fix through owned `test vm update`; original capture + evidence was preserved. Added `PERFMON` only for caller-selected network + monitoring. Effective default seccomp and read-only debugfs remained in place; + no privileged mode or extra host installation. The syscall then returned a + valid descriptor (4, errno 0). NPM/USM network tracer, event monitor and discovery + modules started. Kernel: `7.2.5-200.fc44.x86_64`; SELinux enforcing; BIOS VM, so + Secure Boot was unavailable and remains unverified. +- System-probe connection endpoint returned HTTP 200 and 63 connections. A private + Syncthing health request returned OK; six tracked connections involving port + 8384 had nonzero byte counters, HTTP classification and HTTP aggregates. These + are connection records, not a count of distinct requests. USM reported Running. +- Agent reported `env:test` / `region:lab`, successful submissions (56 core + forwarder transactions at one sample), no Autodiscovery errors, one Syncthing + HTTP check, three process checks, one Btrfs and one systemd check. Repeat + Datadog apply preserved every container ID; Agent `NRestarts=0`. +- Named fixture smoke and guarded reboot/readiness recovery passed. After reboot, + without manual Agent restart, `PERFMON`, default seccomp, USM Running, tracer + polling, tags and the same application check counts were present; successful + submissions reached 92 in the sampled boot. A new health request yielded ten + bridge records with HTTP classification, HTTP aggregates and nonzero byte + counters. Agent remained active/running with `NRestarts=0`; no ERROR logs in + the sampled two-minute steady-state window. Startup socket/remote-config + retries before warmup were transient. Readiness restores original captured + binaries by design; this reboot used the explicitly deployed fix at boot. +- Provider monitor read-back before/after reboot: both all-host monitors retain + their test exclusions and return zero test-host groups. Host-tag read-back + returned HTTP 403 with the monitor-only key; remote tag receipt and PagerDuty + incident delivery were not independently verified. Appropriate host-read + access is still needed for the former. +- Formatting, pedantic workspace Clippy, workspace tests and Fedora base repeat + apply passed. Regression assertions cover the explicit capability and retained + default seccomp/read-only debugfs; the non-network caller has no added caps. + Smoke evidence was generated under `/var/lib/skillet-smoke/`; private local + command logs are under `/tmp/skillet-datadog-*.log`. +- Owned Tailscale identity cleanup and VM disposal passed. The run directory + was removed and the retained smoke VM remained running. + + +## Datadog monitor setup, 2026-10-08 + +- Verified the API/application key pair from KeePassXC using + `skillet/datadog/api-key` and `skillet/datadog/monitor-setup-key`. + Key-pair validation and the six-monitor inventory returned HTTP 200. +- The all-host Agent no-data and NTP service-check queries now include + `.exclude("env:test")`. Both provider validations succeeded before mutations; + query-only updates returned HTTP 200 and read-back confirmed the exclusion. +- Compared options, messages, names, types, tags and priority before/after; + notification routing and production no-data policy were preserved. Other + monitors have explicit production targets and were not changed. Historical + test-group reevaluation and actual PagerDuty notification behavior were not + observed in this check. +- Complete original monitor snapshots are in a private local temporary + directory outside the repository. The setup key was used only in memory on + the workstation; no credentials or real provider identifiers were committed. +- Updated the secret inventory to the user's chosen setup-key path. No VM + operations or new Agent submissions were performed. Live region-tag + ingestion remains pending. + +## Datadog test alert isolation, 2026-10-08 + +- Credential delivery retains `env:prod`/`env:test` and now derives production + region from the canonical host caller (`ftwo` for Clamps). Every test uses + `region:lab`; conflicting template region tags are replaced. +- Formatting, pedantic workspace Clippy, workspace tests and the Fedora base + scenario passed. New offline tests cover production/test tag policy, different + caller regions, retained unrelated tags, conflicting template regions, + idempotent rendering, invalid inputs and stdin-only delivery. Fedora repeat + apply issued no service start/restart. +- At this slice's validation, provider monitor edits were pending. The + subsequent monitor setup above completed the query exclusions using the + optional workstation application key; ingestion credentials alone cannot + administer monitors. +- Live Datadog tag/submission acceptance was deferred to avoid generating + unwanted notifications before monitor setup. Region-tag ingestion remains + unverified. No VM + was created, provisioned or destroyed for this slice. + +## Datadog automatic boot discovery, 2026-10-08 + +- Implemented Agent `After=` ordering from the existing caller-selected + monitored unit list. No application `Wants=` or `Requires=` was added; missing + optional credentials do not activate their consumers. Reject Agent self-ordering + before any mutation. No changes to container privileges or eBPF policy. +- Formatting, pedantic workspace Clippy, all 301 workspace tests and Fedora + base integration passed on 2026-10-07. Repeat base apply issued no service + activation. The actual Quadlet compiler retained the ordering. New offline + regressions cover caller-provided units, ordering versus activation, and + self-dependency rejection; routine CI needs no vault or external runtime. +- Created fresh `clamps-test-discovery` on port 2202 from the working-tree fix + based on `869c438`; the retained smoke VM on port 2201 was untouched. The + source capture includes the fix. Signed boot/readiness and Pi-hole, Tailscale, + DDNS and Datadog provisioning succeeded. Local debug image hashing was stopped + before VM definition and the recorded preparation resumed using release CLI. +- Initial status: one Syncthing HTTP check, four process instances, no current + check/submission errors. Direct owned reboot automatically restored the same + checks without an Agent restart. Startup timestamps confirmed the five active + monitored containers started before the Agent. Status was sampled again after + initial Agent warm-up; an early probe before auth-token creation was inconclusive. +- Repeating the Datadog credential phase preserved every container ID. + `test smoke clamps --instance discovery` passed all fixture cases across a + second reboot and readiness recovery, using release binaries. Smoke initially + refused the Started lifecycle state after direct reboot; explicit `test vm + ready` completed before retry. `--with-applications` was not used. +- After the second reboot: one HTTP and four process checks, no current check, + Autodiscovery or submission errors; Agent `NRestarts=0`. No manual Agent + restart was used during either recovery check. Caddy was not provisioned; + later application-start/event reconciliation is not established by this test. +- eBPF remains the separate unresolved acceptance gap from 2026-10-07. This + slice changes boot ordering only; it does not establish probe/kernel/Secure + Boot compatibility or richer application metrics. +- Owned Cloudflare/Tailscale cleanup and VM disposal completed successfully. + The run directory was removed; the retained smoke VM remained running. + Private zones, tokens and status payloads were kept out of tracked files. + +## DDNS and Datadog named-VM acceptance, 2026-10-07 + +- Used separate `clamps-test-monitoring`, instance `monitoring`, SSH port 2202, + on native `qemu:///session`. The retained `clamps-test-smoke` stayed running + on port 2201. KVM is available outside the sandbox. Tested working-tree + changes based on `bbfcae8`; the run's original capture preceded live fixes. +- Shared Datadog API-key/public site change passed the six-required-entry vault + audit, formatting, pedantic workspace Clippy, all 299 workspace tests, and + Fedora base integration (two applies, no repeat service activation). The + actual Quadlet generator accepted the Agent and Autodiscovery fixtures. +- Provisioned Pi-hole, Tailscale, DDNS and Agent using cached vault access and + the ownership-checked CLI. DDNS published one allowlisted, marked public A + record and verified publication on repeat provisioning and after reboot. + Disposable reprovisioning replaces its bounded token by design; it does not + establish token reuse. Ordinary repeat apply preserved all container IDs. +- Live acceptance found and fixed three defects: socket-as-directory volume + convergence, HTML escaping of Quadlet label quotes, and replacement of the + Agent's built-in integration directory. Mounted the socket's parent and + individual integration directories; machine templates preserve literal text. +- Confirmed Agent 7.84.1, VM hostname, SELinux enforcing, API key validation, + successful submissions with no submission errors, Btrfs/network/systemd/SSH + checks, default host/container checks, Syncthing HTTP and four process checks. + Configured checks had no current errors; Docker had an initial historical + error but no current error/warning. Agent listeners observed on loopback + 5000/5001; no 8125/8126 listener was observed. Dashboard/graph verification + and detailed host metric-value comparison were not performed. +- `skillet test smoke clamps --instance monitoring` passed all synthetic + fixture cases across reboot and readiness. It did not run `--with-applications`. + Readiness reinstalled original captured binaries/definitions; restored the + current build with `test vm update`, reconverged phases, then used an owned + direct reboot for the monitoring recovery check. +- DDNS and Agent units and credential apply phases recovered successfully. + Host checks and submissions resumed. Application Autodiscovery did not + recover automatically: HTTP/process instances were absent despite running + labelled containers. A manual Agent restart restored one HTTP and four + process checks. Automatic application monitoring recovery remains failed. +- eBPF network tracing remains failed: probe attachment reported “function not + implemented”, and fallback kprobe registration could not write read-only + tracing files. Secure Boot state was not verified. Do not claim eBPF parity; + the active container and successful host network check do not prove it. +- `test vm destroy clamps monitoring` completed Cloudflare owned-record/token + cleanup, Tailscale cleanup and VM disposal. The run directory was removed; + native libvirt listed only retained `clamps-test-smoke`. No production + handover was attempted. Unrelated provider-resource preservation has offline + ownership coverage; a full live provider inventory comparison was not done. +- Next: fix boot-time Autodiscovery reconciliation and review eBPF kernel, + seccomp and tracing access; see `../plan/CLAMPS-DATADOG.md`. Private ping, + richer application metrics and production acceptance remain separate work. + +## Datadog offline validation, 2026-10-07 + +- Approved rootful Agent recipe and delivery implemented; Clamps selects it + separately from ordinary full apply. No live telemetry was enabled here. +- Formatting, pedantic Clippy, all 291 workspace tests, the Fedora base + integration scenario (two applies, repeat with no service activation), and + all eight Butane bootstrap regressions passed on the musl target. +- The installed Quadlet generator accepted the actual Agent recipe emitted by + the offline `skillet_datadog` `agent` example. Generated ExecStart retains + host PID/cgroup/network namespaces, optional eBPF capabilities, read-only + mounts without host relabeling, and native Podman environment-secret + references. No literal API key or published ports appear in the Quadlet. +- Unit regressions cover input validation, storage prerequisites, independent + credential phase, environment tags, stdin-only delivery, repeat apply, + secret rotation, and retry after a failed restart. +- Official pinned image metadata reports Agent 7.84.1. The image's startup + requires `DD_API_KEY`; native Podman environment-secret injection is used. +- Live named-VM Agent acceptance was unverified at this stage. The absent + `/dev/kvm` observation was inside the sandbox; a later native workstation + check confirmed KVM and session libvirt are available. Agent status/version, socket discovery, actual host/Btrfs + observations, systemd/SSH checks, eBPF under uCore Secure Boot/SELinux, + listener exposure, reboot recovery, and Datadog ingestion still need tests + with the shared `skillet/datadog/api-key` and public template site configuration. + No vault unlock was attempted for this Agent validation. + +## Disposable DDNS attempt and cleanup, 2026-10-06 + +- Built the current Skillet CLI with Rust 1.99 and created a separate + `clamps-test-ddns` VM on the native session libvirt runtime, leaving the + retained `clamps-test-smoke` VM untouched. The guest completed signed boot + and Skillet readiness. +- Ran `skillet test vm provision clamps ddns --with-ddns`. Pi-hole and + Tailscale provisioning completed, then DDNS preflight stopped because the + test KeePassXC entry + `skillet/environments/test/hosts/clamps/cloudflare/ddns-config` is absent. + The failure occurred before Cloudflare mutation: no DDNS child token, + ownership journal, or DNS record was created. +- Ran `skillet test vm destroy clamps ddns`. Skillet removed the recorded + Tailscale identity and disposed the VM and its temporary SSH material. No + production DNS or other VM was touched. +- Live DDNS record creation, updater convergence, and exact-record cleanup are + still parked until the test-only DDNS config entry is added to KeePassXC. + The disposable ownership/create/cleanup paths are covered by workstation + fake-provider tests; this attempt is not live DDNS ownership acceptance. + +## DDNS lifecycle local validation, 2026-10-06 + +- Passed `cargo fmt --all -- --check`, `git diff --check`, strict workspace + Clippy, and `cargo test --workspace --all-targets --offline` using Rust 1.99 + and the configured x86_64 musl target. The full suite passed; workstation + coverage now includes 77 tests. +- Added a Cloudflare API fixture regression proving cleanup refuses an extra, + unjournaled record carrying the disposable marker before sending any DELETE. + Disposable provisioning tests cover bounded credentials, exact configured + names, journal identity, mismatch refusal, cleanup/retry and safe errors. +- Did not run live DDNS container/record acceptance: test configuration was + missing, so no DDNS guest credential or service was activated. The VM attempt + and recorded Tailscale cleanup are described above. + +## DDNS local implementation checks, 2026-10-06 + +- Evaluated upstream 2.2.0 (source revision + `7c6d5b43c1d400f4977f2b25bd44d784bb601ae3`) and its multi-platform image index + `sha256:5f2471be9efd9f0c95f973645cc87f05d501020ded94d380a2120fbfdf812d3d`. + Image inspect confirmed `/cloudflare-ddns --repeat`, working directory `/`, + and default root identity. Ran the exact image as `0:0`, `--network=none`, + `--dry-run` with a dummy JSON Podman secret at `/config.json`, mode `0400`. + The binary accepted the config and skipped updates after IP discovery failed, + as expected without networking. The container and dummy secret were removed. + This verifies consumer parsing/mount access, not DNS updates or rootful uCore. +- DDNS and canonical profile tests cover caller selection, phase credentials, + missing storage/input, no published ports, no plaintext config files, repeat + apply, rotation, and failed-activation retry. Fake provider/transport tests + cover persistent token reuse, vault conflicts, saving/revocation failure, + interrupted delivery retry and record takeover/namespace protection. Routine + CI uses no vault, live Cloudflare, IP-discovery service or hypervisor. +- Workspace musl tests, pedantic Clippy and formatting passed with Rust 1.99. + Fedora base integration applied twice without repeat service actions and + removed its container. Bootstrap regression tests passed all eight cases. +- Superseded by the later disposable DDNS attempt recorded above. That attempt + created and disposed a named uCore VM using the native session libvirt runtime; + test-only config was missing, so live DDNS records and provider cleanup remain + unverified. No production activation or old-writer handover was attempted. + +## CI lint recovery, 2026-10-06 + +- [CI run 37440317671](https://github.com/gbagnoli/devmachine/actions/runs/37440317671) + at `c8f5d7e` failed strict Rust 1.99 Clippy's `assert_is_empty` lint and + ShellCheck SC2015 on the desktop llama helper's intentional best-effort stop. + Replaced affected collection assertions with equality assertions that expose + actual values on failure. The workflow excludes only SC2015 for that helper + and still runs its other ShellCheck checks; desktop runtime files are unchanged. +- After the user updated local Rust to 1.99, passed the musl-target commands + `cargo fmt --all -- --check`, + `cargo clippy --workspace --all-targets --offline -- -D warnings`, + `cargo test --workspace --all-targets --offline`, and + `cargo run --bin skillet -- test run beezelbot --phase base --image fedora:latest`. + Used the existing cross compiler on PATH and existing Cargo build cache. + The Fedora container applied successfully twice without repeat service actions + and was removed afterward. The complete workspace suite, including provider + HTTP fixtures, passed outside the sandbox's localhost-listener restrictions. +- The exact updated workflow ShellCheck command and + `bash ublue/butane/tests/bootstrap.sh` passed. Remote CI must rerun after push; + Syncthing live VM acceptance remains parked. No vault/provider/VM contact was + required by these local checks. + +## Migration documentation audit, 2026-10-06 + +- Reconciled the refactor/migration status against current source and recorded + acceptance. Refactor code is committed; Syncthing ownership-fix VM acceptance + stays parked until Flatpak libvirt access returns. Remote CI remains pending. +- Added the source-level Rupik parity checklist and DDNS implementation plan. + The active Chef path includes DDNS and Datadog. Chef's Tailscale exit-node + advertisement has no corresponding option in current Skillet composition. + The legacy NAT/second updater recipe is outside the default include path; + its effective live state remains unknown. +- Recorded the user's decision to retain Datadog. DDNS remains planned; + Datadog port and isolated UniFi backup/restore follow it. No provider mutation, + guest operation, production data transfer, or new runtime acceptance occurred. +- Prior ownership-fix validation passed formatting, GNU-target strict workspace + Clippy, and Syncthing/host-profile/Podman tests. The full workspace run had 10 + provider-fixture failures (`Operation not permitted` creating local HTTP + listeners) in this sandbox. Do not treat that run as a full-suite pass. + +## Interrupted reboot checkpoint recovery, 2026-10-05 + +- Added `bash ../skillet/integration_tests/interrupted-reboot-recovery.sh HOST INSTANCE` + to exercise the persisted checkpoint and recovery across separate Skillet + processes. The first smoke run intentionally exits after recording + `Started`, before asking libvirt to reboot; the script verifies that phase, + runs `test vm ready`, verifies `Ready`, then runs an ordinary fixture smoke + reboot. The fault point is hidden from normal CLI help and is only available + to the disposable-VM smoke command. +- Passed on the existing `clamps/smoke` VM without the optional application + check. The interrupted run remained `Started`; separate readiness restored + `Ready`; the normal fixture reboot passed and the VM returned to `Ready`. +- The optional `--with-applications` variant also reached the injected + interruption and recovered readiness, but its final application check + failed because `/var/lib/data/syncthing` was `core:core` after reboot while + the profile declares `giacomo:giacomo`. Syncthing's journal also showed + permission denied opening its config lock. The profile now separates the + host data owner from container UID/GID 1000 and maps that container identity + to the host account through Podman's typed namespace mapping. Regression + coverage is added; live acceptance of this fix is parked until workstation + access to the Flatpak libvirt backend returns. +- Passed workspace formatting check, `cargo test -p skillet --no-run`, + `cargo clippy -p skillet --all-targets -- -D warnings`, ShellCheck for the + recovery script, and `git diff --check`. The complete workspace suite and + remote CI have not been rerun for this change. + +## Refactor follow-up, 2026-10-05 + +- The first live UI retry exposed a host/Podman rootless networking mismatch: + the VM could not connect to Caddy through `127.0.0.1`, while direct access to + Caddy's dynamically inspected bridge address returned the intended 403. The + denial probe now discovers the current Caddy container IPv4 at runtime. + Disposable provisioning then passed and reused its journaled state. +- Passed `cargo run --release -p skillet -- test smoke clamps --instance + smoke --with-applications` on the provisioned named VM. After fixture reboot, + Skillet verified active Pi-hole, Tailscale, Syncthing, UniFi, + `skillet-btrbk.timer`, and Caddy; container network/mount state, declared + ownership, listeners, Pi-hole and Tailscale health; unchanged runtime after + repeat apply; and marker persistence across reboot. The VM returned to + `Ready`. Application acceptance derives from the host profile. +- Passed `cargo fmt --all -- --check`, strict workspace Clippy, workspace + all-target tests, the CI Fedora container command + (`cargo run --bin skillet -- test run beezelbot --phase base --image + fedora:latest`), bootstrap state regressions, and the workflow ShellCheck + command. Exact commands are in `../AGENTS.md` and `../skillet/AGENTS.md`. +- Existing `clamps/smoke` ownership was verified with + `cargo run --release -p skillet -- test vm status clamps smoke`. `test vm + update clamps smoke` delivered current binaries, and `test vm ready clamps + smoke` passed. Then `cargo run --release -p skillet -- test smoke clamps + --instance smoke` passed fixture convergence, pre/post-reboot assertions, + new boot detection, captured binary verification, and readiness recovery. +- Injected tests cover active services, container network, bind paths, named + versus numeric host ownership, listeners, health probes, repeat-apply + identity, data mount, and reboot persistence. Live checks corrected the + initial ownership expectations: Pi-hole may change writable directory owners, + and Syncthing's declared host account differs from its container UID. +- Remote CI is pending until these working-tree changes are pushed. Production + renewal, external-network denial, and interrupted-reboot fault injection + remain outside this local acceptance result. + +## Refactoring and disposable UI revalidation, 2026-10-04 + +- Passed `cargo fmt --all -- --check`, strict workspace Clippy, and + `cargo test --workspace --all-targets --offline`. The CI Fedora repeat-apply + command passed: `cargo run --offline --bin skillet -- test run beezelbot + --phase base --image fedora:latest`. +- A fresh `clamps-test-generic-ui` VM was created on SSH port 2202 and reached + `Ready` through signed uCore boot, bootstrap, and user-environment checks. + `cargo run --offline --bin skillet -- test smoke clamps --instance + generic-ui` passed its real systemd/Podman apply, failure-recovery, and + reboot-persistence cases. `test vm destroy clamps generic-ui` removed the + disposable VM and its recorded Tailscale device. +- Live provisioning exposed two fresh-host issues and they are fixed in code: + staged Butane `mode` values are normalized to numeric values before compile, + and Pi-hole credentials are delivered before Tailscale so the full-apply + unit receives its complete credential prerequisites. Tailscale enrollment + now treats a confirmed absent container as unenrolled while keeping other + Podman probe failures fail-closed. Unit coverage and all local checks pass. +- The corrected environment paths were exercised from a fresh credential-empty + VM. `test vm provision clamps generic-ui --with-ui` completed using the test + KeePass group. Caddy's guest journal confirmed successful certificate + issuance from `acme-staging-v02.api.letsencrypt.org-directory` for all three + declared UI names. The provisioning probes also received the configured 403 + tailnet-denial response over HTTPS for the UI names. +- `test vm destroy clamps generic-ui` completed Cloudflare ownership cleanup, + removed the disposable Tailscale device, and deleted the VM. No production + DNS or credentials were changed. A separate tailnet-client URL check, + cryptographic SAN/chain verification, and denial from a separate outside + network remain unverified; staging certificates are intentionally untrusted. + +## Recoverable VM creation intent, 2026-10-03 + +- Creation now writes a mode-0600 Rust manifest with a generated domain UUID + before installer staging. Repeated preparation resumes only the same + `Preparing` intent with matching backend, runtime, SSH port, and source + revision; ambiguous and progressed directories are refused. +- The UUID is embedded in native libvirt XML and passed to Flatpak `virt-install`. + Native definition is recorded as `Defined` before start. Failed start no + longer undefines the guest. Re-running create checks UUID and disk ownership, + starts a verified stopped domain, or resumes staging when no domain exists. + Captured hashes and `Started` are written after the helper succeeds. +- Local regression tests cover intent reuse/conflict, private run-directory + permissions, and phase transitions. Passed `cargo fmt --all --check`, + offline workspace all-target Clippy with `-D warnings`, offline workspace + tests/build, the CI Fedora repeat-apply container integration, ShellCheck for + both changed helpers, CLI help, and `git diff --check`. No VM, libvirt domain, + vault, or provider resource was contacted for this change. + +## Native XML rendering, 2026-10-03 + +- Native domain XML is rendered in `skillet_vm` with `quick-xml`, written + atomically as a private file under the manifest's run directory, and consumed + by the existing `virt-xml-validate` and `virsh define` steps. The renderer + carries the manifest UUID, exact disk and Ignition paths, QEMU fw_cfg input, + passt loopback SSH forwarding, serial console, and original machine profile. + Attribute escaping is checked using a path containing `&`. +- Removed the Python XML generator and the Python 3 VM preflight requirement. + Backend/emulator selection and subsequent define/start remain in Bash for now; + Flatpak still delegates VM construction to `virt-install`. +- Passed format, offline workspace all-target Clippy (`-D warnings`), workspace + tests (51 VM tests), build, ShellCheck for both changed helpers, and the CI + Fedora repeat-apply container integration. The renderer test validates the + generated XML against the same name, UUID and disk ownership parser used by + status inspection. No VM/libvirt domain was contacted. + +## Rust readiness migration, 2026-10-03 + +- The CLI's `test vm ready HOST INSTANCE`, create's readiness phase, + `test-vm HOST ready INSTANCE` and the retained RUN_DIR helper now share + Rust readiness. It uses the run lock, recorded SSH target, ownership checks + after waits, captured binaries, bounded probes and explicit profile boot + expectations. It does not use embedded shell or `systemctl --wait`. +- Preserved signed-origin, base-unit, artifact bytes/metadata, SELinux, + resolver/DNS, masked-unit, Homebrew and dotfiles acceptance checks. Both + binaries are verified again after apply. Ready/deployed state is saved only + after all checks and final domain ownership validation. +- Forty-nine VM tests pass, including success/retry, SSH/signed-boot timeout, + bootstrap/apply failure, ownership loss, capture tampering, signed-origin + refusal, guest security/user-environment failures and directory-based legacy + identity lookup. Failure retains private diagnostic logs; ownership loss + refuses further guest contact. The temporary boot policy lookup remains to + be consolidated in workstream 2. +- Passed static-musl `cargo fmt --all --check`, offline workspace all-target + Clippy with `-D warnings`, offline workspace tests/build, the routine Fedora + base integration (both applies; no repeat service start/restart), readiness + CLI help, ShellCheck for both changed helpers, and `git diff --check`. +- Live native/Flatpak readiness, rebase and retained-run import remain + unverified. The user's VM and provider resources were not contacted or + modified. No vault unlock or workstation installation was needed. + +## Readiness retry recovery, 2026-10-03 + +- A readiness retry now persists `Started` before guest work, so failure after + updating a previously `Ready` run cannot leave the prior success checkpoint + in place. Only full acceptance plus a final owner check persists `Ready` and + the captured deployed hashes. +- A guarded transport checks domain identity before and after every guest + command and upload, including systemd service starts. A changed owner halts + the sequence before subsequent service operations or diagnostics. +- Regression tests begin timeout/bootstrap/apply retry cases in `Ready` and + verify the saved phase becomes `Started`; an ownership-loss case during the + Brew start confirms dotfiles is not started afterward. Full validation is + complete: formatting, all-target offline Clippy, workspace tests/build, + Fedora repeat-apply integration, readiness CLI help, ShellCheck for both VM + wrappers and `git diff --check` pass. Live VM acceptance remains pending. + +## Retained VM update migration, 2026-10-03 + +- Rust now builds both binaries from Cargo-reported paths, checks recorded + domain ownership, and delivers through the shared noninteractive SSH adapter. + Updates and disposal use the same per-run lock. Original captured hashes are + preserved; deployed hashes are saved only after both installed files verify. +- Adapter tests cover repeated delivery without upload/install, interrupted + installation and retry, hash mismatch, cleanup-phase refusal, shared lock + contention and rejection of nonregular manifest files. Forty VM tests pass. +- Passed `cargo fmt --all --check`, offline workspace all-target pedantic + Clippy with `-D warnings`, offline workspace tests and static musl build. + Passed `skillet test run beezelbot --phase base --image fedora:latest`: + both applies succeeded and the repeat issued no service start/restart. + ShellCheck for `bin/test-vm` and `git diff --check` passed. +- No live VM was contacted or changed: VM creation is running in the user's + other panel. Live update, boot, and migration acceptance remain unverified. + +## VM refactoring foundation, 2026-10-03 + +- Source slice: after roadmap commit `471068f`, added `skillet_vm` manifest + types and shared CLI identity validation. No guest service behavior changed. +- Passed on the static-musl target, with the existing cross compiler on PATH: + `cargo fmt --all --check`, + `cargo clippy --offline --workspace --all-targets -- -D warnings`, + `cargo test --offline --workspace`, and `cargo build --offline`, followed by + `./target/x86_64-unknown-linux-musl/debug/skillet test run beezelbot --phase base --image fedora:latest`. + The isolated Fedora container passed both applies with no repeat start/restart + and was removed afterward. Eight new manifest tests cover strict import, + preserved journals/hashes, metadata permissions and ownership/path refusal. +- Initial GNU-target checks under `/tmp` ran out of space. Only this task's + temporary build directory was removed; subsequent builds used `/home`. + The cross compiler was present outside the restricted filesystem view; no + software was installed. +- Live creation/import/readiness/destruction checks are deferred. The user is + creating a smoke VM in another panel; it and its artifacts were left alone. + No vault, Cloudflare or Tailscale request was needed for this slice. + +## Shared SSH transport contract, 2026-10-03 + +- Source slice: follows `67d8f54`. Added `GuestTransport` and `SshTransport` + for literal executable/argument requests, explicit target and host-key policy, + noninteractive SSH/SCP, and stdin delivery. No live SSH command was run. +- Subprocess I/O is memory-backed with concurrent pipe readers/writer, bounded + output and complete-operation timeout. Tests cover a 1 MiB round trip, a + blocked stdin writer, unconsumed input on nonzero exit, key/target validation, + shell metacharacter quoting, upload traversal refusal, DNS/IPv6 targets and + explicit host-key enrollment versus verification. Thirty-five VM tests pass. +- Passed static-musl workspace format, all-target pedantic Clippy, + workspace tests/build and the routine Fedora base container integration. + Readiness/delivery migration and live transport acceptance remain pending; + no vault unlock, provider mutation or change to the user's VM was attempted. + +## Interrupted local artifact removal, 2026-10-03 + +- Follow-up to `d799ace`: disposal now removes application artifacts before + deleting authoritative/legacy recovery records. A deterministic local + removal failure leaves both the versioned manifest and original identity/ + hash records readable; retry finishes artifact deletion. +- Twenty-seven VM tests, workspace tests, all-target pedantic Clippy, + formatting, routine Fedora container integration and whitespace checks pass. + This is local fault injection; live VM disposal remains deferred. + +## Rust catalog and Cargo artifact selection, 2026-10-03 + +- Source slice: follows `d799ace`. Both list entry points delegate to Rust + catalog/inspection, without rewriting manifests. Cargo JSON selection now + supplies the container runner. Twenty-six VM tests cover catalog availability, + partial-run discovery, non-default artifact paths/profiles, missing/duplicate + artifacts, and existing ownership/cleanup cases. +- Passed static-musl formatting, workspace all-target pedantic Clippy, + workspace tests/build, helper ShellCheck and whitespace checks. Also passed: + `CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target ./target/x86_64-unknown-linux-musl/debug/skillet test run beezelbot --phase base --image fedora:latest`. + The runner selected the cache directory's Cargo-reported host binary rather + than a conventional workspace path; both applies passed and cleanup completed. +- Live VM catalog/runtime checks and retained-run lifecycle acceptance remain + deferred. No VM mutation, vault prompt or external provider call was attempted. + +## Rust VM inspection and disposal, 2026-10-03 + +- Source slice: follows `544b8e5`. `test vm status` and both destroy entry + points use `skillet_vm`. Native and Flatpak command contracts are covered + without contacting libvirt. Destructive actions use the recorded UUID. +- Twenty-one VM regression tests pass, including absent guests, failed runtime + queries, reused names/renamed UUIDs, wrong disks, literal subprocess arguments, + bounded command timeout, concurrent disposal, external cleanup failure/retry, + and local cleanup retry after the external phase was already completed. +- Passed the same static-musl workspace format, all-target pedantic Clippy, + workspace tests/build and Fedora base integration commands listed above; + also passed `shellcheck -x butane/bin/test-vm` and `git diff --check`. +- No live VM mutation, retained-run import, provider call or vault unlock was + attempted. Live cleanup and Flatpak runtime acceptance remain unverified. + The user's concurrent VM creation was left untouched. + +Status: clean disposable VM create, readiness, and the real Skillet smoke +scenario passed on 2026-09-26. A subsequent fresh VM reached the signed +deployment and installed the full user environment. Bootstrap interruption +recovery remains unverified. + +## Shared bootstrap and user environment + +- The host-independent `ucore-bootstrap.service` read clamps' image reference + from `/etc/ucore-bootstrap-image` and completed both rebases on a fresh VM. +- `brew-install.service` and `dotfiles-install.service` completed with status + 0. The `core` Brewfile bundle check passed, and the Brewfile and SSH key + symlinks resolve into the dotfiles checkout. Completion markers survive + reboot and allow an interrupted installation to retry. +- The first readiness run failed only because its symlink check compared a + resolved `/var/home` path with an unresolved `/home` path. After correcting + the comparison, `test-vm clamps ready smoke` passed on that same VM. +- `test-vm clamps reboot smoke` followed by `test-vm clamps ready smoke` passed; the + temporary SSH key and completion markers survived the reboot. +- The VM interface is now `test-vm `, with `list` + also accepting a host or listing all hosts. The Skillet wrapper accepts + host and instance for `test vm create` and `test vm destroy`. + `test-vm clamps status smoke` confirmed the retained domain. `test-vm list` + showed clamps as available, smoke as running, a prior incomplete run as + invalid, and beezelbot as unavailable because its Butane file is absent. + Other hosts still need their own Butane configuration and host artifact. +- A second fresh VM, `clamps-test-generic` on port 2202, was created with the + generalized command and passed signed-boot and full user-environment + readiness. + +## Passing clean run + +- `cargo run --release -p skillet -- test vm create` created + `clamps-test-smoke` from a fresh FCOS qcow2 and returned success without + manual guest repair. The VM remains available for smoke runs. +- `runs/clamps-test-smoke/final-status.json` records the booted origin as + `ostree-image-signed:docker://ghcr.io/gbagnoli/ucore-clamps:latest`; the + previous deployment is the unsigned clamps image. +- The readiness helper verified the generated SSH key, guest binary SHA, successful + repeated base apply, enforcing SELinux, NetworkManager's resolver, working + DNS, and masked `systemd-resolved`. +- Ignition keys survived under `~/.ssh/authorized_keys.d/ignition`. Skillet's + SSH config now includes that path. The image repo has the matching change, + which will take effect after its next image build. +- `cargo run --release -p skillet -- test smoke clamps` passed the real + Podman/systemd fixture, repeated apply, config and secret changes, injected + startup failures, and post-reboot persistence. Snapshots and journals remain + under `/var/lib/skillet-smoke` in the disposable VM. + +## Preflight and failed attempts + +- Native `virsh -c qemu:///session list --all` worked with host execution and + showed no existing domains before provisioning. Host has `passt` and + `virt-install`; a read-only XML preview showed loopback-bound passt forwarding. +- Initial launcher attempt `clamps-agent2-0925-01` stopped before VM creation + on an invalid yq expression; fixed in `bin/butane`. +- `clamps-agent2-0925-02` compiled strict Butane output but QEMU refused to + open the fw_cfg Ignition file in the workspace (`Permission denied`). +- `clamps-agent2-0925-03` produced the same QEMU error with artifacts under + `/tmp`. No domain remained after either failed virt-install. The current + source adds the Ignition file as a read-only libvirt-managed raw attachment; + a later clean VM create passed. Per-VM confinement was not + disabled and host security policy was not changed. +- Failed generated artifacts remain in ignored `runs/` and `/tmp` paths for + diagnosis. They contain private test SSH keys and should not be committed. +- A later `runs/clamps-test-0925-05` directory contains generated artifacts + but no recorded domain UUID or acceptance results. This is not evidence of + a successful guest boot. The helper now selects a separate native libvirt + runtime to avoid mixing native and Flatpak daemon filesystem views; that + approach passed the clean create recorded above. +- `runs/clamps-test-smoke` created a running VM, but SSH on port 2201 never + became available. The serial console showed Ignition still reading the + 2.5 MiB `fw_cfg` payload after about 30 host minutes; the dominant + embedded entry was the 4.9 MiB `skillet-clamps` binary. Ignition documents + that large QEMU `fw_cfg` configs can take an unreasonable time to read + ([release notes](https://coreos.github.io/ignition/release-notes/)). The VM + launcher now omits that binary from the VM-only Ignition config and + the readiness helper transfers it over SSH after first boot. The clean create + recorded above validated this change. + +## Static checks + +- ShellCheck passes for the four launcher/readiness scripts, bootstrap + regression script and both Skillet smoke scripts. +- Full strict Butane compilation passed on 2026-09-26 with an earlier staged + source and rebuilt host artifact. Local output: + `/tmp/clamps-commit-validation-93upz2et/ignition/clamps.ign`. +- `bash tests/bootstrap.sh` passes eight mocked deployment-state cases: + fresh clamps and bender boots, unsigned boot with signed rollback, signed pending, signed + booted (tag and digest), reboot retry limit, and rebase failure. + These are regression checks, not a replacement for actual VM boots. +- Review corrected duplicate `:latest` matching, rollback selection as a + pending deployment, and reuse of a stale default Skillet build. The default + launcher now rebuilds; `--artifact` remains an explicit override. +- Skillet review passed formatting, 25 workspace tests, configured pedantic + Clippy with warnings denied, and the offline static release build. +- Current VM Skillet artifact SHA256: + `6cdb80f03221c0fb9521f7ff9a58ee33844fb7262651ff47793b9a9f70ec7784`. + +## Exit criteria + +| Criterion | Result | Evidence / next command | +| --- | --- | --- | +| Clean install | Pass | Clean `test vm create` returned success | +| Access | Pass | Generated key authenticated after signed boot | +| Rebase sequence | Pass | Booted signed and previous unsigned clamps deployments | +| Resolver continuity | Partial | Final boot passed; each intermediate boot not checked | +| Artifact delivery | Pass | Guest SHA matched `skillet.sha256` | +| Automatic apply | Pass | Boot unit completed with status 0 | +| Repeat apply | Pass | `test-vm clamps ready smoke` reran the base unit with `systemctl --wait` | +| Reboot | Pass | Smoke reboot preserved data and container state | +| Interruption | Not run | Interrupt an isolated bootstrap, then recover | +| Repeatability | Not run | Second fresh VM after first passes | +| Real Skillet scenario | Pass | `skillet test smoke clamps` returned success | + +The passing VM is retained for further inspection. The mutable +source-tree helper has not been installed as a frozen, persistently approved +host tool; it still uses normal command approvals. + +## Pi-hole credential slice, 2026-09-27 + +- Fresh `clamps-test-pihole` VM on port 2202 passed `test vm create clamps + pihole`, including unsigned/signed image rebases and base readiness. Its + ignored `runs/clamps-test-pihole/` directory retains build and VM evidence. +- `test vm provision clamps pihole` delivered a generated dummy password via + SSH stdin. The guest installed mode 0600, root-owned encrypted credential + and loaded it through `skillet-full-apply.service`. A comparison of decrypted + credential bytes with the live container's `/run/secrets/pihole_web_password` + passed without printing the value. +- First full apply exposed a pre-existing `useradd` same-name group bug and a + non-root Pi-hole startup failure. Both were fixed. `test vm update clamps + pihole` deployed the current host binary while retaining the creation + snapshot. Pi-hole then stayed active. +- Repeat provision kept container ID + `de3716fc0a698771f792a1c909cec4f5486bbbe15f5fb74292da5a4b94c05e8c`. + Explicit `--rotate` changed it to + `dc4a8970644a44684b0d00b0f7dbf0d9bd242848b3a3ab2ff7b46bff94c55e87`, + and the mounted secret matched the new credential. A reboot brought Pi-hole + back active with the encrypted file intact. +- `skillet test smoke clamps --port 2202 --identity + ../butane/runs/clamps-test-pihole/ssh/id_ed25519` passed its real-systemd + fixture, including its own reboot. Evidence is under + `/var/lib/skillet-smoke/` on the retained VM. +- Final workspace checks passed: 32 Rust tests, pedantic Clippy with warnings + denied, `shellcheck` and `bash -n` for `bin/test-vm`, and `git diff --check`. +- Post-delivery OS rebase, interruption recovery, production TPM binding, + Cloudflare token migration, and Pi-hole DNS/web behavior were not checked. + +## Fresh default smoke VM, 2026-09-27 + +- The retained `clamps-test-smoke` VM predated `skillet-full-apply.service`: + readiness passed, but `test vm provision clamps smoke` failed because that + unit was absent. The owned disposable domain and run directory were removed. +- `test vm create clamps smoke` then built a fresh VM from current Butane and + passed signed-image readiness and the user-environment checks. The run + artifacts remain in the ignored `runs/clamps-test-smoke/` directory. +- `test vm provision clamps smoke` delivered a generated dummy credential and + completed full apply. `test smoke clamps` passed all real-systemd/Podman + fixture cases, including repeat apply, recovery, secret rotation, and reboot + persistence. Guest evidence remains under `/var/lib/skillet-smoke/`. +- This run used no KeePassXC vault or Cloudflare token. It did not check + storage mounts, Pi-hole DNS/web behavior, or post-delivery OS rebase. + +## Shared Btrfs data storage, 2026-09-27 + +- Fresh `clamps-test-storage` VM on port 2203 booted from Butane with the + shared `data-storage.bu` include. After the signed uCore bootstrap, + `/var/lib/data` mounted as Btrfs `/data` on the same filesystem as `/var`. + `skillet-data-prepare.service` created the `containers` subvolume and + restored the rootful graphroot SELinux label. Podman reported graphroot + `/var/lib/data/containers/system`, runroot `/run/containers/storage`, and + driver `overlay`. +- Dummy-credential provisioning created the `pihole` child subvolume and + started Pi-hole with bind paths under `/var/lib/data/pihole/{etc,dnsmasq.d,log}`. + Repeated full apply retained the subvolume and running container. After + Pi-hole changed ownership of its own files, two further applies made no + metadata changes or container replacement. +- A sentinel in the Pi-hole data tree survived VM reboot. Temporarily masking + `var-lib-data.mount` caused full apply to fail without creating fallback + Pi-hole or container paths. A wrong tmpfs mount was rejected by both the + shared preparation script and Skillet before either wrote to it. Restoring + the real mount and running normal convergence recovered Pi-hole and retained + the sentinel. The temporary mount override was removed. +- The real Podman/systemd `test smoke clamps` scenario passed against this VM, + including its reboot and recovery cases, after deployment of the final + shared full-apply guard. Workspace tests, pedantic Clippy, ShellCheck for + both storage scripts, and `git diff --check` passed. These checks did not include a + post-provision OS rebase or the physical stock SSD. Other host templates do + not yet merge the shared include because they do not yet exist. + +## KeePass use-or-create smoke, 2026-09-28 + +- Fresh `clamps-test-credential-e2e` VM on port 2202 passed current Butane + readiness, including signed uCore boot and the user environment. The updated + Rust host command installed a generated dummy Pi-hole credential; full apply + completed and Pi-hole became active. The guest credential is root-owned and + mode 0600. +- `skillet test smoke clamps --port 2202 --identity + ../butane/runs/clamps-test-credential-e2e/ssh/id_ed25519` passed all fixture + cases and reboot persistence. Guest snapshots remain under + `/var/lib/skillet-smoke/` on the retained VM. +- An empty disposable KDBX could not replace the VM's existing credential. + After removing only that VM's dummy credential and Podman secret, the same + vault created `skillet/hosts/clamps/pihole/web-password` and delivered it. + A repeat delivery reopened the saved entry and completed full apply. The + encrypted vault backup was retained, the host reported `present`, Pi-hole + was active, and full apply reported `Result=success` and `ExecMainStatus=0`. +- On this workstation, both Skillet and `keyctl timeout` received permission + denied when setting expiry on a new user key. Skillet unlinked that key and + continued without caching the dummy unlock; the user keyring was empty after + the run. The three-hour kernel expiry path remains unverified here. No + production vault, production host, or physical disk was used. +- Follow-up: `keyctl timeout` succeeded for a dummy key stored in a named + session keyring. Linking that keyring from the user keyring kept the same + named ring and dummy key available to a later process. Skillet now uses that + arrangement for the three-hour cache; the KeePass-backed end-to-end flow has + not yet been repeated with this implementation. + +## Post-delivery OS update/rebase and credential recovery, 2026-09-28 + +- On `clamps-test-credential-e2e`, the signed `latest` ref had a newer image + available (digest `5d11f1003ffa1cb2a528c2e6943fa505dad86c2d3e5bf87b5330978111f3e0d2`). + `rpm-ostree upgrade` staged it, and `test-vm clamps ready credential-e2e` + passed after reboot. The encrypted Pi-hole credential remained present and + decryptable; full apply returned success, the Podman secret was present, and + Pi-hole was active. +- An explicit signed rebase to that same image digest also staged and booted. + Readiness passed again; credential decryption and full apply succeeded, and + Pi-hole returned active. A same-tag rebase had been a no-op because the refs + were equal, so the newer signed image was staged first. +- To exercise delivery recovery, a runtime-only systemd drop-in temporarily + forced `skillet-full-apply.service` to fail. Skillet `test vm provision + clamps credential-e2e --rotate` returned the expected activation error after + atomically installing a new encrypted credential. `systemd-creds decrypt` + validated it; the existing Podman secret and running Pi-hole were retained. + After removing the drop-in, normal `test vm provision clamps credential-e2e` + reused the installed credential, full apply succeeded, the Podman secret ID + changed, and Pi-hole remained active. The runtime drop-in was removed and + systemd reloaded. +- This tests recovery after guest-side atomic credential installation when + activation fails. A disconnect during the input stream or encryption is + still untested. The software-only VM also reports that its systemd host key + is not on encrypted media; production TPM binding remains open. + +## Syncthing service, 2026-09-28 + +- On the named disposable `clamps-test-credential-e2e` VM, a full apply created + `/var/lib/data/syncthing` as a Btrfs subvolume and started the separate + `syncthing` Quadlet container on the shared clamps network. Container-name + DNS resolved both bridge addresses, and `http://syncthing:8384` returned + HTTP 200 from another container. +- The VM showed the Chef-equivalent host publications for GUI port 8384 and + transfer port 22000 on IPv4 and IPv6. A repeated full apply preserved the + container ID and configuration hash. Its empty data tree generated a VM-only + device identity; no production Syncthing identity was copied or run. +- These are the previous session's runtime results. On the current workstation, + `test-vm clamps list` reports this domain as `missing`, so no fresh VM + rerun was made before committing. Real peer connectivity and production + state transfer remain cutover work. The GUI publication must be removed or + locally restricted before tailnet access can serve as its sole gate. + +## Btrbk image and VM attempt, 2026-09-29 + +- Image workflow [run 36552978960](https://github.com/gbagnoli/ucore-images/actions/runs/36552978960) + succeeded for `ucore-common` and all three host images. The published clamps + image digest is + `sha256:e6c0cd35641199ddb5e2d9ca0f281dc1d640126ae5b2a04fa87c65163cedfd7c`. +- The user created `clamps-test-smoke` with `./butane/bin/test-vm clamps + create smoke`. The guest booted the expected signed image and confirmed + `btrbk-0.32.7-1.fc44.noarch` is present, `btrbk.timer` is masked, and + `/var/lib/data` is Btrfs subvolume `/data` on the expected UUID. +- The first readiness attempt hung during baseline convergence. Guest + `systemd-sysctl.service` is `Type=oneshot`, `RemainAfterExit=yes`; passing + `--wait` made systemctl wait for this persistent unit to stop. The rule was + corrected: ordinary `systemctl start` and `restart` wait for the requested + job. Skillet, credential delivery, the VM readiness helper, and its usage + example now omit `--wait`. Rebuilt clamps completed base apply successfully. + +## Btrbk snapshot verification, 2026-09-29 + +- Dummy-credential provisioning completed full apply. Pi-hole and Syncthing + started, the `/var/lib/data/syncthing` Btrfs subvolume was present, and + `/etc/btrbk/btrbk.conf` contained only that source with the Chef-equivalent + hourly retention (`6h`, `24h 31d 6m`). `skillet-btrbk.timer` was active and + enabled while the packaged daily `btrbk.timer` remained masked. +- `btrbk -n -v run` showed only `/var/lib/data/syncthing` as its source and + `/var/lib/data/snapshots/syncthing/syncthing.` as its target. + A real run created read-only snapshot `syncthing.20260929T1402`. A disposable + sentinel was changed in the live tree, then restored from that snapshot into + `/var/tmp/skillet-btrbk-restore`; byte comparison passed. The temporary + restore subvolume and sentinel were deleted, while the btrbk snapshot was + retained. +- Repeated full apply preserved the btrbk config and timer hashes and retained + the existing snapshots. The timer fired at 14:00:30 and created + `syncthing.20260929T1400`. After a VM reboot, Pi-hole, Syncthing, and + `skillet-btrbk.timer` were active; the timer remained enabled, the package + timer remained masked, both snapshots remained, and the next hourly run was + scheduled for 15:00 CEST. +- `cargo run --release -p skillet -- test smoke clamps` passed the real-runtime + systemd/Podman fixture checks through reboot. Local validation passed: + formatting, 45 workspace tests, pedantic Clippy with warnings denied, the + CI-style container command, `bash -n` and ShellCheck for `test-vm-ready`, + and `git diff --check`. The CI-style command ran from `/tmp` because the + checked-out workspace mount is read-only to shell commands. +- Fail-closed behavior for missing and wrong `/var/lib/data` mounts was already + exercised against the shared storage configuration (see the shared Btrfs + data storage section above); btrbk unit tests also reject a wrong mount + before writing managed state. The disposable VM used an empty Syncthing + subvolume. The user confirms Syncthing data is designed without nested + subvolumes; the btrbk design treats selected sources as leaf subvolumes and + does not support nested subvolumes. + +## Generic private UI slice, 2026-10-01 + +- `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test`, `cargo + build`, and the CI command `skillet test run beezelbot --phase base --image + fedora:latest` passed. New tests cover a non-clamps Syncthing-only declaration + in production and test, derived names, multi-service rendering, zone suffix + validation, rejection of invalid/duplicate services, and receiver-side + payload matching. A repeated Caddy apply test confirms unchanged generated + config does not restart the proxy container. Existing container tests still + pass. +- Fresh `clamps-test-generic-ui` completed Ignition, signed uCore rebase, + base apply, Homebrew, and dotfiles installation using the pre-correction + static host-binary unit. A `%H` experiment failed because systemd expands it + to the full VM hostname, which is distinct from the host profile. The shared + unit now invokes the generic CLI with `/etc/skillet/host`; clamps writes the + stable profile `clamps`, and its credential-gated full-apply unit is in a + clamps-specific include. +- On the existing `clamps-test-generic-ui` disposable VM, installed the current + generic and clamps binaries, wrote the stable host profile, and used a + temporary systemd drop-in to run the base unit through the generic + `--host-file` invocation. The unit reported success while the VM hostname + remained `clamps-test-generic-ui`. The real `skillet test smoke clamps + --port 2202 --identity /ssh/id_ed25519` passed through reboot. This + verifies the generic runtime path; a fresh VM from the updated Ignition and + binary-staging flow is still pending. The prior guest evidence remains in its + ignored run directory. +- Strict Butane compilation passed for the updated clamps config and both + shared and clamps-specific unit fragments. Running the normal wrapper built + and staged both current static binaries; decoded merged Ignition confirmed + `/etc/skillet/host=clamps`, generic `skillet` commands for base/full/Caddy, + and the host binary at `/var/usrlocal/bin/skillet-clamps`. + `bash -n`, ShellCheck, and `git diff --check` also passed for the changed + launchers. The exact CI integration command passed in a writable temporary + checkout after the workspace mount rejected Cargo output; that run used the + default CI test image and reported repeat apply with no extra service starts. +- The earlier Caddy gate check had no credentials and confirmed only that the + unit remained skipped. No domain/token was delivered; + Caddy startup, ACME issuance, HTTPS proxy access, renewal, and DNS lifecycle + remain unverified. `beezelbot` has no Butane template, so its real-runtime + service behavior remains unverified. + +## Cloudflare UI lifecycle implementation, 2026-10-02 + +- `cargo fmt --all` passed. +- `CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target + CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 + cargo test --workspace --target x86_64-unknown-linux-gnu` passed (63 tests). +- `CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target + CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 + cargo clippy --workspace --all-targets --target x86_64-unknown-linux-gnu -- + -D warnings` passed. Cloudflare tests use a local mock HTTP server and need + no KeePassXC unlock, Cloudflare account, or live zone. +- The CI integration command `skillet test run beezelbot --phase base --image + fedora:latest` passed using the GNU-target CLI. Its repeat apply issued no + extra service starts. +- `skillet test vm list clamps` found `generic-ui` and `smoke` running. Updating + `generic-ui` for the integration test was attempted, but `test-vm update` + requires both x86_64 musl artifacts; the generic CLI artifact was missing. + The musl Rust target is installed, and the existing cross compiler requires + `/opt/x86_64-linux-musl-cross/bin` in PATH. The earlier diagnosis of a missing + compiler was incorrect. No packages or toolchains were installed. The VM smoke script, + `--with-ui` provisioning, real Cloudflare mutations, staging ACME, and HTTPS + acceptance remain unverified. + +### Musl build and live UI attempt, 2026-10-02 + +- With `/opt/x86_64-linux-musl-cross/bin` prepended to PATH and + `CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target`, + `cargo build --release --target x86_64-unknown-linux-musl -p skillet + -p skillet-clamps` passed using the existing toolchain. +- `skillet test vm update clamps generic-ui` installed both static binaries + in the existing named disposable VM. +- `skillet test vm provision clamps generic-ui --with-ui` stopped at vault + unlock: the kernel cache was unavailable and the tool process had no terminal + (`reading database password from terminal: No such device or address`). + No Cloudflare API or live certificate acceptance is claimed. User terminal + unlock is needed before continuing retained-VM checks and the fresh + destroy/create/provision/verify/dispose cycle. + +### Cloudflare zone preflight permissions, 2026-10-02 + +- After user unlock, `skillet test vm provision clamps generic-ui --with-ui` + reached Cloudflare and returned HTTP 403 / code 9109 during the configured + zone lookup using the workstation issuer. This occurs before child-token + issuance and DNS reconciliation. Caddy ACME/HTTPS remain unverified. +- The README had incorrectly excluded zone permissions from the issuer. + Corrected setup requires Zone Read on configured zones as well as User API + Tokens Read/Edit; DNS Write is still confined to child tokens. The rebuilt + CLI reproduced the error with zone-preflight permission context. +- `cargo fmt --all --check`, workspace tests (64 tests), and all-target + pedantic Clippy passed with `--target x86_64-unknown-linux-gnu` for tests and + Clippy. The CI integration scenario `skillet test run beezelbot --phase base + --image fedora:latest` passed; repeat apply issued no service restarts. + `cargo build --release --target x86_64-unknown-linux-musl -p skillet` passed + with the documented compiler PATH. The HTTP mock regression test verifies + useful 403 context without token or configured zone ID disclosure. +- Live acceptance is pending issuer zone access being updated in Cloudflare; + no live certificate issuance or fresh full cycle is claimed. + +### Relative UI namespace, 2026-10-02 + +- `dns/cloudflare-zone-id` remains mandatory. Workstation production delivery, + opt-in VM UI provisioning, and VM cleanup now resolve the optional + `dns/ui-domain` as a relative prefix appended to the fetched zone domain. + An absent entry defaults to `ui`; an empty entry is invalid. Full-domain + values are also treated as relative and require deliberate local migration. +- `cargo fmt --all --check`, workspace tests (66 tests), and all-target + pedantic Clippy passed; tests and Clippy used the GNU target. Tests cover + default/multiple-label prefixes, environment isolation, always-appended + zone suffixes, invalid prefixes and excessive total domain length. +- Both musl release binaries built successfully with the documented compiler + PATH. `skillet test run beezelbot --phase base --image fedora:latest` passed + with no repeat-apply service restart. The named `clamps-test-generic-ui` VM + was updated with both binaries. +- `integration_tests/smoke-ssh.sh --target giacomo@127.0.0.1 --port 2202 + --identity ../butane/runs/clamps-test-generic-ui/ssh/id_ed25519 + --disposable-target --binary + /home/giacomo/.cache/devmachine-target/x86_64-unknown-linux-musl/release/skillet + --clamps-binary /var/usrlocal/bin/skillet-clamps` passed all fixture cases, + failure recovery and reboot persistence. Evidence is retained in the guest + under `/var/lib/skillet-smoke/`. This fixture check is not live ACME acceptance. +- Live Cloudflare/Caddy acceptance remains pending migration of the existing + vault prefix. No new DNS mutations or certificate issuance are claimed for + this change. + +### Live issuer retry, 2026-10-02 + +- Retried `skillet test vm provision clamps generic-ui --with-ui` using the + cached vault unlock. Zone lookup and relative namespace resolution succeeded. +- The first retry found that the saved issuer is account-owned and correctly + has Account API Tokens Write. The original Skillet implementation called + user-token endpoints, which returned HTTP 403 / code 9109. This endpoint + mismatch was corrected by deriving the account ID from the selected zone and + using account-token endpoints for discovery, creation, listing, and revoke. +- See the live account-token and Caddy result below; the earlier blocked state + has been resolved. + +### Account token, Cloudflare DNS, and Caddy live acceptance, 2026-10-02 + +- The account-owned issuer passed permission-group discovery and created the + scoped child token. Cloudflare DNS reconciliation created the expected five + owned records for the machine address, canonical UIs, and declared alias. +- Caddy's disposable container started and obtained Let's Encrypt staging + certificates for Pi-hole, Syncthing, and the Syncthing alias using DNS-01. + OpenSSL checks confirmed each certificate SAN matched the requested name and + the issuer was staging. +- From the workstation on the tailnet, DNS resolved over IPv4 and IPv6. HTTPS + returned Pi-hole `/admin/` login redirects (302) and Syncthing canonical and + alias pages (200) over both address families. Bare Pi-hole `/` returns 403; + its UI is at `/admin/`. +- `skillet test vm destroy clamps generic-ui` completed. It removed the owned + Cloudflare records and child tokens through account endpoints, removed the + Tailscale device, undefined the libvirt domain, and removed Cloudflare + ownership metadata. The retained unrelated `clamps-test-smoke` VM remains. +- `cargo fmt --all --check`, workspace GNU tests (67 tests), all-target + pedantic Clippy, and `skillet test run beezelbot --phase base --image + fedora:latest` passed. The account create/list/revoke endpoints and Caddy + config directory mount have regression coverage. +- This validates staging only. A request from outside the tailnet and + production ACME issuance/renewal were not tested. + +### Cloudflare API failure recovery tests, 2026-10-02 + +- Added local mocked-HTTP tests under `skillet/crates/cli/src/cloudflare_tests.rs`. + They run as part of the existing workspace `cargo test` CI job and require + no vault, Cloudflare account, credentials, or DNS zone. +- The token test models Cloudflare committing a named token while returning an + error; retry lists and revokes that orphan before creating exactly one + replacement. DNS reconciliation retries after a create failure and adopts + the already committed records without duplicates. Cleanup retries after a + partial delete failure and preserves a record owned by another marker. +- Focused Cloudflare tests pass (10 tests). `cargo fmt --all --check`, all 70 + workspace tests, `cargo clippy --all-targets -- -D warnings`, and + `skillet test run beezelbot --phase base --image fedora:latest` all pass. + +### Non-tailnet Caddy denial probe, 2026-10-02 + +- Caddy now returns a distinctive 403 body for requests outside the Tailscale + IPv4/IPv6 ranges. Opt-in `test vm provision --with-ui` probes each declared + canonical name and alias from the guest's loopback address, using the UI + hostname for TLS SNI and the HTTP Host header. +- The user completed live `test vm provision clamps generic-ui --with-ui` on + the disposable VM. Provisioning returned successfully, which means the + loopback probe received the exact configured 403 for every declared UI name + and alias. Follow-up inspection found the Caddy container running and three + successful certificate issuance events in its logs. +- The Caddy renderer test, workspace suite, Clippy, and routine integration + scenario pass. This loopback check does not replace a request from an + independent network outside the VM. The disposable VM remains running for + manual inspection. + +### Pi-hole DNS behavior on smoke VM, 2026-10-02 + +- On the running `clamps-test-smoke` VM, Pi-hole was active and published DNS + listeners on wildcard IPv4 and IPv6 addresses. Queries for a public test + name succeeded over UDP and TCP through the VM's IPv4 and IPv6 interface + addresses. +- A temporary Pi-hole v6 `dns.hosts` record using the documented + `"IP HOSTNAME"` format resolved over both transports and address families. + The original empty custom-host setting was restored and verified afterward. + The unauthenticated admin page redirected to login (302), and the unauthenticated + API auth endpoint returned 401. +- This VM uses libvirt's user-mode `passt` network with only SSH forwarded to + the workstation. Its active firewalld zone has no explicit DNS service or + port allowance. Guest-local queries do not establish client ingress, so LAN + reachability and host-firewall behavior remain unverified; validate them on + a bridged/physical clamps network before cutover. No production DNS records + were supplied or changed. + +### UniFi rupik source inventory, 2026-10-02 + +- The sanitized inventory log is `/tmp/rupik.log` on the workstation. Rupik is + ARM64 and runs `jacobalberty/unifi:latest`, package `10.0.162-32076-1`, in a + rootful host-network container as image user `unifi` (UID/GID 999). Podman + reports host UID 999 as `systemd-coredump`; Chef separately creates host + `unifi` UID/GID 2666. The data tree is owned by 999:999, mode 0750, and uses + 1.2 GiB. `/srv` is Btrfs; the image's `/unifi/run` is a separate generated + Podman volume. +- Observed listeners: TCP 8080, 8443, 6789, 8843, 8880; UDP 3478, 10001. HTTPS + on local 8443 returned 302. No UniFi nginx route appears in the Chef repo. + Eight small monthly `.unf` backup files were listed through October 2026; + validity and restore compatibility are unverified. Clamps is x86_64, so a + backup restore from the ARM64 controller must pass on an isolated VM before + any production migration. The full private controller settings were not + included in this log. + +### UniFi Skillet implementation, 2026-10-02 + +- Added reusable `skillet_unifi` support and enabled it from clamps full + apply. It requires `/var/lib/data`, creates `/var/lib/data/unifi` as a Btrfs + subvolume, mounts it at `/unifi`, and configures rootful Podman with mandatory + host networking, the Chef image family, `User=unifi`, `TZ=Europe/Madrid`, + restart-at-boot, and registry auto-update. +- Skillet updates only the subvolume root to numeric UID/GID 999; it does not + recursively alter existing controller data and does not require matching + host account names. The first disposable VM apply exposed that clamps uCore + has no passwd entry for UID 999. Numeric ownership handling and regression + tests were added in follow-up commit `6765ecb`. + +### UniFi empty-controller VM acceptance, 2026-10-03 + +- Updated the running `clamps-test-unifi` disposable VM with the new `skillet` + and `skillet-clamps` binaries using `cargo run --release -p skillet -- test + vm update clamps unifi`. +- Retried `skillet-full-apply.service`; it completed with status 0. The + `unifi.service` Quadlet is active and `podman inspect` reports + `network=host` with `/var/lib/data/unifi:/unifi` mounted. +- `/var/lib/data/unifi` is a Btrfs subvolume with owner `999:999` and mode + `750`. The container started and emitted UniFi application initialization + logs. +- Repeated the full apply. It completed with status 0, UniFi remained active, + and the container ID stayed unchanged. Backup restore, reboot persistence, + and expected host listener checks remain pending; see + [the UniFi plan](../plan/CLAMPS-UNIFI.md). + +### Rust VM local artifact preparation, 2026-10-03 + +- `skillet_vm::provisioning` now owns preparation of the disposable VM's copied + disk and Ed25519 SSH key. Disk copies are staged beside the destination and + atomically renamed; a SHA-256 sidecar records the selected image. Repeated + preparation reuses a byte-identical disk and matching key pair, repairs a + changed image while the run is still `Preparing`, and rejects partial keys, + symlinked inputs, or progressed runs. The hidden `skillet test vm + prepare-local` command bridges `coreos-install` to this library and returns + the public key for Ignition rendering. +- `cargo fmt --all --check`, workspace pedantic Clippy with warnings denied, + all 131 workspace tests, and `cargo build --offline --workspace` passed. + `bash -n` and ShellCheck passed for `coreos-install`; `git diff --check` + passed. The CI-style `cargo run --release -p skillet -- test run beezelbot + --phase base --image fedora:latest` passed, including repeat apply without + service restarts. +- No VM was created or changed for this slice. Template staging, Butane + compilation and native define/start remain in the Bash installer; Flatpak + creation and end-to-end VM acceptance remain pending. + +### Rust VM Butane source staging, 2026-10-03 + +- `skillet_vm::staging` now copies the selected host config, `.bu` includes, + and both captured binaries into the owned run directory. It specializes the + guest hostname, injects the validated disposable SSH key, removes image + binary entries and VM-only password-expiry resources, and adds the test-only + sudoers file. Artifact hashes and `run.conf` compatibility metadata are + written atomically. Repeated staging is idempotent and requires the current + persisted run to remain `Preparing` while holding its run lock. +- `coreos-install` delegates disk/key preparation and staging to hidden Rust + VM commands. Bash now invokes the standalone Butane compiler on the Rust- + staged tree. Native define/start and tool-version capture remain in Bash. +- Formatting, workspace Clippy with warnings denied, all 134 workspace tests, + workspace build, `bash -n`, ShellCheck, and `git diff --check` passed. The + CI-style beezelbot base container scenario passed, including repeat apply + without service starts. +- No VM was created or changed. The staged tree was exercised through fixture + tests, but a fresh VM create through the combined flow was not verified. + Native define/start remain in Bash; Flatpak creation is still unverified. + +### Rust native VM define and start, 2026-10-03 + +- Native VM creation now resolves the x86_64 HVM emulator from the selected + libvirt connection, captures tool versions, renders the owned domain XML, + defines and starts the domain, and records its UUID in Rust. The lifecycle + validates UUID and disk ownership after each backend observation. A retry + after define or start can continue from the manifest and current domain + state. `test-vm` delegates native resume to the same Rust command; Flatpak + `virt-install` remains on its compatibility path. +- Fake-backend coverage verifies successful creation, recovery from an + existing defined domain, idempotent running-domain retries, refusal of a + foreign UUID, start-failure retry, and x86_64 emulator selection. The VM crate's 62 tests and the + full workspace test suite and pedantic Clippy with warnings denied passed. + Formatting, ShellCheck, Bash syntax, and `git diff --check` passed. +- No live VM was created or changed. The combined native flow and Flatpak + creation remain to be exercised on their respective backends. + +### Rust VM reboot and guest access, 2026-10-03 + +- `test-vm` now delegates reboot, interactive SSH, and guest diagnostics to the + Skillet CLI. Rust holds the per-run lock, validates domain UUID/disks before + guest access, and validates ownership after each operation. Reboot marks a + previously Ready run Started before issuing the backend command, so stale + readiness cannot survive an interrupted reboot. Interactive SSH attaches a + terminal directly; captured logs remain bounded through the shared transport. +- Unit coverage verifies that interactive SSH allocates a terminal and uses + the manifest target, and that a reboot invalidates Ready state. Workspace + tests and pedantic Clippy passed; shell checks and formatting passed. +- No live VM was contacted. Reboot and interactive access remain unverified + against a live guest in this slice. The CI-style container integration + command was unavailable: Podman cannot set the sticky bit on + `/run/user/4000/libpod` because the filesystem is read-only. No container + was started. + +### Rust Flatpak VM creation adapter, 2026-10-03 + +- Added a bounded `virt-install` adapter that uses the recorded Flatpak runtime + and preserves the manifest UUID, disk, Ignition, QEMU user-networking, and + forwarded SSH port. Flatpak definition now uses the common Rust phase and + ownership recovery logic, including a retry when `virt-install` created or + started the guest before an interrupted response. `coreos-install` delegates + both native and Flatpak domain creation; Bash no longer records the domain + UUID or start phase. +- Fake adapter and lifecycle coverage checks the executable wrapper, runtime + environment, UUID, disk/network arguments, successful transition and retry + behavior. The workspace suite passed, including 66 VM crate tests, and + pedantic Clippy passed; formatting, ShellCheck, Bash syntax, and + `git diff --check` passed. +- No Flatpak VM was created. Live acceptance remains unverified on Bazzite. + +### Rust-owned VM create orchestration, 2026-10-03 + +- `skillet test vm create HOST INSTANCE` now owns the full create path: backend + selection/runtime startup, image resolution, guest binary build, disk/key + preparation, Butane staging/compilation, native or Flatpak define/start, and + readiness. Create holds a dedicated per-run orchestration lock while calling + the lower-level locked operations. Repeated create skips the SSH-port + availability probe once a guest is already running. It preflights KVM and + the forwarding port before expensive preparation. `bin/test-vm` is now a + narrow compatibility argument adapter; the separate Bash `coreos-install` + and hidden preparation/staging/creation CLI commands were removed. +- Passed `cargo fmt --all --check`, offline workspace tests (72 VM crate tests + plus all other workspace suites), and offline workspace all-target Clippy + with warnings denied. Built the static-musl CLI using + `PATH=/opt/x86_64-linux-musl-cross/bin:$PATH` and the configured external + Cargo target directory. `shellcheck -x` and `bash -n` passed for all affected + VM/compiler wrapper scripts. CLI create help and `git diff --check` passed. +- Passed the CI integration command: + ```bash + CARGO_TARGET_DIR=/home/giacomo/.cache/devmachine-target \ + /home/giacomo/.cache/devmachine-target/x86_64-unknown-linux-musl/debug/skillet \ + test run beezelbot --phase base --image fedora:latest + ``` + Both applies + succeeded and the repeat issued no service restart; the test container was + removed. +- Live VM acceptance was not run because this workstation has no `/dev/kvm` + device (`stat /dev/kvm` reports “No such file or directory”). The recorded + `clamps/smoke` run was reported as unavailable by `test vm list`; it was not + altered. Native/Flatpak create, readiness, reboot, and destroy remain + unverified live. + +### Canonical host profiles, 2026-10-03 + +- Moved host service composition and boot expectations into the canonical + `skillet_hosts` crate. Profile-level unit coverage checks all current hosts' + service sets, UI ports/aliases, credential consumers, network parameters, + mount needs, and snapshot configuration. Added a profile with a credentialed + Pi-hole service without UI exposure, and an explicit no-service baseline. + Unknown hosts are rejected before effects. The smoke fixture is now selected + by a separate hidden test command rather than a synthetic host identity. +- Passed `cargo fmt --manifest-path ublue/skillet/Cargo.toml --all`, + `cargo test --offline --manifest-path ublue/skillet/Cargo.toml --workspace`, + and `cargo clippy --offline --manifest-path ublue/skillet/Cargo.toml + --workspace --all-targets -- -D warnings`. Passed ShellCheck and Bash syntax + checks for `smoke-ssh.sh` and `smoke-guest.sh`. +- Passed the CI container command using the Cargo-reported beezelbot binary: + `ublue/skillet/target/x86_64-unknown-linux-musl/debug/skillet test run + beezelbot --phase base --image fedora:latest`. Both applies succeeded and + the test container was removed. +- Live base/full apply on a retained VM remains unverified because `/dev/kvm` + is absent. No VM was created, contacted, or changed in this slice. + +### Injected account, credential, and ownership inputs, 2026-10-04 + +- Host composition now receives systemd credential values explicitly. The CLI + loads only the profile-declared credentials needed by the selected phase. + Account lookups use the injected system boundary. Podman reads subordinate + ID data via the injected file resource and refuses missing, malformed, + duplicate, overflowing, or undersized ranges instead of using a fixed range. +- Directory ownership now uses one value supporting account names or numeric + IDs. The numeric-ID path does not require a host account, and the fake keeps + mode/ownership state through later existence-only checks. Tests preserve a + child file across repeat apply and cover file-versus-directory errors, + injected filesystem failures, account-ID mismatch, and mapping failures. +- Passed offline workspace tests for core, Podman, UniFi, and full workspace; + pedantic Clippy with warnings denied; formatting; ShellCheck and Bash syntax + checks; and the CI container integration command (beezelbot base apply twice, + then remove the container). +- Workstream 3 remains in progress: the remaining broad file/system trait + decomposition and live ownership/mount checks are outstanding. No live VM + was changed; `/dev/kvm` is absent on this workstation. + +### Cohesive file effect capabilities, 2026-10-04 + +- Split file access into read, mutation, and Btrfs storage traits. Recorder, + local adapter, and fake implement those contracts individually. Updated + service and Podman recipe bounds to declare the operations their composition + uses; retained the aggregate for host composition during migration. +- Passed `cargo fmt --manifest-path ublue/skillet/Cargo.toml --all`, offline + workspace tests, and offline workspace all-target Clippy with warnings + denied. Passed `git diff --check`. +- Passed the CI container integration command: + `ublue/skillet/target/debug/skillet test run beezelbot --phase base --image + fedora:latest`. Both applies succeeded, the second caused no service restart, + and the test container was removed. +- System capability splitting and live VM ownership/mount acceptance remain + pending. This workstation has no `/dev/kvm`; no VM was changed. + +### Cohesive system effect capabilities, 2026-10-04 + +- Split system effects into account lookup, account mutation, Podman-secret, + and service capabilities. Narrowed Podman, hardening, service, and backup + crate bounds; host composition keeps the aggregate trait. Linux, recorder, + and mock adapters implement each capability. +- Passed workspace formatting, offline workspace tests, and offline workspace + all-target Clippy with warnings denied. The full CI command + `ublue/skillet/target/debug/skillet test run beezelbot --phase base --image + fedora:latest` exited 0: both applies succeeded, repeat apply issued no + restart, and the disposable test container was removed. +- Live ownership and Btrfs mount acceptance remains unverified because this + workstation has no `/dev/kvm`; no VM was changed. + +### Shared guest credential delivery, 2026-10-04 + +- Production host secret delivery and disposable VM provisioning now call the + same `skillet_vm::credential::install` API over `GuestTransport`. The helper + validates host/credential/unit inputs, supports immediate or deferred unit + activation, delivers bytes on stdin, and returns sanitized guest errors. +- Tests cover stdin-only delivery, deferred activation arguments, invalid + inputs before transport, and remote failure diagnostics. Passed offline + workspace tests, workspace formatting, and strict all-target Clippy. +- The CI container integration command exited 0. Both base applies succeeded, + repeat apply did not restart a service, and the disposable test container + was removed. VM credential rotation/recovery acceptance remains pending; no + `/dev/kvm` is available here. + +### Tailscale Rust HTTP client, 2026-10-04 + +- Replaced the curl-based Tailscale API adapter with the workspace `reqwest` + blocking client. The endpoint is injectable through a test-only constructor; + production uses the HTTPS endpoint with bounded connect/request timeouts and + Rustls. +- Local HTTP fixture tests cover OAuth auth-key creation, bearer auth, tagged + device lookup/deletion, and a forbidden response. Full workspace + formatting/tests/strict Clippy passed. The static-musl CLI built with + `PATH=/opt/x86_64-linux-musl-cross/bin:$PATH`. +- CI container integration exited 0: both applies succeeded, repeat apply + issued no service restart, and the container was removed. No live Tailscale + API request was made. + +### Workstation vault library, 2026-10-04 + +- Added `skillet_workstation` and moved KeePassXC opening, exact secret lookup, + atomic encrypted save, backup/conflict checks, XDG location, lock operation, + and named-session kernel cache out of CLI code. The API returns `thiserror` + errors and keeps the unlocked database/password private. +- Workstation tests passed for exact entries, XDG fallback, correct/wrong + KDBX passwords, symlink-target replacement and backup, concurrent-change + refusal, absent-vault error before prompting, and three-hour cache expiry and + clearing. +- Passed workspace formatting, tests, strict all-target Clippy, and static + musl build. CI container integration exited 0; both applies succeeded, + repeat apply did not restart, and the container was removed. Live credential + delivery and rotation recovery on a disposable VM remain unverified because + `/dev/kvm` is unavailable. + +### Cloudflare workstation provider library, 2026-10-04 + +- Moved the Cloudflare SDK adapter, scoped account-token issuance/revocation, + DNS reconciliation, desired record validation, and the local HTTP fixtures + into `skillet_workstation::cloudflare`. The library uses a `thiserror` + boundary preserving SDK/JSON sources; the CLI calls the library API. +- The 10 Cloudflare provider tests passed, including DNS reconciliation and + ambiguous-token retry cases. Full workspace formatting/tests/strict + all-target Clippy and the static-musl build passed. CI container integration + exited 0; both applies succeeded, repeat apply issued no restart, and its + disposable container was removed. No live Cloudflare API mutation was made. + +### Tailscale workstation provider library, 2026-10-04 + +- Moved OAuth, auth-key, tagged-device lookup, and device-removal operations + into `skillet_workstation::tailscale`; removed the CLI's direct `reqwest` + dependency. Provider errors use `thiserror` and preserve transport sources. +- Local HTTP fixture tests passed for token/auth-key requests, tagged lookup and + removal, and API errors. Workspace formatting, offline tests, strict + all-target Clippy, and the CI container integration command passed. The + integration command applied the beezelbot base twice, confirmed the repeat + caused no service restart, and removed its disposable container. +- Live Tailscale API/VM acceptance was not run. Lifecycle ownership and + production-versus-test orchestration remain pending in workstream 4. + +### Explicit workstation environment policy, 2026-10-04 + +- Added typed production/test provisioning policy for UI domain lookup, + shared Cloudflare zone lookup, live versus staging ACME, Cloudflare token + lifetimes, cleanup-token lifetime, and production/disposable Tailscale tags. + Production UI delivery and disposable VM issue/cleanup now use that policy. +- The two policy unit tests and full offline workspace tests passed. Strict + workspace all-target Clippy passed. The CI container integration exited 0: + both applies succeeded, the second caused no service restart, and the + disposable container was removed. No provider or VM resources were changed. + +### Podman host-wide DNS policy, 2026-10-04 + +- Moved Aardvark's host-wide DNS listener config out of each container apply. + The canonical profile selects the policy when Pi-hole is declared; host + composition writes it before service recipes. Container apply now only owns + its Quadlet and declared network. +- Podman and host-profile tests passed, including idempotent listener config + and profile selection. Full offline workspace tests, strict all-target + Clippy, formatting, and the CI container integration passed. The CI + beezelbot base fixture applied twice without restart and its container was + removed. +- Live Pi-hole port 53 and service-name DNS behavior was not verified; no + `/dev/kvm` is available on this workstation. + +### Explicit Podman process identity, 2026-10-04 + +- Replaced the ambiguous container UID/GID + host-user fields with explicit + image-default, named, and numeric identities. Namespace mapping is a separate + opt-in input requiring an existing host account and subordinate ranges; the + generic adapter no longer creates accounts. UniFi's named process user now + comes from the typed identity field. +- Podman and UniFi tests passed for image-default, named, numeric, mapped + identity, map-range validation, and rejection of raw typed-field conflicts + before effects. Full offline workspace tests, strict all-target Clippy, + formatting, and the CI container integration passed. The container applied + beezelbot base twice with no restart on the second apply, then was removed. +- No live container identity or ownership checks ran; `/dev/kvm` is unavailable + on this workstation. + +### Typed Podman networks and port publications, 2026-10-04 + +- Host composition now creates each profile bridge network once; application + recipes attach by network name. Container network mode and port bindings are + typed values. Dual-stack Pi-hole, Syncthing, and Caddy publications plus + UniFi/Tailscale host networking retain their prior intent. Raw directives + that conflict with typed fields fail validation. +- Podman, service, and full workspace tests passed. Strict all-target Clippy + passed. The CI container integration exited 0; beezelbot base applied twice + without a restart on the repeat and its container was removed. +- No named-VM DNS/discovery or service networking test ran; `/dev/kvm` is not + available here. + +### Shared service-data mount dependency, 2026-10-04 + +- Added a reusable typed Podman mount dependency that renders preparation, + bind, ordering, and mountpoint assertion directives. Persistent Pi-hole, + Syncthing, UniFi, Caddy, and Tailscale containers use it. Ignition still owns + filesystem and graphroot preparation; applications own their child data + directories and ownership. +- The dependency rendering/rejection tests and full workspace tests passed. + Strict all-target Clippy and the CI container integration passed; both + beezelbot base applies succeeded with no restart on the second apply and the + disposable container was removed. +- Live mount/boot-order checks need a named VM and remain unverified because + `/dev/kvm` is unavailable here. + +### Shared activation, sanitized recording, and test fixture split, 2026-10-04 + +- Added shared activation/revision handling to hardening, Podman containers, + and the btrbk timer. Unit tests verify changed definitions, stopped services, + no-ops, oneshot semantics, and recovery after failed reload/start/restart. +- `apply --record` now writes versioned YAML atomically after both successful + and failed applies. Records include operation outcomes, omit content and + secret fingerprints, and do not retain raw effect error messages. Tests cover + failed applies and combined apply/record persistence failure. +- Extracted `skillet-smoke-fixture` from production host/CLI dispatch and moved + its guest entrypoint to a standalone shell file. Cargo-reported artifact + selection follows the invoked Skillet target/profile. The smoke runner + verifies the fixture transfer and the installed generic/host binaries against + the expected recorded hashes. CI includes Butane and helper paths, all-target + formatting/Clippy/tests, extensionless shell helpers, and the Fedora + container integration command. +- Local full workspace tests, strict all-target Clippy, formatting, ShellCheck, + and the CI container integration passed. The beezelbot base phase applied + twice; the repeat issued no service start/restart and the disposable container + was removed. The container runtime needed SIGKILL after its 10-second stop + timeout during cleanup; this did not fail the test. +- Fresh VM activation/reboot, host-profile acceptance, and retained-run recovery + are unverified because this workstation has no `/dev/kvm`. + +### Typed Podman volume-root ownership, 2026-10-04 + +- Added optional typed host mode and ownership to each bind-volume declaration. + Podman applies these only to the mount root. UniFi now declares mode `0750` + and numeric UID/GID `999` as part of its volume; other existing volumes leave + ownership unspecified. Descendant data is not recursively changed. +- The focused Podman and UniFi tests passed. The full offline workspace test + suite, strict all-target Clippy, formatting, and the documented beezelbot + Fedora container integration passed. The integration applied twice; the + second apply did not restart services. Podman needed SIGKILL after its + 10-second stop timeout while removing the test container. +- A regression test confirms the typed metadata does not change the existing + Quadlet bytes. VM verification of actual volume ownership, descendants, and + service restart behavior remains unverified because `/dev/kvm` is absent. + +### Workstation-owned disposable provisioning state, 2026-10-04 + +- Moved Cloudflare ownership JSON, Tailscale pending-enrollment markers, and + enrolled-device records from the CLI into + `skillet_workstation::provisioning_state`. Filenames are unchanged. New + journals include separate host/environment/instance identity fields; readers + accept the prior JSON and hostname-only marker formats with exact identity + checks. Writes are atomic and mode `0600`; reads and writes reject + symlinks/non-regular files. Retrying with another VM identity fails closed. +- Workspace all-target tests, strict Clippy, and formatting passed. State tests + cover private file mode, state round trips, symlink refusal, idempotent marker + writes, typed identity conflict refusal, legacy state compatibility, and + marker cleanup. The documented + beezelbot Fedora container integration passed twice without a restart on the + second apply; cleanup escalated to SIGKILL after its 10-second timeout. +- No provider resources or live VMs were changed. Full enrollment/cleanup + acceptance remains pending; `/dev/kvm` is absent. + +### Shared private UI provisioning plan, 2026-10-04 + +- Added `skillet_workstation::ui_provisioning` as the shared derivation for + profile-declared Caddy sites and Cloudflare A/AAAA/CNAME records. Production + and disposable VM delivery now use the same plan and explicit environment + policy. It reads the services from the canonical host profile, including + rendered aliases, without host-specific UI code in the delivery path. +- The three focused UI plan tests passed, followed by all workspace tests + (including 33 workstation tests), strict all-target Clippy, and format check. +- `cargo run --offline --manifest-path skillet/Cargo.toml --bin skillet -- + test run beezelbot --phase base --image fedora:latest` exited 0. Both applies + succeeded; the repeat issued no service start/restart. Podman needed SIGKILL + after its 10-second stop timeout during test-container cleanup. +- This exercised derivation and the existing base container fixture only. No + Cloudflare mutation, credential delivery, or live VM check was performed. + `/dev/kvm` is unavailable on this workstation. + +### Tailscale guest status parsing boundary, 2026-10-04 + +- Moved guest `tailscale status --json` interpretation from CLI orchestration + into `skillet_workstation::tailscale`. The provider returns no addresses + until its backend is running and rejects malformed or incomplete running + responses; the CLI retains only remote invocation and bounded retry policy. +- All seven Tailscale provider tests passed, including running/non-running, + invalid-address filtering, malformed JSON, and missing address arrays. The + full workspace tests, strict all-target Clippy, and formatter check passed. +- The documented beezelbot container integration built its target executable + but produced no runtime output for over 45 seconds. No matching Skillet or + Podman worker appeared in the process listing; the command was interrupted. + Treat this integration as unverified for this slice. +- No VM or live provider API was contacted. `/dev/kvm` remains unavailable. + +### Shared Tailscale guest enrollment retry, 2026-10-04 + +- Moved the bounded retry loop into `skillet_workstation::tailscale` while + keeping the guest SSH probe in the CLI. Empty status and transient probe + errors retry until the supplied timeout; tests cover delayed success and + timeout without sleeping. +- All 9 Tailscale provider tests passed. Workspace format, all-target tests, + and strict all-target Clippy passed. +- The beezelbot Fedora base integration exited 0: both applies succeeded and + the repeated apply issued no service start/restart. The disposable container + needed SIGKILL after Podman's 10-second stop timeout during cleanup. This + successful retry supersedes the stalled integration attempt recorded above. +- No VM or provider API was contacted. `/dev/kvm` remains unavailable. + +### Fail-closed Tailscale enrollment probe, 2026-10-04 + +- Initial enrollment now propagates SSH/status command errors rather than + interpreting them as an empty device state. The new regression test confirms + a failed guest status command refuses enrollment even when stdout resembles + a valid `NeedsLogin` response. +- The focused regression test, full offline workspace tests, strict all-target + Clippy, and format check passed. The beezelbot Fedora base integration exited + 0; both applies succeeded, and the repeat issued no service start/restart. + Podman needed SIGKILL after its 10-second stop timeout while cleaning up the + disposable container. +- No live VM or provider API was used. `/dev/kvm` remains unavailable. + +### Shared deferred credential-set delivery, 2026-10-04 + +- Added `skillet_vm::credential::install_set`, which validates the full + duplicate-free credential set before any guest mutation and applies a single + consumer/activation policy. Production and disposable Caddy flows install + `caddy_sites` and `cloudflare_acme_token` as one deferred set, then activate + Caddy after successful delivery. +- Credential delivery tests passed for stdin-only payloads, deferred + activation, complete-set validation, duplicate refusal, and guest failure. + Workspace formatting, all-target tests, and strict all-target Clippy passed. +- The beezelbot Fedora base integration exited 0: two applies succeeded, the + repeat issued no service start/restart, and the disposable container was + removed. Podman needed SIGKILL after its 10-second stop timeout. +- No VM credential delivery or live provider API was run; `/dev/kvm` is + unavailable. + +### Workstation-owned disposable Tailscale enrollment, 2026-10-04 + +- Moved the test VM enrollment transaction into + `skillet_workstation::tailscale_enrollment`. The CLI supplies typed host, + environment, instance, VM hostname, run directory, provider credentials, and + verified guest transport. The workflow persists intent, reuses an existing + enrollment, refuses failed status probes before auth-key creation, delivers a + one-use key only after a successful empty observation, verifies the tagged + device, and saves the ownership record before removing pending state. +- Four orchestration tests passed with fake guest transport and provider: + existing enrollment, new enrollment/key delivery, failed-probe recovery + state, and refusal of production policy. Full workspace tests, strict + all-target Clippy, and formatting passed. +- The beezelbot Fedora base integration exited 0: both applies succeeded, the + repeat issued no service start/restart, and the disposable container was + removed. Podman needed SIGKILL after its 10-second stop timeout. +- No VM or live Tailscale API was contacted. `/dev/kvm` is unavailable. + +### Workstream 3 ownership API audit, 2026-10-04 + +- Static search found no `/etc/subuid` or `/etc/subgid` reads outside the + Podman adapter and no recipe reads of `CREDENTIALS_DIRECTORY`; account/NSS + lookups remain in the system adapter. File, systemd, and Btrfs observations + remain inside their declared core adapters. +- The ownership contract still has a concrete gap: file mutation accepts + separate name-only owner/group arguments while directory mutation accepts + the shared named-or-numeric `Ownership` type. A proposed broad core API + migration was rejected by automatic review because it affects filesystem + behavior across recipes. No source changes were made for that rejected + migration. `/dev/kvm` is absent, so privileged ownership and mount checks + remain unavailable. + +### Workstation-owned non-UI credential delivery, 2026-10-04 + +- Moved production Pi-hole password reuse/use-or-create and Tailscale auth-key + creation/delivery into `skillet_workstation::credential_delivery`. Disposable + Pi-hole secret reuse/rotation now uses the same module. The shared + `vault::SecretStore` and `VaultSecretStore` adapter also back Caddy token + provisioning. The CLI now selects its typed workflow and constructs the + verified transport. +- The workstation suite passed (66 tests), including missing/existing Pi-hole + secrets, refusal when the guest already has an untracked secret, disposable + reuse/rotation and inspection failure, and production-tagged Tailscale key + delivery. Full workspace tests, strict all-target Clippy, and formatting + passed. +- The documented Fedora Podman base integration exited 0: both applies + succeeded; the repeat issued no service start/restart and the container was + removed. Podman needed SIGKILL after its 10-second stop timeout. +- No production vault, live provider API, or named VM was used. Production + credential acceptance remains unverified; `/dev/kvm` is unavailable. + +### Workstation-owned persistent private UI provisioning, 2026-10-04 + +- Moved persistent Caddy provisioning into one workstation operation. It checks + the host profile, resolves the tagged Tailscale device, builds the shared UI + plan, reuses or mints a zone-scoped ACME token, migrates the prior production + KeePass entry when present, reconciles owned DNS, installs both Caddy + credentials before activation, and uses the existing verified guest + transport. Failed encrypted vault persistence revokes the newly issued + token. The CLI now supplies inputs and dispatches the operation. +- Fake store/provider/guest tests cover token reuse, issuance-before-DNS, + legacy migration, vault conflict refusal, failed-save revocation, and + deferred activation after the full credential set. Full workspace tests, + strict all-target Clippy, and formatting passed (56 workstation tests). +- The beezelbot Fedora base integration exited 0: both applies succeeded; the + repeat issued no service start/restart and the container was removed. Podman + needed SIGKILL after its 10-second stop timeout during cleanup. +- No vault or live provider credentials were used here. Caddy certificate + acceptance and Tailscale device lookup on a named production host remain + unverified; `/dev/kvm` is unavailable. + +### Workstation-owned disposable Caddy guest delivery, 2026-10-04 + +- Moved disposable Caddy credential-set installation, activation, per-name + non-tailnet denial probes, and superseded-token revocation into + `skillet_workstation::ui_provisioning`. Retry timing is bounded per hostname. + Old tokens are retained until guest activation and every canonical UI/alias + probe succeeds. +- Fake guest/provider tests cover deferred credential delivery, all-name probe + arguments, revocation after success, and retaining older credentials when + the denial response fails. Full workspace tests, strict all-target Clippy, + and formatting passed (58 workstation tests). +- The beezelbot Fedora base integration exited 0: both applies succeeded; the + repeat issued no service start/restart and the container was removed. Podman + needed SIGKILL after its 10-second stop timeout during cleanup. +- No VM, vault, or live provider API was used. Live Caddy ACME/HTTPS acceptance + remains pending because `/dev/kvm` is unavailable. + +### Workstation-owned disposable Cloudflare UI transaction, 2026-10-04 + +- Moved disposable UI token issuance and DNS reconciliation into + `skillet_workstation::ui_provisioning`. It persists intent before token + creation, checks the vault snapshot immediately before issuing, saves token + ID/expiry before DNS changes, and saves owned record IDs after reconciliation. +- Six UI provisioning tests passed, including write-ahead ordering, vault + change refusal, and retaining token ownership when DNS reconciliation fails. +- The full workspace tests, strict all-target Clippy, and formatting checks + passed (44 workstation tests in the workspace run). +- The documented beezelbot Fedora base integration exited 0: both applies + succeeded; the repeat issued no service start/restart. Podman needed SIGKILL + after its 10-second stop timeout during cleanup. +- Guest credential delivery, Caddy HTTPS acceptance, and live Cloudflare API + behavior were not exercised here. `/dev/kvm` is unavailable. + +### Workstation-owned disposable Cloudflare cleanup, 2026-10-04 + +- Moved disposable UI cleanup into `skillet_workstation::ui_provisioning`. + Before provider mutations, it checks the test policy, typed VM identity, + deterministic token/marker names, configured zone, and current relative UI + namespace. It removes marker-owned DNS, revokes disposable and cleanup + tokens, then removes the journal. Failures leave ownership state retryable; + malformed and symlinked state paths are refused. +- Cleanup tests cover successful revocation and journal removal, repeated + cleanup with no journal, identity mismatch before mutation, and DNS failure + retaining the journal. State tests cover regular-file presence/removal and + symlink refusal. The full workspace tests, strict all-target Clippy, and + formatting checks passed (48 workstation tests). +- The beezelbot Fedora base integration exited 0: both applies succeeded; the + repeat issued no service start/restart and the container was removed. Podman + needed SIGKILL after its 10-second stop timeout during cleanup. +- No VM or live Cloudflare API was contacted. `/dev/kvm` is unavailable. + +### Workstation-owned disposable Tailscale cleanup, 2026-10-04 + +- Moved test-device cleanup into `skillet_workstation::tailscale_enrollment`. + The operation validates the disposable environment and typed host/instance/ + VM hostname, verifies ownership journals, asks the provider to remove only + the recorded tagged device, and removes recovery files after provider success + or an already-absent device response. Provider errors retain the journal. +- The workstation crate tests passed (51 tests), including provider failure + retry, recorded-device identity verification, local journal cleanup, and + identity mismatch refusal before provider calls. Full workspace tests, + strict all-target Clippy, and formatting also passed. +- The beezelbot Fedora base integration exited 0: both applies succeeded; the + repeat issued no service start/restart and the container was removed. Podman + needed SIGKILL after its 10-second stop timeout during cleanup. +- No VM or live Tailscale API was contacted. `/dev/kvm` is unavailable. + +### Manifest-backed disposable provisioning transport, 2026-10-04 + +- Removed the disposable provisioner's call through the `test-vm` compatibility + wrapper and its parser for legacy `run.conf`. It now loads the typed run + manifest, holds the per-run lock, uses its recorded SSH target, and validates + the expected running domain before and after each guest operation. +- Destroy's provider cleanup callback now receives the validated run identity + from the lifecycle orchestrator, checks it against the CLI request, and + derives the cleanup journal path from that recorded identity. +- Extracted `OwnershipCheckedTransport` from readiness so both readiness and + workstation provisioning use the same pre/post ownership-check contract. + Transport tests cover both checks around execution/upload and report ownership + loss after a remote operation. +- `cargo fmt --all -- --check`, `cargo test --workspace --all-targets --offline`, + and `cargo clippy --workspace --all-targets --offline -- -D warnings` passed. + The workspace suite includes 66 workstation tests. +- `cargo run --offline --bin skillet -- test run beezelbot --phase base --image + fedora:latest` exited 0. Both applies succeeded and the repeat issued no + service start/restart; Podman needed SIGKILL after its 10-second stop timeout + during cleanup. +- No named VM or production vault/provider was used. `/dev/kvm` is absent, so + named-VM delivery, cleanup, and production credential acceptance remain + unverified. + +### Unified typed file ownership API, 2026-10-04 + +- File and directory mutation APIs now accept the same `Ownership` value for + optional named or numeric UID/GID. Local files and directories resolve named + identities consistently, while numeric IDs do not require a matching NSS + account. Existing metadata omitted from an ownership request is preserved. +- Recorder operations serialize this common ownership value; diagnostic + recording schema is now version 2. Version 1 output remains historical and + is not rewritten. +- Added local filesystem tests for numeric ownership and idempotent repeat + apply, plus a numeric comparison test that does not perform NSS lookup. + Existing shared mutation contract tests continue to exercise both the local + and fake file adapters. +- `cargo fmt --all -- --check`, + `cargo test --workspace --all-targets --offline`, and strict all-target + Clippy passed. The beezelbot Fedora base integration exited 0: both applies + succeeded, the repeat issued no service start/restart, and the disposable + container was removed. Podman needed SIGKILL after its 10-second stop + timeout during cleanup. +- Named-VM ownership/mount acceptance remains unverified because `/dev/kvm` + is unavailable on this workstation. + +### Standalone KeePassXC unlock, 2026-10-04 + +- Added `skillet secret unlock` to verify the configured KeePassXC database + and establish the existing three-hour session-keyring cache without starting + host or VM provisioning. `--database` and `--key-file` override the defaults. +- The command exits with an error if the vault opens but the cache cannot be + established. CLI parsing tests cover the default path and both overrides; + the vault cache tests cover the three-hour expiry and the cache result. +- `cargo fmt --all -- --check`, offline workspace all-target tests, and strict + offline all-target Clippy passed. The beezelbot Fedora base integration + exited 0: both applies succeeded, the repeat issued no service start/restart, + and Podman removed the disposable container after its stop timeout. +- Interactive validation against the user's vault was not run because the + password must be entered by the user. + +### Retained clamps smoke VM readiness and fixture, 2026-10-04 + +- The retained `clamps-test-smoke` VM was running but its local run state was + not marked ready. The first readiness attempt reached signed boot and base + apply, then found `uv` missing from the cloned dotfiles Brewfile. Installed + the missing dependency inside the disposable VM with its existing Brewfile; + no workstation packages were installed. +- Retried `cargo run --offline --bin skillet -- test vm ready clamps smoke`; + it passed signed boot, base apply, Homebrew bundle, and user-environment + checks. `cargo run --offline --bin skillet -- test smoke clamps` then passed + all fixture cases, including its reboot phase. Guest snapshots and journals + are under `/var/lib/skillet-smoke/`; the VM remains running for inspection. +- Vault-backed Caddy/Cloudflare provisioning was not attempted. Although the + user unlocked the vault interactively, the assistant execution process could + not read that session keyring entry and has no interactive password input. + No provider mutations were made by this attempt. + +### Fresh disposable UI and roadmap acceptance, 2026-10-04 + +- After the environment-scoped vault paths were corrected, a fresh + `clamps-test-generic-ui` VM completed create, signed readiness, and + `test vm provision clamps generic-ui --with-ui`. Provisioning used the test + vault entries, enrolled the disposable VM in Tailscale, reconciled owned + Cloudflare DNS, delivered credentials, and activated Caddy. +- Guest Caddy logs confirmed successful Let's Encrypt staging ACME issuance + for every declared UI name. The in-guest HTTPS loopback denial probes returned + the expected 403 for each name. The fixture smoke passed, including its + failure-recovery and reboot cases. Destroy removed the journaled DNS/token + and Tailscale resources before deleting the VM. No production credential or + production issuer was used. +- Local validation on this runner: `cargo fmt --all -- --check`, strict + all-target Clippy, and all workspace/all-target tests (244 passed) passed for + the configured musl target. The CI integration command + `cargo run --bin skillet -- test run beezelbot --phase base --image + fedora:latest` passed both applies; repeat apply caused no service + start/restart. Workflow-wide ShellCheck and + `bash ublue/butane/tests/bootstrap.sh` passed. A supplementary GNU-target + suite and integration also passed. The integration container needed SIGKILL + after its 10-second stop timeout. +- At this checkpoint, retained-host repeat apply, live process/network/storage + assertions, credential rotation, and post-rotation reboot were outstanding. + They were completed in the following retained-VM acceptance entry. Remaining + live gaps are listed there. + +### Retained clamps VM apply, storage, and repeat-apply acceptance, 2026-10-04 + +- Host-context verification confirmed `/dev/kvm` and the running retained + `clamps-test-smoke` VM. `test vm ready clamps smoke` passed, including signed + boot, captured-binary delivery, base apply, and user-environment checks. +- `test vm provision clamps smoke` completed twice after one manually + interrupted attempt. The successful retries enrolled/verified the test VM in + Tailscale, delivered Pi-hole credentials, and completed the declared clamps + application. No Caddy or Cloudflare operation was requested in this run. +- The fixture smoke passed after provisioning, including pre-reboot failure + recovery and post-reboot checks. `test vm status clamps smoke` confirmed the + recorded domain remained running and owned after the cycle. +- Guest inspection through `test vm ssh` confirmed `/var/lib/data` is the + Btrfs `data` subvolume mounted from the VM disk; Podman graph storage and + Pi-hole, Syncthing, Tailscale, and UniFi use the expected paths under it. + Pi-hole and Syncthing use the bridge network; Tailscale and UniFi use host + networking. UniFi's container user and data-root ownership resolved to the + declared numeric identity. All four service containers were running, and + UniFi reported healthy. +- A further full provisioning apply left the recorded Podman start timestamps + unchanged for all four service containers, confirming no restart on repeat. +- `test vm provision clamps smoke --rotate` succeeded. Pi-hole's container + restarted with the new generated test credential; Syncthing, Tailscale, and + UniFi start times did not change. A normal apply afterward left all four + start times unchanged. After another fixture reboot, all four services and + the fixture container were running, the encrypted Pi-hole credential file + was present, and `skillet-full-apply.service` reported `Result=success`. +- For the ownership check, created a temporary child with UID/GID `12345:12345` + and mode `0600`, then changed only its managed UniFi volume root to + `12345:12345`/`0700`. Host apply restored the volume root to `999:999`/`0750` + and preserved the child's UID/GID/mode. The temporary file was removed, + UniFi was healthy, and `systemctl --failed` was empty afterward. +- At this checkpoint, controlled interrupted recovery and legacy-manifest + import remained open. Both passed in the following acceptance entry. + Production delivery/renewal and outside-tailnet denial remain separate + deferred acceptance. + +### Legacy manifest import and interrupted provisioning recovery, 2026-10-05 + +- Legacy import: on the running retained `clamps-test-smoke` VM, temporarily + moved the current `vm.json` aside and ran + `skillet test vm ready clamps smoke --verbose`. Skillet read the legacy + `run.conf`, validated its UUID and disk against the live libvirt domain, + imported the state, reapplied signed-boot/readiness checks, and returned + success. The resulting `vm.json` was mode `0600`, phase `ready`, and retained + the captured/deployed artifact hashes. `run.conf` had the same SHA-256 before + and after; the original manifest backup was removed after verification. +- Interrupted recovery: created the disposable `clamps-test-recovery` VM on + port 2202 and completed readiness. Started + `skillet test vm provision clamps recovery --verbose`, then sent Ctrl-C only + after `tailscale-pending` existed. Before and after interruption the marker + was mode `0600` with the expected test host/environment/instance identity; + `tailscale.json` was absent and the VM manifest remained `ready` with the + same UUID. The command exited on interruption without removing ownership + state. +- Retried the same provision command. It delivered the Tailscale credential, + completed full service apply and enrollment, wrote `tailscale.json`, and + removed `tailscale-pending`. `skillet test smoke clamps --instance recovery` + passed all fixture cases, including reboot. `skillet test vm destroy clamps + recovery --verbose` removed the journaled Tailscale ownership and disposed + the VM. A final VM list showed only the retained `clamps-test-smoke`; its + libvirt domain and disk were untouched. +- No production credentials, production DNS, or Cloudflare APIs were used for + this recovery check. Production delivery/renewal and outside-tailnet denial + remain deferred from the refactoring gate. + + +### Native fleet SMTP provisioning, 2026-10-09 + +- ucore-images commit `3223d48` adds Postfix and `cyrus-sasl-plain` to the common + image, initially disabled. The previously published common image was inspected + and lacked both packages. Package availability was verified in a disposable + Fedora image; the signed uCore image rebuild remains an operator step. +- Passed `cargo fmt --all -- --check`, pedantic workspace Clippy, all 323 workspace + tests, Fedora beezelbot base integration (repeat apply issued no service + start/restart), Butane bootstrap regression tests, and Ruff check/format for + the native acceptance fixture. These checks require no provider credentials. +- Production `secrets check --host clamps --environment production` passed: + 7 required checked, 0 missing/invalid, 0 unused, including SMTP sender/custom + fields. This validates presence/formats, not Mailjet permissions or sender DNS. +- Ran `crates/smtp/tests/native_acceptance.py` against the Cargo-reported musl CLI + in fresh disposable Fedora systemd containers with `--network none` and + synthetic credentials/addresses. Capture and production-policy runs passed; + both containers were removed. No provider APIs or external email were used. +- Actual Postfix accepted host sendmail submission, preserved the recipient and + queued message across stop/apply-start, and delivered to the loopback capture + sink after an upstream outage. Repeated apply preserved the Postfix PID. + Credential preparation regenerated a deleted volatile map. The production + map was root-owned mode `0640`, under root/Postfix-group mode `0750`; a query + under the Postfix worker identity verified backend access. +- With production policy and a synthetic upstream that did not offer STARTTLS, + Postfix retained the queued message and sent no AUTH, MAIL or message content. + Sender canonical rewriting preserved the recipient. This is not acceptance + of provider authentication or certificate validation against a TLS server. +- Pending: rebuilt uCore VM with enforcing SELinux, boot/reboot/rebase map and + queue persistence, non-loopback access rejection, certificate/authentication + failures, explicit Mailjet/Gmail delivery with SPF/DKIM/DMARC inspection, and + UniFi recovery-email configuration. Bridge clients and queue-age/send-failure + metrics are separate remaining implementation slices; unit health is declared. + + +### Rebuilt uCore SMTP capture acceptance, 2026-10-09 + +- [Image workflow 37916306725](https://github.com/gbagnoli/ucore-images/actions/runs/37916306725) + succeeded for ucore-images `3223d48`. Created the owned disposable + `clamps-test-smtp` on port 2202; UUID + `59878d4b-8095-4ebb-81e9-dc9e4658914d`. The retained smoke VM was untouched. + Signed clamps boot/readiness passed; the guest contained + `postfix-3.10.14-1.fc44` and `cyrus-sasl-plain-2.1.28-35.fc44`. +- Fresh activation exposed missing package `/var` state on ostree. Skillet now + initializes the full packaged queue directory layout, PID directory and + `/var/lib/postfix` with package ownership/modes before confined startup, then + restores security labels. Initializing only the queue root was insufficient: + confined startup could not initialize all remaining directory metadata. +- Capture-only delivery used `test vm provision clamps smtp --smtp-only` without + vault/provider access. With SELinux enforcing, native Postfix and credential + preparation started, host sendmail queued a synthetic message, and connections + to the guest's non-loopback IPv4 address on port 25 were rejected. +- Removed the volatile map, rebooted through `test vm reboot`, and ran owned + readiness. The encrypted credential survived, boot regenerated the map, and + the synthetic message persisted and delivered to a temporary loopback sink. + The first observation captured a transient maildrop ID; subsequent checks + waited for the finalized deferred ID before comparing identities. +- Exercised signed common-image rebase and return to signed clamps. For the + direct common check, temporarily changed `/etc/ucore-bootstrap-image` to the + common image and restored it before returning. Disabling bootstrap alone is + insufficient because the base unit requires it and can start it again. + Confirmed actual signed common boot, unchanged finalized queue ID, regenerated + map, enforcing SELinux, and successful retry. Signed clamps readiness passed + after restoring its bootstrap target. All guest assertions used the owned + `test vm ssh` interface; reboot and readiness used the Rust lifecycle owner. +- Local checks passed: formatting, pedantic workspace Clippy, 324 workspace + tests, Fedora base repeat-apply integration, and Ruff check/format. Both native + fixture policies additionally passed after deleting package queue/data state + inside isolated disposable containers; both containers were removed. +- After returning to signed clamps, updated both guest binaries to the current + fix and repeated capture provisioning. Confirmed the restored image/target, + empty queue, retained encrypted credential, and unchanged Postfix PID on repeat + apply. Disposed `clamps-test-smtp`; final VM list retained only the running + `clamps-test-smoke` on port 2201. +- No real provider credential or external email was used. Production credential + map access and certificate/authentication failures under enforcing SELinux, + Mailjet/Gmail delivery, sender-domain verification and UniFi SMTP settings + remain pending. Bridge clients and queue-age/send-failure metrics remain + implementation follow-ups in `plan/CLAMPS-EMAIL.md`. + +## 2026-10-09 — live Mailjet relay and Gmail receipt + +- Created fresh owned `clamps-test-mailjet` on native `qemu:///session`, SSH port + 2202. Signed uCore boot and base readiness passed; retained smoke VM untouched. +- Explicit `--smtp-only --live-smtp` provisioning used cached KeePass access and + shared encrypted credential delivery. Normal VM provisioning remains capture-only; + the override does not change VM environment or enable other production services. +- Submitted one operator-authorized message. Initial delivery deferred because + the compatibility CA symlink was absent and the SMTP worker could not read + RAM credentials with the generic SELinux label. TLS policy prevented plaintext + fallback. Fixed the extracted trust-bundle path and runtime credential labeling, + updated both guest binaries, and retried the original queue entry without + submitting a duplicate. +- Mailjet accepted the original message over verified TLS with SMTP authentication. + Operator confirmed Gmail receipt. SELinux remained enforcing; credentials have + `postfix_etc_t`, root/Postfix ownership and restricted Unix permissions. Queue + drained. Encrypted credential preparation recreated the deleted RAM map with + the correct label. Restarting the prerequisite stopped its dependent Postfix unit; + apply recovered it. A subsequent ordinary repeat production apply preserved + Postfix PID and empty queue. +- Local checks: 328 workspace tests, formatting, pedantic Clippy, Fedora base + repeat-apply, and Ruff check/format passed. Isolated native capture and + production-policy fixtures passed from absent mutable package state; configured + CA bundle exists, and an upstream without STARTTLS receives no credentials/mail. +- SPF/DKIM/DMARC headers, explicit invalid-certificate/authentication cases, and + UniFi recovery-email integration remain unverified. No additional provider + messages were submitted. The disposable Mailjet VM and fixture containers were + removed after acceptance; provider secrets/private sender details are not recorded. + +## 2026-10-09 — caller-selected Tailscale exit-node configuration + +- Clamps declares production exit-node advertisement. Production delivery wraps + its one-use key with resolved routing policy in a versioned encrypted payload; + disposable delivery explicitly disables advertisement. No hostname-based + environment inference, new vault entries, or control-plane ACL mutations. +- Raw retained credentials remain readable with advertisement disabled; + production redelivery activates the declared policy. Only the decoded key is + delivered to the Podman secret. Invalid/unknown payload versions fail safely. +- Retained host networking, state, capabilities and `TS_AUTH_ONCE=true`. + Quadlet health readiness orders native `tailscale set` after CLI availability, + so persisted preferences converge on service startups as well as first join. + Explicit false withdraws previous advertisement. Existing shared IPv4/IPv6 + forwarding settings already enable routing; no image changes were needed. +- Formatting, pedantic Clippy, all 331 workspace tests and Fedora base repeat-apply + passed. Installed Podman 6.1 Quadlet compiler accepted a focused directive + fixture for both true and false, including healthy notification and the native + post-start preference command. Fixtures did not start containers or enroll nodes. +- Live VM/client routing was intentionally omitted by operator decision, rather + than recorded as passing. Production tailnet exit-node approval and client + selection remain operator deployment steps. Retained smoke VM was not modified. + + +## 2026-10-09 — read-only host baseline audit + +- Used the owned `test vm ssh clamps smoke` transport for bounded read-only + inspection of the retained VM. No reapply, reboot, service or account changes. +- SSH server syntax and client configuration parsing passed. Effective key-only + authentication, root policy, forwarding and SFTP match the public Chef role. + SSH service enabled/active; home and Ignition key permissions appropriate. +- Sudo syntax passed. Base uCore sudo-group rule grants passwordless access, + independently of the VM override. This differs from Chef's default password + requirement. Production forced-password-change behavior is removed by VM + staging and was not accepted here. +- Eternal Terminal installed/enabled/active with dual-stack TCP 2022 listeners; + an end-to-end ET client session and physical firewall access were not tested. +- Giacomo UID/GID is 1001, versus Chef's declared 1000; Core owns 1000. Live + user authorized_keys contains seven keys in addition to the Ignition file; + no key material or password hashes copied into this record. +- Brew and dotfiles oneshots succeeded and their markers exist. Installer `-V` + explicitly skips Vim plugins; plugin installation remains deferred. +- SELinux enforcing, auditd active/enabled, both forwarding sysctls set to one. + OS-hardening placeholder and observed login-policy differences remain open. + WOL installed; private target shortcuts and actual wake-up were not tested. +- Source comparison confirmed identical image/Skillet SSH and sysctl files. + Findings and ordered follow-ups: `../plan/CLAMPS-BASELINE-AUDIT.md`. + + +## 2026-10-09 — explicit administrative access finalization + +- Operator accepted UID 1001 and selected temporary passwordless provisioning, + followed by password-required sudo and first-login password change. +- Shared guest/host CLI now offers `access finalize --user `; base apply + never invokes it. New production Butane inputs no longer run early automatic + expiry. Existing install media must be regenerated; installed legacy expiry + units require explicit retirement. No retained VM or production account changed. +- Finalization validates sudo policy, installs the account's PASSWD override and + expires its existing password. Root-only intent/completion state and a lock + support retry without expiring a password already changed by the operator. +- Formatting, pedantic Clippy, 338 workspace tests, Fedora base repeat-apply and + all eight bootstrap regressions passed. CI unit coverage includes command + parsing, one-time expiry, expiry failure/retry, interrupted recovery preserving + a changed password, invalid journal rejection and account-name validation. +- An isolated Fedora container with native sudo/shadow tools passed temporary + passwordless access, final expiry, passwordless rejection, new-password sudo + acceptance, repeat finalization preserving password age, and sudo syntax. + Synthetic passwords only; fixture removed. No workstation packages installed. +- Interactive first-login password change over SSH/console on a fresh uCore + install remains unverified. Later workstation delivery currently assumes + passwordless sudo; finish it first, then use interactive sudo/root administration. + + +## 2026-10-10 — TPM/boot capability audit + +- Used the owned retained `clamps/smoke` SSH transport for read-only package, + boot-layout and deployed-initramfs inspection. No guest state mutation, + reboot, credential delivery, enrollment or formatting. +- Deployed uCore: Clevis 21, cryptsetup 2.8.8, dracut 108, systemd 259.8 with + TPM2 support, TPM2 tools 5.7. Initramfs includes Clevis TPM2, cryptsetup, + systemd TPM token plugin and Btrfs. Boot entries use separate kernel/initrd; + EFI runtime and TPM devices absent in the existing BIOS VM. ukify and + systemd-measure were not found; signed PCR policies are not configured. +- Workstation: KVM present; swtpm 0.8, QEMU 10.1, libvirt 11.10, edk2 OVMF + installed. q35 domain capabilities advertise EFI/Secure Boot and emulated + TPM2. Secure Boot firmware and enrolled-key NVRAM template files exist. + No physical TPM device exposed. No workstation packages installed. +- Reviewed current CoreOS/Clevis/systemd upstream documentation. Native TPM + encryption and signed-PCR boot integrity are distinct guarantees; protection + scope choice remains pending. Initial FCOS image initramfs, PCR banks, actual + UEFI/Secure Boot/MOK startup and encrypted rebase/rollback are unverified. +- Updated storage design and TPM plan. No encrypted profile or VM was created; + current install templates and both repositories' runtime code are unchanged. + +## 2026-10-11 — protected header backup and native TPM-loss recovery + +Native `qemu:///session`, fresh `clamps / header-recovery` (SSH 2202), recovery +clone `clamps / tpm-loss` (2203). The existing `clamps / smoke` VM on 2201 was +preserved. No workstation packages or physical disks/TPMs were changed. + +Passed: + +- Fresh TPM-profile install reached signed uCore and base/user readiness. +- `test vm recovery-key clamps header-recovery` persisted the independent key, + volume UUID and verified `root-luks.header` attachment in the encrypted vault. + Repeating it left the encrypted vault bytes unchanged. +- The archived header was restored onto a new 64 MiB tmpfs regular-file fixture; + the archived passphrase unlocked it. The fixture and plaintext inputs were removed. +- A stopped disk copy received independent TPM/NVRAM and an unlogged console. + The fresh TPM failed automatic unlock and reached the passphrase prompt; + the vault passphrase booted the same encrypted volume. +- Explicit re-enrollment verified the new TPM before deleting the old binding, + retained the independent recovery slot, and saved a separate clone header. +- Two unattended cold boots preserved volume identity and the flushed data + sentinel, with exactly two keyslots. Repeating completed recovery left vault + bytes unchanged and introduced no additional slots. +- The original TPM still unsealed its original key and unlocked its unchanged + volume. Its header archive matched the pre-clone hash throughout recovery. +- Both owned recovery VMs, disks, TPM/NVRAM and run artifacts were disposed. + Vault archives remained unchanged; the original smoke VM remained running. + +Driver: `cargo run --release -p skillet_workstation --example tpm_recovery -- +../butane clamps header-recovery tpm-loss 2203 ACTION`, with `clone`, `unlock`, +`rebind`, `verify` in order. Usage is in the Skillet README. Clone preflight +allows known readiness evidence but rejects provider journals, containers, +Podman secrets and delivered encrypted credentials. + +Local checks passed: formatting, pedantic workspace Clippy, all 363 workspace +and example tests, ShellCheck for the three recovery helpers, and Fedora +`test run beezelbot --phase base --image fedora:latest` (both applies succeeded; +repeat apply issued no service start/restart). Routine CI uses fixture KDBX and +fake transports; this live run used the cached vault unlock. + +Still unverified: physical TPM/firmware compatibility and rescue installation, +Flatpak encrypted-VM acceptance, offline-network and remaining boot-policy / +update / rollback matrix cases. A header backup is not a service-data backup. + + +## 2026-10-11 — physical installation media + +- `skillet install prepare HOST` selects an explicit whole-disk by-id identifier, + private bootstrap key/hash, and independent plain/TPM root profile. It leaves + the committed VM `/dev/vda` template unchanged and never writes a block device. +- Real FCOS 44.20260913.3.2 x86_64 live ISO downloaded and signature-verified by + the existing CoreOS installer container. Plain and TPM USB ISOs generated + successfully using disposable access inputs; TPM used a disposable DER + certificate, not an enrolled hardware key. +- Extracted both ISOs with `iso ignition show`, recursively decoded their merge + payloads, and verified the embedded destination-Ignition digests. Destination + storage selected the requested by-id disk; plain retained raw Btrfs and TPM + selected LUKS2 -> `/dev/mapper/root` -> Btrfs with networkless Clevis policy. + Verified private output-directory and Ignition permissions and replaced access + inputs without printing credential values. +- Full destination payload exceeded CoreOS's 256 KiB embedding limit. The + implemented flow embeds only the live helper/policy, operator public key and + payload digest; destination Ignition is transferred over verified SSH or + separate data media and verified in root-only live tmpfs before installation. +- CI/local checks: fmt, pedantic Clippy, 370 workspace/all-target tests, Fedora + base integration (two applies, no repeated service starts), ShellCheck, + all eight bootstrap regressions and isolated hardware-helper refusal tests + passed. CI now lints the live helper and rejects mismatched payloads/absent + target disks without vault/provider access or host block-device exposure. +- Native `clamps / physical-regression` on port 2202 booted the unchanged + `/dev/vda` installation, accepted SSH/binary delivery and reached signed + bootstrap. Initial readiness was paused to inspect slow image-layer downloads; + one retry lost SSH during a bootstrap reboot. The following retry reached + base/user-environment apply and temporarily received SSH connection resets. + After a quiet interval and retry, full readiness passed with recorded phase + `Ready`, including base apply, user environment and artifact verification. + Private evidence was retained before disposing of this regression VM. +- Unverified: booting the new USB ISO itself, actual S13 disk installation, + physical TPM/Secure Boot/MOK enrollment, LAN and production cutover. See + [the physical runbook](HARDWARE.md). Existing smoke VM was not modified. + + +## 2026-10-11 — boxy replacement host/boot slice + +- Source inventory: `roles/boxy.rb` and the default cookbook includes. Shared + services selected by canonical `beelzebot`: Pi-hole, host-network Tailscale, + Syncthing, caller-selected Syncthing-only btrbk, DDNS, Datadog and SMTP; + generic Caddy exposes only the declared UIs. UniFi is not selected. +- Operator explicitly authorized removing the misspelled profile/package without + compatibility. At that time commands and CI used `beelzebot`; earlier log entries + retain the command spelling actually executed. No vault or manifest migrated. +- Local: all 372 workspace/all-target tests, formatting, strict pedantic Clippy, + Fedora `test run beelzebot --phase base --image fedora:latest` (two applies, + no repeated starts/restarts), ShellCheck, nine bootstrap regressions and both + guarded hardware-helper refusal cases passed. Routine checks used no vault, + provider or hypervisor credentials. +- ucore-images: YAML parsing, Pi-hole DNS mask and serialized common-before-host + workflow assertions passed. The recipe change is committed; its rebuilt remote + image/CI has not been checked. The new Butane template also masks resolved + at day zero so the older correctly named image can boot with this policy. +- Native named run: `test vm create beelzebot migration --port 2202`, plain root, + `/dev/vda`, existing verified FCOS image; the clamps smoke VM was preserved. + Strict compilation and decoded destination Ignition assertions passed for + canonical host/image, resolved mask, full-apply credentials and disk layout. + Captures were built from the working tree before commit `13961a1`; its manifest + records the previous HEAD. Recorded hashes bind the delivered artifacts. +- Signed boot and binary delivery reached the base/user-environment stage; + full base/user readiness passed. `test smoke beelzebot --instance migration` + passed namespaced fixture convergence/failure/recovery checks, repeat apply, + reboot/persistence and final readiness. This was fixture mode, not full declared + application acceptance. The native run remains `Ready` on SSH port 2202 for + the next slice; final manifest/status/journals and command logs are preserved + privately at `/tmp/beelzebot-migration-evidence`. +- Vault-dependent audit could not unlock: `secrets check --host beelzebot + --environment test` returned `reading database password from terminal: No such + device or address`; the cache was unavailable in this process. No provider + mutation, production identity, DNS publication or real service-data transfer. + Full application/provider acceptance remains pending an operator unlock. +- Remaining parity: private Pi-hole v6 DNS configuration delivery, boxy's subnet + routing/acceptance/SNAT policy, private cross-host UI/probes, source data/ACLs, + physical deployment and cutover. See `../plan/JOEY-MIGRATION.md`. diff --git a/ublue/butane/HARDWARE.md b/ublue/butane/HARDWARE.md new file mode 100644 index 00000000..d761cb5a --- /dev/null +++ b/ublue/butane/HARDWARE.md @@ -0,0 +1,159 @@ +# Physical installation + +Status: media generation is implemented. Beelink S13 installation, physical TPM, +MOK enrollment, and LAN acceptance remain unverified. VM commands continue to +use `clamps.bu` with `/dev/vda`; never compile that template directly for hardware. +See [storage policy](../design/storage.md) and [remaining encryption checks](../plan/TPM-ENCRYPTED-ROOT.md). + +## Prepare + +1. Boot a verified Fedora CoreOS **x86_64 live ISO** on the server to inspect it. + Attach Ethernet, a keyboard and a monitor. Identify the stock SSD: + + ```sh + lsblk -o NAME,SIZE,MODEL,SERIAL,TYPE,MOUNTPOINTS + ls -l /dev/disk/by-id/ + ls /sys/class/tpm + mokutil --sb-state + ``` + + Record the SSD's whole-disk `/dev/disk/by-id/...` path, not a `-partN` link. + The path belongs to the server; it need not exist on the workstation. + Keep rupik and its production SSD running until the scheduled cutover. + +2. Prepare private operator inputs outside the checkout: an Ed25519 public key + and a **fresh bootstrap password hash** (yescrypt or SHA-256/SHA-512 crypt). + Use an existing password-hashing tool that prompts, keeping the plaintext + password off command lines and logs. Save the hash in a mode-0600 file. + For example, if OpenSSL is already available, this prompts for the password: + + ```sh + umask 077 + openssl passwd -6 > /absolute/private/path/bootstrap.hash + ``` + + Know that password: access finalization later requires it. Media generation + replaces the inherited public keys and committed bootstrap hash; it does not + generate or store an operator's SSH private key. + +3. For TPM, obtain the trusted ublue DER signing certificate using the + [uCore Secure Boot instructions](https://github.com/ublue-os/ucore#readme). + TPM requires UEFI, TPM 2 and Secure Boot with that MOK enrolled. Supply the + certificate for unencrypted installation too if Secure Boot is enabled. + Verify the live ISO's signature using Fedora's published verification flow. + `--iso` is an already verified local ISO; Skillet does not verify its signature. + +4. From `ublue/skillet`, prepare a **new private output directory**: + + ```sh + export PATH=/opt/x86_64-linux-musl-cross/bin:$PATH + cargo run --release -p skillet -- install prepare clamps \ + --disk /dev/disk/by-id/WHOLE_DISK_ID \ + --iso /absolute/path/fedora-coreos-live.x86_64.iso \ + --output /absolute/private/path/clamps-install \ + --ssh-public-key /absolute/path/operator_ed25519.pub \ + --password-hash-file /absolute/private/path/bootstrap.hash \ + --root-profile tpm \ + --mok-certificate /absolute/path/ublue.der + ``` + + Use `--root-profile unencrypted` for a machine without TPM; this is the + default. A requested TPM profile never silently falls back. Preparing TPM + media does not prove that the destination hardware supports it. + The existing Podman/Butane/CoreOS installer containers and musl toolchain + are required; no workstation packages are installed. + + The bundle includes `install.iso`, destination `clamps.ign`, live `live.ign`, + and staged source/binaries. It contains the private password hash: keep the + whole bundle outside Git, protect the USB, and delete it after acceptance. + Existing output directories are refused. No workstation block device is opened. + Failed tooling leaves a private diagnostic file named in the error; treat it + as sensitive and remove it after inspection. + +## Write and install + +5. Identify the **USB device on the workstation** with `lsblk`; it is separate + from the destination SSD declared above. Write the generated `install.iso` + using an existing image writer. If using `dd`, check the whole USB identity + before running this destructive command: + + ```sh + sudo dd if=/absolute/private/path/clamps-install/install.iso \ + of=/dev/disk/by-id/WHOLE_USB_ID bs=4M conv=fsync status=progress + ``` + +6. Boot that USB in UEFI mode. It starts a live environment and **does not + automatically install**. Find its temporary DHCP address with `ip -brief address`. + The supplied operator key also grants SSH access to the live `core` account. + Verify its SSH host-key fingerprint at the console before accepting it: + + ```sh + sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub + ``` + + From the workstation, transfer the prepared destination Ignition: + + ```sh + scp -i /absolute/path/operator_ed25519 \ + /absolute/private/path/clamps-install/clamps.ign core@LIVE_ADDRESS:clamps.ign + ``` + + On the server's live console or an interactive SSH session, run: + + ```sh + sudo /var/usrlocal/bin/skillet-install /home/core/clamps.ign + ``` + + The helper copies the supplied file into root-only live tmpfs and checks its + SHA-256 against the digest embedded in this USB. A payload from another bundle + is refused. Destination Ignition remains in the private bundle and travels + over SSH. See [the media design](../design/storage.md#physical-installation-media). + An offline alternative is a separate data USB: mount it and pass the Ignition + path to the same helper. + + The helper checks the prepared disk exists and is a whole disk. TPM media + additionally checks UEFI, TPM 2 and Secure Boot. With Secure Boot enabled, + it requires the supplied ublue certificate to be enrolled. If needed: + + ```sh + sudo mokutil --import /etc/skillet-install/ublue.der + ``` + + Reboot, complete the firmware MOK enrollment, then boot this USB again. + Retry the helper after the final Secure Boot/MOK policy is established. + It displays the disk model/serial/size and requires typing its complete + prepared identifier before running `coreos-installer`. Installation erases + that disk. A hardware mismatch stops installation; regenerate the bundle + instead of editing its target ad hoc. + +7. Remove the USB and reboot. Allow FCOS-to-uCore signed bootstrap and its + intermediate reboots to complete. Connect using the supplied SSH key; + verify the host key independently. Check signed boot, Skillet base apply, + Btrfs `/var/lib/data`, DNS, user environment and enforcing SELinux. + +## Provision and cut over + +8. Unlock KeePassXC and use the existing production `secrets deliver` commands + in [Skillet's README](../skillet/README.md). Check the production secret audit + first. Service delivery may activate the full host configuration: keep the + new controller isolated from production devices until the planned migration. + +9. For encrypted root, run the documented `secrets root-recovery-key clamps` + command **before finalizing sudo**. Back up the resulting vault, including + its verified recovery key and `root-luks.header` attachment, off the server. + Test cold boot, offline unlock and an approved OS update on the physical box. + +10. Follow the [migration plan](../plan/CLAMPS-MIGRATION.md): test Pi-hole from + LAN clients; production HTTPS and outside-tailnet denial; transfer service + data/identities; stop the old UniFi controller before restoring/enabling the + new one; switch DNS/DDNS with one writer and retain rupik for rollback. + Establish off-machine data backups; local snapshots are not a disk backup. + +11. Finish with `sudo /var/usrlocal/bin/skillet access finalize --user giacomo`. + Keep the current session open, change the password in a second interactive + login and confirm sudo asks for it. Automated credential delivery currently + assumes temporary passwordless sudo, so complete it before this step. + +PXE is an alternative transport for the same destination Ignition, but this +command currently produces USB ISO media only. Firmware PXE support and the +DHCP/boot-file server setup remain separate work. diff --git a/ublue/butane/README.md b/ublue/butane/README.md index 44df66ee..cd2d65f2 100644 --- a/ublue/butane/README.md +++ b/ublue/butane/README.md @@ -1,28 +1,274 @@ -# butane wrapper +# Fleet VM bootstrap -This is a simple compiler script that uses butane (running in podman) to -transpile the configurations to ignitions. +For real servers, use the separate [physical installation flow](HARDWARE.md). +VM creation keeps its `/dev/vda` template and independent root-profile choice. -It has minimal support for includes: every `.bu` include will get first -transpiled to ignition, then the original source will be modified to point to -the generated ignition file. An include can *not* include other files this way. +Available VM templates: `clamps.bu` and `joey.bu`. Both retain `/dev/vda` +and the shared Btrfs data layout. Use `joey` for boxy's replacement; +retained VM manifests and vault entries are not renamed implicitly. [Migration plan](../plan/JOEY-MIGRATION.md). -## running +```bash +./bin/test-vm joey create smoke --port 2202 +./bin/test-vm joey ready smoke +./bin/test-vm joey destroy smoke +``` + +Choose a free port if another VM already uses 2202. + +A clean disposable VM create passed signed boot and readiness on 2026-09-26. +The Flatpak backend on Bazzite passed create, status, and readiness on 2026-09-27. +See [ACCEPTANCE.md](ACCEPTANCE.md) for the checks performed and remaining work. + +Design decisions: [disposable VM lifecycle](../design/smoke-vms.md) and +[secret storage and delivery](../design/secrets.md). + +`clamps.bu` is a VM configuration. It resizes and reformats partition 4 on +`/dev/vda`, the virtio disk attached by this launcher. Do not use it as a +physical-disk install file. The host needs Podman, `yq`, `rg`, `ssh-keygen`, +and KVM. Native libvirt additionally needs `virsh`, `virtqemud`, +`virtstoraged`, and `passt`; Rust defines native VMs through `virsh`. The +Flatpak backend uses `virt-install`, QEMU user networking, and an existing +virt-manager installation with its QEMU extension. +The shared [data storage design](../design/storage.md) is implemented by +`includes/data-storage.bu`. On a fresh VM it mounts the Btrfs `data` subvolume +at `/var/lib/data` and sets rootful Podman's graphroot there. Recreate a +disposable VM made before this include to check the install-time layout. +`bin/butane` builds and stages the generic `skillet` CLI and the host-specific +`skillet-HOST` credential CLI when the input includes `includes/skillet.bu` and +the binaries were not explicitly staged under `files/`. Set `CARGO_TARGET_DIR` +to redirect Cargo output. The generated Ignition therefore contains both +executables for a normal host install. The VM launcher removes the two large +file entries from its temporary Ignition copy and delivers those same binaries +over SSH after the first boot. + +The VM command selects native user-session libvirt when its tools and daemons +are available, then uses the existing Flatpak virt-manager runtime otherwise. +Flatpak libvirt daemons use `/run/user/UID/skvm` and QEMU user networking to +forward the selected localhost SSH port. The selected backend, connection URI, +and runtime directory are recorded in `runs/NAME/vm.json`. Use +`test vm status HOST INSTANCE` to inspect the selected connection. Native runs +normally use `qemu:///session`; a fallback runtime directory is also recorded +in the manifest. Check that `/dev/kvm` is accessible on the host. The helper +does not install host software. + +On Rocky Linux, if QEMU startup reports `cannot limit core file size ... to +18446744073709551615`, set `max_core = 0` in +`${XDG_CONFIG_HOME:-$HOME/.config}/libvirt/qemu.conf` and restart the idle user +`virtqemud` daemon. This account had a zero core-dump hard limit; the user +session's `qemu.conf` now contains that setting. `virt-install` is optional for +the native backend. + +From this directory, create and check the default disposable clamps VM with: + +```bash +./bin/test-vm clamps create smoke +./bin/test-vm clamps ready smoke +``` + +For another disposable run, choose a unique test name and loopback SSH port: ```bash -./bin/butane -s +./bin/test-vm clamps create example --port 2202 +./bin/test-vm clamps ready example ``` -this will generate a ignition file under a directory `ignition` (can be -controlled by passing `-o`. Without `-s` the output will go to STDOUT. -See `-h` for more +The command form is `./bin/test-vm `. The host must +have a Skillet host crate; creating a VM also requires `HOST.bu`. The instance +distinguishes disposable runs and may contain lowercase letters, digits, and +hyphens. The helper records `clamps` plus `smoke` as the internal domain +`clamps-test-smoke`. Use these commands after creation: -## coreos-install +```bash +./bin/test-vm clamps list # available template and recorded instances +./bin/test-vm list # all known hosts and recorded instances +./bin/test-vm clamps status smoke # domain and attached disks +./bin/test-vm clamps logs smoke # bootstrap and Skillet journals +./bin/test-vm clamps ssh smoke # interactive guest shell +./bin/test-vm clamps reboot smoke # reboot the guest +./bin/test-vm clamps ready smoke # rerun readiness after a reboot or repair +./bin/test-vm clamps update smoke # build and install both current binaries +./bin/test-vm clamps destroy smoke # delete the VM, disk, test key, and run artifacts +``` + +`list` reports each host as `available` when both its Butane config and host +crate exist. Instance rows show the libvirt state; `missing`, `mismatch`, or +`invalid` identify runs needing inspection. `unavailable` means the recorded +runtime could not be queried; it does not establish that a guest is absent. +Rust owns VM creation, including backend selection and startup. The shell helper +keeps the established command shape and forwards each request to Skillet; the +Rust command is also available directly: ```bash -./bin/coreos-install +cargo run --release -p skillet -- test vm create clamps smoke +cargo run --release -p skillet -- test vm list clamps +cargo run --release -p skillet -- test vm status clamps smoke +cargo run --release -p skillet -- test vm ready clamps smoke +cargo run --release -p skillet -- test vm provision clamps smoke +cargo run --release -p skillet -- test vm destroy clamps smoke ``` -this will use the above script to transpile the file, then download a coreos -image and finally boot a KVM machine executing the ignition file. -It supports being run inside distrobox and will execute KVM on the host. +All VM lifecycle commands use the Rust ownership implementation; `bin/test-vm` +is a compatibility argument adapter. Reading status does not +import or rewrite a legacy run. Disposal creates a private versioned manifest +while retaining original files until cleanup finishes. If the owned guest is +already absent, disposal still cleans recorded external resources before +removing local artifacts. A stopped/inaccessible libvirt runtime is reported +as an error; retry after making its recorded connection available. An enrolled +VM may require the normal KeePassXC unlock to remove its provider resources. +Failed cleanup retains recovery metadata. See the +[refactoring roadmap](../plan/SKILLET-REFACTOR.md) for remaining acceptance +and follow-on workstreams. + +Rust builds the generic `skillet` CLI and the selected host binary from Cargo's +reported artifact paths. It prepares the run's disk and SSH key, stages and +specializes the Butane source tree, and records captured binary hashes in +`runs/NAME/`. The standalone Butane compiler builds Ignition; Rust selects and +starts native libvirt or the installed Flatpak backend, then runs readiness. +`test-vm ready` transfers +both binaries over SSH after first boot and installs them at +`/var/usrlocal/bin/skillet` and `/var/usrlocal/bin/skillet-clamps`. `ready` +uses those captured binaries on every run. The shared base unit reads the +stable host profile from `/etc/skillet/host`, so a test VM hostname can differ +from its host profile. After editing Skillet, use +`cargo run --release -p skillet -- test vm update clamps smoke` to build and +install both binaries on a retained VM. This uses Cargo's reported executables, +including a configured target directory, and verifies installed hashes and +permissions before updating `vm.json`. `provision` delivers a disposable +Pi-hole password and runs the credential-loaded full apply; `--rotate` replaces +that disposable password. +The shared uCore bootstrap script also lives in `/var/usrlocal/bin`. +Generated Ignition, SSH key, VM disk and logs stay under `runs/NAME`, which Git +ignores. The test key is private and only its public half enters Ignition. +Homebrew and the `core` dotfiles profile install after the signed clamps boot. +The dotfiles installer links `authorized_keys`; SSH also reads Ignition's key file. +`--image PATH` selects a specific FCOS qcow2; +the default is `images/coreos.qcow2` when present. Both VM backends forward +the SSH port on `127.0.0.1`; native libvirt uses passt and Flatpak uses QEMU +user networking. + +Use `skillet test vm status HOST INSTANCE` and `skillet test vm logs HOST +INSTANCE` for supported VM inspection. The run manifest is the authority for +the libvirt URI and runtime directory; do not guess these values from the VM +name. + +Connect with: + +```bash +ssh -i runs/NAME/ssh/id_ed25519 -p PORT \ + -o UserKnownHostsFile=runs/NAME/ssh/known_hosts giacomo@127.0.0.1 +``` + +For a local application UI, keep the owned SSH session open with a loopback +forward (run from the Skillet workspace): + +```bash +cargo run --release -p skillet -- test vm ssh clamps unifi-restore --forward 18443:8443 +``` + +Open `https://127.0.0.1:18443` for the guest's local HTTPS endpoint. The guest's +self-signed certificate will not match localhost. `--forward LOCAL:GUEST` can +be repeated; both ends use localhost and the forward closes when SSH exits. +A port already in use causes SSH to fail. See the [VM lifecycle design](../design/smoke-vms.md). + +The VM staging step grants `giacomo` passwordless sudo. The guest starts +`ucore-bootstrap.service`, which reads the host image from +`/etc/ucore-bootstrap-image` and rebases first to the unsigned clamps image +and then to the signed image. It checks the *booted* rpm-ostree deployment on +every boot. It never starts the base apply until the signed deployment boots. +A failed rebase stops without rebooting; an already pending deployment gets at +most two reboot attempts. The shared base unit runs +`/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base` +with no app credentials. Clamps adds a separate full-apply unit gated on its +Pi-hole and Tailscale credentials. + +`./bin/test-vm HOST ready INSTANCE` waits up to 45 minutes per SSH/signed-boot +phase with bounded probes, and checks the recorded domain UUID/disks, +noninteractive SSH and sudo, signed booted deployment, successful +Skillet unit, guest artifact SHA and metadata, enforcing SELinux, DNS, +the profile's masked units, +Homebrew, dotfiles links, and the complete `core` Brewfile bundle. +It writes `final-status.json`, `final-boot-id`, +`guest-skillet*.sha256`, `readiness.log`, and `user-environment.log` into the run +directory with mode 0600. On failure, it attempts to retain relevant journals +in those logs while preserving the original error. When ownership no longer +matches, it refuses further guest contact. `bin/test-vm-ready RUN_DIR` remains +a compatibility entry point to the same Rust implementation. +For manual diagnostics, use `test-vm HOST status INSTANCE` and +`test-vm HOST logs INSTANCE`. +The equivalent guest SSH command is: + +```bash +ssh -i runs/NAME/ssh/id_ed25519 -p PORT giacomo@127.0.0.1 \ + 'sudo rpm-ostree status; sudo journalctl -b -u ucore-bootstrap.service -u skillet-apply.service' +``` + +`create` selects `HOST.bu`, builds `skillet-HOST`, and uses port 2201 by default. +Other commands check the recorded host, domain UUID, and disk before acting. +`destroy` removes only the selected test-owned resources. + +The VM and its disk remain available after readiness checks. Destroy runs with +`skillet test vm destroy HOST INSTANCE` so external enrollment/DNS cleanup and +the owned libvirt resources follow the recorded recovery journal. + +Run the local bootstrap state regression checks with `bash tests/bootstrap.sh`. +They exercise mocked deployment states and do not create or modify a VM. + +## Isolated UniFi restore acceptance + +Follow the [UniFi plan](../plan/CLAMPS-UNIFI.md). Use a dedicated disposable VM, +install the test fence from `tests/fixtures/unifi-restore/`, and verify fresh +SSH, blocked application egress and reboot persistence before importing state. +The fence files are test fixtures; do not merge them into production Butane. + +Build the service-only driver from the Skillet workspace with: + +```bash +cargo build --release -p skillet_unifi --example acceptance_apply +``` + +Deploy the reported executable through the owned VM transport and run it as +root inside the prepared guest. It applies the actual UniFi recipe; it neither +imports backups nor configures other services. Keep `.unf` files private and +outside the checkout; Skillet's ignore rules also protect accidental local +copies. UniFi 10.0.162's offline CLI restore failed in this acceptance attempt; +use the setup/UI flow through `test vm ssh --forward 18443:8443`. See the plan +for the retained instance and pending verification. Do not enable cloud access +or change device inform settings during this isolated test. + +## Optional TPM-encrypted test root + +From `ublue/skillet`: + +```sh +cargo run --release -p skillet -- test vm create clamps encrypted --root-profile tpm --port 2202 +cargo run --release -p skillet -- test vm encryption-check clamps encrypted +cargo run --release -p skillet -- test vm destroy clamps encrypted +``` + +The equivalent wrapper is `ublue/butane/bin/test-vm clamps create encrypted +--root-profile tpm --port 2202`, followed by `encryption-check encrypted` and +`destroy encrypted`. Choose a free SSH port when retaining another VM. +`--root-profile unencrypted` is the default and supports hosts without TPM. +Profiles cannot be switched on an existing VM; use a separate instance. + +The TPM profile needs native libvirt 10.10+, swtpm 0.7+, KVM, and q35 UEFI +Secure Boot firmware with enrolled keys. Creation checks capabilities and fails +explicitly if unsupported. No packages are installed. Early boot diagnostics +are saved privately in the run's `serial.log` before SSH is available. + +`encryption-check` saves a disposable recovery key and LUKS header backup in +`butane/runs//recovery/` (directory 0700, files 0600), verifies +the independent key, cold-boots the owned VM and checks data persistence. +Reruns reuse the recovery key. Disposal removes these artifacts. No vault or +provider credentials are required. Keep production recovery material separately; +physical installation and its vault-backed recovery workflow are still pending. + +See the [storage design](../design/storage.md) and +[encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md) for policy and remaining +acceptance. PCR 7 does not measure every boot component; this profile has the +accepted encrypted-disk protection scope. + +TPM creation prints `skillet test vm recovery-key HOST INSTANCE` to optionally +archive a separately scoped printable passphrase in KeePassXC. Production uses +`skillet secrets root-recovery-key HOST` with explicit verified SSH options, +before access finalization. See [recovery key usage](../skillet/README.md#root-recovery-keys). diff --git a/ublue/butane/bin/butane b/ublue/butane/bin/butane index 1e49e849..b773c617 100755 --- a/ublue/butane/bin/butane +++ b/ublue/butane/bin/butane @@ -1,108 +1,84 @@ -#!/bin/bash - +#!/usr/bin/env bash set -euo pipefail -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -OUTPUTDIR="$(realpath "$SCRIPT_DIR/../ignition")" -INPUT="" -RAW=false -STDOUT=true - -pushd "$SCRIPT_DIR/.." &>/dev/null +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +butane_dir="$(realpath "$script_dir/..")" +repo_dir="$(realpath "$butane_dir/..")" usage() { - echo "$(realpath --relative-to="$SCRIPT_DIR" "$0") [-hrs] [-o OUTPUT_DIR] [config.ign]" - echo - echo "By default, ignition file is pretty printed to stdout" - - echo "Options:" - echo "-h : show this help" - echo "-r : do not prettyfy stdout" - echo "-s : save to file" - echo "-o : output directory for ignition files. Defaults to $OUTPUTDIR" -} - -run() { - podman run --interactive --rm --security-opt label=disable \ - --volume "${PWD}:/pwd" --workdir /pwd quay.io/coreos/butane:release \ - --pretty --strict --files-dir "$(basename "$OUTPUTDIR")" "$1" - return $? + echo "Usage: $0 [-s] [-o OUTPUT_DIR] CONFIG.bu [CONFIG.ign]" >&2 } -while getopts "rsho:" opt; do - case $opt in - o) - OUTPUTDIR="$OPTARG" - ;; - r) - RAW=true - ;; - s) - STDOUT=false - ;; - h) - usage - exit 0 - ;; - \?) - echo "Invalid option: -$OPTARG" >&2 - usage - exit 1 - ;; - :) - echo "Option -$OPTARG requires an argument." >&2 - exit 1 - ;; +save=false +output_dir="$(realpath -m "$butane_dir/ignition")" +while getopts ':hso:' option; do + case "$option" in + h) usage; exit 0 ;; + s) save=true ;; + o) output_dir="$(realpath -m "$OPTARG")" ;; + *) usage; exit 2 ;; esac done - -if [ $# -lt $OPTIND ]; then - echo "Missing input script" >&2 - usage - exit 1 +shift $((OPTIND - 1)) +[[ $# -ge 1 && $# -le 2 && "$1" == *.bu && -f "$1" ]] || { usage; exit 2; } + +input="$(realpath "$1")" +output_name="${2:-$(basename "${input%.bu}.ign")}" +[[ "$output_name" != */* && "$output_name" == *.ign ]] || { echo "Output must be a .ign filename" >&2; exit 2; } +mkdir -p "$output_dir" +work_dir="$(mktemp -d "$output_dir/.butane.XXXXXXXX")" +trap 'rm -rf -- "$work_dir"' EXIT + +# Local merge sources are compiled first. Their Ignition paths stay relative to +# --files-dir, which is this isolated workspace rather than the source tree. +mapfile -t fragments < <(yq -r '.ignition.config.merge[].local' "$input") +for fragment in "${fragments[@]}"; do + [[ "$fragment" == *.bu && "$fragment" != /* && "$fragment" != *..* ]] || { + echo "Unsupported merge source: $fragment" >&2; exit 2; + } + mkdir -p "$work_dir/$(dirname "$fragment")" + cp "$(dirname "$input")/$fragment" "$work_dir/$fragment" +done +cp "$input" "$work_dir/input.bu" +if [[ -d "$(dirname "$input")/files" ]]; then + cp -a "$(dirname "$input")/files" "$work_dir/files" fi -INPUT="$(eval echo \$$OPTIND)" -OUTPUT="${INPUT/.bu/.ign}" - -if [ $# -gt $OPTIND ]; then - IDX=$(( OPTIND + 1 )) - OUTPUT="$(eval echo \$$IDX)" +# Host configs include the generic CLI and a host-specific credential CLI. +# VM provisioning stages freshly built artifacts for VM-only Ignition; physical +# host compilation builds them here when callers have not staged binaries. +if rg -q -F 'includes/skillet.bu' "$input"; then + host="$(basename "$input" .bu)" + generic_artifact="$work_dir/files/skillet" + host_artifact="$work_dir/files/skillet-$host" + if [[ ! -x "$generic_artifact" || ! -x "$host_artifact" ]]; then + artifact_target_dir="${CARGO_TARGET_DIR:-$repo_dir/skillet/target}" + if [[ "$artifact_target_dir" != /* ]]; then artifact_target_dir="$repo_dir/skillet/$artifact_target_dir"; fi + (cd "$repo_dir/skillet" && cargo build --release --target x86_64-unknown-linux-musl -p skillet -p "skillet-$host") + mkdir -p "$work_dir/files" + cp "$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet" "$generic_artifact" + cp "$artifact_target_dir/x86_64-unknown-linux-musl/release/skillet-$host" "$host_artifact" + chmod 0755 "$generic_artifact" "$host_artifact" + fi fi -OUTPUT="$(realpath "${OUTPUTDIR}/${OUTPUT}")" -# first. We need to transpile every (sigh) include -for fragment in $(yq '.ignition.config.merge[] | select (.local == "*.bu") | .local' "$INPUT"); do - fragment_name="${fragment%.bu}" - mkdir -p "$(dirname "$OUTPUTDIR/${fragment_name}")" - fragment_out="$(realpath "${OUTPUTDIR}/${fragment_name}.ign")" - echo >&2 "Compiling fragment ${fragment}" - run "${fragment}" > "${fragment_out}" -done - -temp_input=$(mktemp --tmpdir="$OUTPUTDIR" ) -cp "$INPUT" "$temp_input" -# shellcheck disable=SC2046 -sed -i $(yq '.ignition.config.merge[] | select(.local == "*.bu") | .local | "-e s;" + . + ";" + sub(".bu", ".ign") + ";"' "$INPUT" | tr '\n' ' ') "$temp_input" - -trap 'rm $temp_input' EXIT -new_input="ignition/$(basename "$temp_input")" +run_butane() { + podman run --interactive --rm --security-opt label=disable \ + --volume "$work_dir:/work" --workdir /work quay.io/coreos/butane:release \ + --pretty --strict --files-dir /work "$1" +} -if $STDOUT; then - if $RAW; then - run "$new_input" - exit $? - fi - run "$new_input" | jq . - exit $? -fi +for fragment in "${fragments[@]}"; do + compiled="${fragment%.bu}.ign" + run_butane "$fragment" > "$work_dir/$compiled" + FRAGMENT="$fragment" COMPILED="$compiled" yq -i \ + '(.ignition.config.merge[] | select(.local == strenv(FRAGMENT)) | .local) = strenv(COMPILED)' \ + "$work_dir/input.bu" +done -# ok we are not printing to stdot -if [ "$OUTPUT" -ef "$INPUT" ];then - echo >&2 "output $OUTPUT and input $INPUT are the same file" - exit 1 +if "$save"; then + run_butane input.bu > "$output_dir/$output_name" + echo "$output_dir/$output_name" +else + run_butane input.bu fi - -echo "Saving to $OUTPUT" >&2 -run "$new_input" > "$OUTPUT" -exit $? diff --git a/ublue/butane/bin/coreos-install b/ublue/butane/bin/coreos-install deleted file mode 100755 index 142d92a3..00000000 --- a/ublue/butane/bin/coreos-install +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" -# shellcheck source-path=SCRIPTDIR -# shellcheck source=../../lib/utils.sh -source "$(realpath "$script_dir/../../lib/utils.sh")" - -if [ $# -lt 1 ]; then - echo "Missing butane input script" >&2 - exit 1 -fi - -BUTANE_CONFIG="$1" -if [[ ! -f "$BUTANE_CONFIG" ]]; then - echo "Cannot find file at $BUTANE_CONFIG" >&2 - exit 1 -fi - -if [[ "$BUTANE_CONFIG" != *".bu" ]]; then - echo "Invalid file $BUTANE_CONFIG: should be a butane config" >&2 - exit 1 -fi - -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -IGNITION_CONFIG_NAME="${BUTANE_CONFIG/.bu/.ign}" -IGNITION_CONFIG="$(realpath "$SCRIPT_DIR/../ignition/$IGNITION_CONFIG_NAME")" -echo >&2 "Creating $IGNITION_CONFIG from $BUTANE_CONFIG" -"$SCRIPT_DIR"/butane -s -o "$SCRIPT_DIR/../ignition" "$BUTANE_CONFIG" "$IGNITION_CONFIG_NAME" - -VM_NAME="${BUTANE_CONFIG/.bu/}" -IMAGES_DIR="$(realpath "$SCRIPT_DIR/../images/")" -mkdir -p "$IMAGES_DIR" -IMAGE_NAME="coreos.qcow2" -IMAGE_PATH="$IMAGES_DIR/$IMAGE_NAME" - -if [[ ! -L "$IMAGE_PATH" ]]; then - set -x - run_on_host podman run --pull=always --privileged --rm -v "${IMAGES_DIR}:/data" -w /data \ - quay.io/coreos/coreos-installer:release download -s stable -p qemu -f "qcow2.xz" --decompress - set +x - for file in "$IMAGES_DIR"/fedora-coreos-*; do - latest=$(basename "$file") - done - - ln -vs "$IMAGES_DIR/$latest" "$IMAGE_PATH" -fi - -echo >&2 "Creating copy of image file" -VM_IMAGE="${VM_NAME}.img" -VM_IMAGE_PATH="${IMAGES_DIR}/$VM_IMAGE" -rm -f "$VM_IMAGE_PATH" -cp -v "$IMAGE_PATH" "$VM_IMAGE_PATH" - -set -x -run_on_host qemu-kvm -m 8192 -cpu host \ - -drive "if=virtio,format=qcow2,file=$(realpath "$VM_IMAGE_PATH")" \ - -fw_cfg "name=opt/com.coreos/config,file=${IGNITION_CONFIG}" \ - -nic "user,model=virtio,hostfwd=tcp::2222-:22" diff --git a/ublue/butane/bin/flatpak-virt b/ublue/butane/bin/flatpak-virt new file mode 100755 index 00000000..3507e037 --- /dev/null +++ b/ublue/butane/bin/flatpak-virt @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +(($# >= 1)) || { echo "Usage: $0 COMMAND [ARG...]" >&2; exit 2; } +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=../../lib/utils.sh +source "$(realpath "$script_dir/../../lib/utils.sh")" + +flatpak_args=(--filesystem=host --filesystem="$(realpath "$script_dir/..")" + --env=PATH=/app/bin:/usr/bin:/app/lib/extensions/bin:/run/host/usr/bin) +if [[ -n "${TEST_VM_LIBVIRT_RUNTIME_DIR:-}" ]]; then + flatpak_args+=(--filesystem=xdg-run/skvm --env="XDG_RUNTIME_DIR=$TEST_VM_LIBVIRT_RUNTIME_DIR") +fi +command_name="$1" +shift +run_on_host flatpak run "${flatpak_args[@]}" --command="$command_name" org.virt_manager.virt-manager "$@" diff --git a/ublue/butane/bin/install-hardware b/ublue/butane/bin/install-hardware new file mode 100755 index 00000000..5d97e5e8 --- /dev/null +++ b/ublue/butane/bin/install-hardware @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Runs only in the prepared Fedora CoreOS live environment, never during apply. +set -euo pipefail +[[ $EUID -eq 0 ]] || { echo 'Run with sudo.' >&2; exit 1; } +[[ $# -le 1 ]] || { echo 'Usage: skillet-install /path/to/destination.ign' >&2; exit 2; } +config="${1:-/etc/skillet-install/config.ign}" +[[ -f "$config" ]] || { echo 'Transfer the prepared destination Ignition file and pass its path.' >&2; exit 1; } +umask 077 +staged_config="$(mktemp /run/skillet-install.XXXXXXXX.ign)" +trap 'rm -f -- "$staged_config"' EXIT +cp -- "$config" "$staged_config" +actual_hash="$(sha256sum "$staged_config" | cut -d ' ' -f 1)" +[[ "$actual_hash" == "$(cat /etc/skillet-install/ignition.sha256)" ]] || { + echo 'Ignition checksum does not match this prepared USB; refusing installation.' >&2; exit 1; +} +disk="$(cat /etc/skillet-install/disk)" +profile="$(cat /etc/skillet-install/profile)" +[[ "$disk" == /dev/disk/by-id/* && "$disk" != *-part* && -b "$disk" ]] || { + echo 'Prepared whole-disk identifier is absent; stop and inspect hardware.' >&2; exit 1; +} +[[ "$(lsblk -dn -o TYPE "$disk")" == disk ]] || { echo 'Target is not a whole disk.' >&2; exit 1; } +[[ "$profile" == tpm || "$profile" == unencrypted ]] || { echo 'Invalid root profile.' >&2; exit 1; } +secure_boot=disabled +if [[ -d /sys/firmware/efi ]]; then + secure_boot="$(mokutil --sb-state)" || { echo 'Cannot establish Secure Boot state.' >&2; exit 1; } +fi +case "$secure_boot" in + 'SecureBoot enabled'|'SecureBoot disabled'|disabled) ;; + *) echo 'Unrecognized Secure Boot state; stop and inspect firmware.' >&2; exit 1 ;; +esac +if [[ "$profile" == tpm ]]; then + [[ -d /sys/firmware/efi && "$(cat /sys/class/tpm/tpm0/tpm_version_major)" == 2 ]] || { + echo 'TPM profile requires UEFI and TPM 2.' >&2; exit 1; + } + [[ "$secure_boot" == 'SecureBoot enabled' ]] || { + echo 'Enable Secure Boot before installation; no unencrypted fallback.' >&2; exit 1; + } +fi +if [[ "$secure_boot" == 'SecureBoot enabled' ]]; then + if [[ ! -f /etc/skillet-install/ublue.der ]] || ! mokutil --test-key /etc/skillet-install/ublue.der; then + echo 'Enroll the supplied ublue MOK, reboot back into this USB, then retry.' >&2 + echo 'Enrollment command: sudo mokutil --import /etc/skillet-install/ublue.der' >&2 + exit 1 + fi +fi +lsblk -o NAME,SIZE,MODEL,SERIAL,TYPE,MOUNTPOINTS "$disk" +if lsblk -nr -o MOUNTPOINTS "$disk" | grep -q '[^[:space:]]'; then + echo 'Target has mounted filesystems; stop and inspect before installation.' >&2; exit 1 +fi +echo "Installation will erase $disk. Root profile: $profile" +printf 'Type the complete disk identifier to install: ' +read -r confirmation &2; exit 1; } +coreos-installer install "$disk" --ignition-file "$staged_config" +echo 'Installed. Remove the USB before booting the installed system.' diff --git a/ublue/butane/bin/rupik-unifi-inventory.sh b/ublue/butane/bin/rupik-unifi-inventory.sh new file mode 100755 index 00000000..eb3400be --- /dev/null +++ b/ublue/butane/bin/rupik-unifi-inventory.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +log="$HOME/rupik-unifi-inventory-$(date -u +%Y%m%dT%H%M%SZ).log" +exec > >(tee "$log") 2>&1 + +printf 'UniFi inventory log: %s\n' "$log" +uname -m +sudo podman inspect unifi --format 'image={{.ImageName}} image-id={{.Image}} user={{.Config.User}} network={{.HostConfig.NetworkMode}} state={{.State.Status}}' +sudo podman inspect unifi --format '{{range .Mounts}}{{printf "mount %s -> %s writable=%t\n" .Source .Destination .RW}}{{end}}' +sudo podman top unifi user huser pid +sudo podman exec unifi dpkg-query -W -f='UniFi package version=${Version}\n' unifi +getent passwd unifi +getent group unifi +sudo stat -c '%u:%g mode=%a %n' /srv/unifi /srv/unifi/data /srv/unifi/data/logs +findmnt -T /srv/unifi -o TARGET,SOURCE,FSTYPE +sudo du -sh /srv/unifi /srv/unifi/data /srv/unifi/data/logs +sudo ss -lntup | grep -E ':(8080|8443|3478|10001|8843|8880|6789)([[:space:]]|$)' +curl -k --silent --output /dev/null --max-time 5 --write-out 'HTTPS 8443 status=%{http_code}\n' https://127.0.0.1:8443/ +sudo find /srv/unifi -type f -iname '*.unf' -printf '%TY-%Tm-%Td %s bytes\n' diff --git a/ublue/butane/bin/test-vm b/ublue/butane/bin/test-vm new file mode 100755 index 00000000..7c583b97 --- /dev/null +++ b/ublue/butane/bin/test-vm @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Compatibility command surface. VM lifecycle policy and implementation live +# in the Rust CLI; this script only translates the long-standing argument form. +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +repo_dir="$(realpath "$script_dir/../..")" + +usage() { + echo "Usage: $0 [HOST] list | $0 HOST create|ready|update|status|ssh|logs|reboot|encryption-check|recovery-key|destroy INSTANCE [--port PORT] [--image IMAGE] [--root-profile unencrypted|tpm]" >&2 + exit 2 +} + +skillet_cli() { + if [[ -n "${SKILLET_BINARY:-}" ]]; then + exec "$SKILLET_BINARY" "$@" + fi + cd "$repo_dir/skillet" + exec cargo run --quiet --release -p skillet -- "$@" +} + +if (($# == 1)) && [[ "$1" == list ]]; then + skillet_cli test vm list +fi +if (($# == 2)) && [[ "$2" == list ]]; then + skillet_cli test vm list "$1" +fi +(($# >= 3)) || usage +host="$1" +operation="$2" +instance="$3" +shift 3 + +case "$operation" in + create) + args=(test vm create "$host" "$instance") + while (($#)); do + case "$1" in + --port|--image|--root-profile) + (($# >= 2)) || usage + args+=("$1" "$2") + shift 2 + ;; + *) usage ;; + esac + done + skillet_cli "${args[@]}" + ;; + recovery-key) + args=(test vm recovery-key "$host" "$instance") + while (($#)); do + case "$1" in + --database|--key-file) + (($# >= 2)) || usage + args+=("$1" "$2") + shift 2 + ;; + *) usage ;; + esac + done + skillet_cli "${args[@]}" + ;; + ready|update|status|ssh|logs|reboot|encryption-check|destroy) + (($# == 0)) || usage + skillet_cli test vm "$operation" "$host" "$instance" + ;; + *) usage ;; +esac diff --git a/ublue/butane/bin/test-vm-ready b/ublue/butane/bin/test-vm-ready new file mode 100755 index 00000000..34fd0670 --- /dev/null +++ b/ublue/butane/bin/test-vm-ready @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Compatibility entry point only. Rust validates ownership and retained state. +if (($# != 1)); then + echo "Usage: $0 RUN_DIR" >&2 + exit 2 +fi +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +run_dir="$(realpath -e "$1")" +cd "$script_dir/../../skillet" +exec cargo run --quiet --release -p skillet -- test vm ready-directory "$run_dir" diff --git a/ublue/butane/bin/virsh b/ublue/butane/bin/virsh new file mode 100755 index 00000000..78872fa3 --- /dev/null +++ b/ublue/butane/bin/virsh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +exec "$script_dir/flatpak-virt" virsh "$@" diff --git a/ublue/butane/bin/virt-install b/ublue/butane/bin/virt-install index 61b7e93a..105f44a6 100755 --- a/ublue/butane/bin/virt-install +++ b/ublue/butane/bin/virt-install @@ -2,10 +2,5 @@ set -euo pipefail -script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" -# shellcheck source-path=SCRIPTDIR -# shellcheck source=../../lib/utils.sh -source "$(realpath "$script_dir/../../lib/utils.sh")" - -run_on_host flatpak run --filesystem=host --command=virt-install org.virt_manager.virt-manager "$@" -exit $? +script_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" +exec "$script_dir/flatpak-virt" virt-install "$@" diff --git a/ublue/butane/clamps.bu b/ublue/butane/clamps.bu index dcbd8b05..208b3378 100644 --- a/ublue/butane/clamps.bu +++ b/ublue/butane/clamps.bu @@ -1,11 +1,20 @@ variant: fcos version: 1.6.0 +# VM-only layout: the Skillet VM provisioner attaches FCOS as virtio /dev/vda. +# A physical installer must use an explicitly selected hardware disk instead. ignition: config: merge: - local: includes/passwd.bu - local: includes/zram.bu + - local: includes/resolver.bu + - local: includes/data-storage.bu - local: includes/brew.bu + - local: includes/skillet.bu + - local: includes/skillet-clamps.bu + - local: includes/skillet-full.bu + - local: includes/ucore-bootstrap.bu + - local: includes/ucore-clamps.bu storage: disks: - # The link to the block device the OS was booted from. @@ -27,6 +36,10 @@ storage: - path: /etc/localtime target: ../usr/share/zoneinfo/Europe/Madrid files: + - path: /etc/skillet/host + mode: 0644 + contents: + inline: clamps - path: /etc/hostname mode: 0644 contents: diff --git a/ublue/butane/includes/brew.bu b/ublue/butane/includes/brew.bu index 5f1851a9..78f61b95 100644 --- a/ublue/butane/includes/brew.bu +++ b/ublue/butane/includes/brew.bu @@ -27,9 +27,11 @@ systemd: contents: | [Unit] Description=Install Homebrew - After=network-online.target + Requires=ucore-bootstrap.service + After=ucore-bootstrap.service network-online.target Wants=network-online.target - ConditionPathExists=!/home/linuxbrew/.linuxbrew/bin/brew + ConditionPathExists=/run/ucore-bootstrap-ready + ConditionPathExists=!/var/lib/ucore-bootstrap/brew-installed [Service] Type=oneshot @@ -38,7 +40,8 @@ systemd: Environment="NONINTERACTIVE=1" ExecStart=curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o /tmp/install_brew.sh ExecStart=/bin/bash /tmp/install_brew.sh - RemainAfterExit=yes + ExecStartPost=+/usr/bin/touch /var/lib/ucore-bootstrap/brew-installed + TimeoutStartSec=30min [Install] WantedBy=multi-user.target @@ -47,11 +50,11 @@ systemd: contents: | [Unit] Description=Install dotfiles - # This ensures the service waits for brew to finish - After=brew-install.service - Requires=brew-install.service + Requires=ucore-bootstrap.service brew-install.service + After=ucore-bootstrap.service brew-install.service Wants=network-online.target - ConditionPathExists=!/home/giacomo/.local/src/dotfiles + ConditionPathExists=/run/ucore-bootstrap-ready + ConditionPathExists=!/var/lib/ucore-bootstrap/dotfiles-installed [Service] Type=oneshot @@ -59,9 +62,10 @@ systemd: StandardOutput=journal+console WorkingDirectory=/home/giacomo/.local/src Environment="PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/usr/bin:/bin" - ExecStart=/usr/bin/git clone https://github.com/gbagnoli/dotfiles.git + ExecStart=/usr/bin/bash -c 'if ! test -d /home/giacomo/.local/src/dotfiles/.git; then git clone https://github.com/gbagnoli/dotfiles.git /home/giacomo/.local/src/dotfiles; fi' ExecStart=/bin/bash /home/giacomo/.local/src/dotfiles/install.sh -b core -V - RemainAfterExit=yes + ExecStartPost=+/usr/bin/touch /var/lib/ucore-bootstrap/dotfiles-installed + TimeoutStartSec=30min [Install] WantedBy=multi-user.target diff --git a/ublue/butane/includes/data-storage.bu b/ublue/butane/includes/data-storage.bu new file mode 100644 index 00000000..fb69a6d0 --- /dev/null +++ b/ublue/butane/includes/data-storage.bu @@ -0,0 +1,133 @@ +variant: fcos +version: 1.6.0 +storage: + directories: + - path: /var/lib/data + mode: 0755 + files: + - path: /etc/containers/storage.conf + mode: 0644 + contents: + inline: | + [storage] + driver = "overlay" + runroot = "/run/containers/storage" + graphroot = "/var/lib/data/containers/system" + - path: /var/usrlocal/bin/skillet-data-top + mode: 0755 + contents: + inline: | + #!/usr/bin/env bash + set -euo pipefail + [[ "$(findmnt -T /var -n -o FSTYPE)" == btrfs ]] || { + echo '/var must be backed by Btrfs' >&2 + exit 1 + } + var_uuid="$(findmnt -T /var -n -o UUID)" + root_device="/dev/disk/by-uuid/$var_uuid" + [[ -n "$var_uuid" && "$(blkid -s UUID -o value /dev/disk/by-label/root)" == "$var_uuid" ]] || { + echo 'The root filesystem does not match the Btrfs filesystem backing /var' >&2 + exit 1 + } + top="$(mktemp -d /run/skillet-data-top.XXXXXX)" + cleanup() { + mountpoint -q "$top" && umount "$top" + rmdir "$top" + } + trap cleanup EXIT + mount -t btrfs -o subvolid=5 "$root_device" "$top" + if btrfs subvolume show "$top/data" >/dev/null 2>&1; then + exit 0 + fi + [[ ! -e "$top/data" ]] || { + echo 'Top-level data path exists but is not a Btrfs subvolume' >&2 + exit 1 + } + btrfs subvolume create "$top/data" + - path: /var/usrlocal/bin/skillet-data-prepare + mode: 0755 + contents: + inline: | + #!/usr/bin/env bash + set -euo pipefail + mountpoint -q /var/lib/data || { + echo '/var/lib/data is not mounted' >&2 + exit 1 + } + [[ "$(findmnt -M /var/lib/data -n -o FSTYPE)" == btrfs && + "$(findmnt -M /var/lib/data -n -o FSROOT)" == /data && + "$(findmnt -M /var/lib/data -n -o UUID)" == "$(findmnt -M /var -n -o UUID)" ]] || { + echo '/var/lib/data is not the data subvolume of the /var Btrfs filesystem' >&2 + exit 1 + } + if ! btrfs subvolume show /var/lib/data/containers >/dev/null 2>&1; then + [[ ! -e /var/lib/data/containers ]] || { + echo 'Container path exists but is not a Btrfs subvolume' >&2 + exit 1 + } + btrfs subvolume create /var/lib/data/containers + fi + mkdir -p /var/lib/data/containers/system + if ! semanage fcontext -l -C | grep -Fq '/var/lib/data/containers/system'; then + semanage fcontext -a -e /var/lib/containers/storage /var/lib/data/containers/system + restorecon -R /var/lib/data/containers/system + fi + restorecon /var/lib/data/containers/system +systemd: + units: + - name: skillet-data-top.service + contents: | + [Unit] + Description=Create the shared Btrfs data subvolume + DefaultDependencies=no + Requires=var.mount + After=var.mount + Before=var-lib-data.mount + + [Service] + Type=oneshot + ExecStart=/var/usrlocal/bin/skillet-data-top + RemainAfterExit=yes + - name: var-lib-data.mount + enabled: true + contents: | + [Unit] + Description=Mount the shared Btrfs service data subvolume + Requires=skillet-data-top.service + After=skillet-data-top.service + + [Mount] + What=/dev/disk/by-label/root + Where=/var/lib/data + Type=btrfs + Options=subvol=data,noatime,compress=zstd:3 + + [Install] + WantedBy=multi-user.target + - name: skillet-data-prepare.service + enabled: true + contents: | + [Unit] + Description=Prepare shared Podman storage on the data mount + Requires=var-lib-data.mount + After=var-lib-data.mount + Requires=ucore-bootstrap.service + After=ucore-bootstrap.service + ConditionPathExists=/run/ucore-bootstrap-ready + AssertPathIsMountPoint=/var/lib/data + + [Service] + Type=oneshot + ExecStart=/var/usrlocal/bin/skillet-data-prepare + RemainAfterExit=yes + + [Install] + WantedBy=multi-user.target + - name: podman-auto-update.service + dropins: + - name: 10-skillet-data.conf + contents: | + [Unit] + Requires=skillet-data-prepare.service + After=skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data diff --git a/ublue/butane/includes/passwd.bu b/ublue/butane/includes/passwd.bu index a4ced259..17003b64 100644 --- a/ublue/butane/includes/passwd.bu +++ b/ublue/butane/includes/passwd.bu @@ -22,27 +22,3 @@ passwd: - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID19B1eeB6KClwXbmu+RWo+3nrXl33Yf3+Oh/WVigrwl giacomo@calculon - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB+LUBD9jvN98k6es/W/0h/nGibhHGf7OY6JZG+H3pQL giacomo@mac - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICYS/jhr4/Ld55BT2YjP+b+LHWNkaSuRYSuLre2Mbxwz giacomo@ubikxps -storage: - files: - - path: /usr/local/bin/force_pw_change.sh - contents: - inline: | - #!/bin/bash - chage -d 0 giacomo - mode: 0755 -systemd: - units: - - name: force-pw-change.service - enabled: true - contents: | - [Unit] - Description=Force password change for user - After=network-online.target - ConditionPathExists=!/var/lib/.force-pw-change - [Service] - Type=oneshot - ExecStart=/usr/local/bin/force_pw_change.sh - ExecStartPost=/usr/bin/touch /var/lib/.force-pw-change - RemainAfterExit=yes - [Install] - WantedBy=multi-user.target diff --git a/ublue/butane/includes/resolver.bu b/ublue/butane/includes/resolver.bu new file mode 100644 index 00000000..4493ed04 --- /dev/null +++ b/ublue/butane/includes/resolver.bu @@ -0,0 +1,17 @@ +# NetworkManager owns host DNS from active connection DHCP. The explicit link +# prevents a resolved stub symlink surviving into the resolved-masked image. +variant: fcos +version: 1.6.0 +storage: + links: + - path: /etc/resolv.conf + target: ../run/NetworkManager/resolv.conf + overwrite: true + files: + - path: /etc/NetworkManager/conf.d/90-clamps-dns.conf + mode: 0644 + contents: + inline: | + [main] + dns=default + rc-manager=symlink diff --git a/ublue/butane/includes/skillet-clamps.bu b/ublue/butane/includes/skillet-clamps.bu new file mode 100644 index 00000000..82dc1302 --- /dev/null +++ b/ublue/butane/includes/skillet-clamps.bu @@ -0,0 +1,8 @@ +variant: fcos +version: 1.6.0 +storage: + files: + - path: /var/usrlocal/bin/skillet-clamps + mode: 0755 + contents: + local: files/skillet-clamps diff --git a/ublue/butane/includes/skillet-full.bu b/ublue/butane/includes/skillet-full.bu new file mode 100644 index 00000000..f8c61c3d --- /dev/null +++ b/ublue/butane/includes/skillet-full.bu @@ -0,0 +1,27 @@ +# Shared full-apply activation for hosts declaring Pi-hole and Tailscale. +# Optional service phases remain independently credential-gated in skillet.bu. +variant: fcos +version: 1.6.0 +systemd: + units: + - name: skillet-full-apply.service + enabled: true + contents: | + [Unit] + Description=Apply declared Skillet application configuration + Requires=skillet-apply.service + After=skillet-apply.service + Requires=skillet-data-prepare.service + After=skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data + ConditionPathExists=/etc/credstore.encrypted/skillet/pihole_web_password.cred + ConditionPathExists=/etc/credstore.encrypted/skillet/tailscale_auth_key.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred + LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase full + + [Install] + WantedBy=multi-user.target diff --git a/ublue/butane/includes/skillet-joey.bu b/ublue/butane/includes/skillet-joey.bu new file mode 100644 index 00000000..3ddddc6d --- /dev/null +++ b/ublue/butane/includes/skillet-joey.bu @@ -0,0 +1,8 @@ +variant: fcos +version: 1.6.0 +storage: + files: + - path: /var/usrlocal/bin/skillet-joey + mode: 0755 + contents: + local: files/skillet-joey diff --git a/ublue/butane/includes/skillet.bu b/ublue/butane/includes/skillet.bu new file mode 100644 index 00000000..a8622be3 --- /dev/null +++ b/ublue/butane/includes/skillet.bu @@ -0,0 +1,94 @@ +variant: fcos +version: 1.6.0 +storage: + files: + - path: /var/usrlocal/bin/skillet + mode: 0755 + contents: + local: files/skillet +systemd: + units: + - name: skillet-apply.service + enabled: true + contents: | + [Unit] + Description=Apply Skillet base configuration + Requires=ucore-bootstrap.service + After=ucore-bootstrap.service + ConditionPathExists=/run/ucore-bootstrap-ready + + [Service] + Type=oneshot + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base + + [Install] + WantedBy=multi-user.target + - name: skillet-caddy-apply.service + enabled: true + contents: | + [Unit] + Description=Apply Skillet Caddy private UI configuration + Requires=skillet-apply.service + After=skillet-apply.service + AssertPathIsMountPoint=/var/lib/data + ConditionPathExists=/etc/credstore.encrypted/skillet/caddy_sites.cred + ConditionPathExists=/etc/credstore.encrypted/skillet/cloudflare_acme_token.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=caddy_sites:/etc/credstore.encrypted/skillet/caddy_sites.cred + LoadCredentialEncrypted=cloudflare_acme_token:/etc/credstore.encrypted/skillet/cloudflare_acme_token.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase caddy + + [Install] + WantedBy=multi-user.target + - name: skillet-ddns-apply.service + enabled: true + contents: | + [Unit] + Description=Apply Skillet optional Cloudflare DDNS configuration + Requires=skillet-apply.service skillet-data-prepare.service + After=skillet-apply.service skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data + ConditionPathExists=/etc/credstore.encrypted/skillet/cloudflare_ddns_config.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=cloudflare_ddns_config:/etc/credstore.encrypted/skillet/cloudflare_ddns_config.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase ddns + + [Install] + WantedBy=multi-user.target + - name: skillet-datadog-apply.service + enabled: true + contents: | + [Unit] + Description=Apply Skillet optional Datadog monitoring configuration + Requires=skillet-apply.service skillet-data-prepare.service + After=skillet-apply.service skillet-data-prepare.service + AssertPathIsMountPoint=/var/lib/data + ConditionPathExists=/etc/credstore.encrypted/skillet/datadog_config.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=datadog_config:/etc/credstore.encrypted/skillet/datadog_config.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase datadog + + [Install] + WantedBy=multi-user.target + - name: skillet-smtp-apply.service + enabled: true + contents: | + [Unit] + Description=Apply shared fleet SMTP configuration + Requires=skillet-apply.service + After=skillet-apply.service + ConditionPathExists=/etc/credstore.encrypted/skillet/smtp_config.cred + + [Service] + Type=oneshot + LoadCredentialEncrypted=smtp_config:/etc/credstore.encrypted/skillet/smtp_config.cred + ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase smtp + + [Install] + WantedBy=multi-user.target diff --git a/ublue/butane/includes/ucore-bootstrap.bu b/ublue/butane/includes/ucore-bootstrap.bu new file mode 100644 index 00000000..b9489725 --- /dev/null +++ b/ublue/butane/includes/ucore-bootstrap.bu @@ -0,0 +1,75 @@ +# The script follows the booted rpm-ostree deployment, not a marker recording +# that a rebase command was requested. A failed rebase stops until the next boot. +variant: fcos +version: 1.6.0 +storage: + directories: + - path: /var/lib/ucore-bootstrap + mode: 0755 + files: + - path: /var/usrlocal/bin/ucore-rebase.sh + mode: 0755 + contents: + inline: | + #!/usr/bin/env bash + set -euo pipefail + image="$(cat /etc/ucore-bootstrap-image)" + [[ "$image" =~ ^ghcr\.io/gbagnoli/ucore-[a-z0-9-]+$ ]] || { + echo "Invalid uCore image: $image" >&2 + exit 1 + } + unsigned="ostree-unverified-registry:$image:latest" + signed="ostree-image-signed:docker://$image:latest" + status="$(rpm-ostree status --json)" + booted="$(jq -r '.deployments[] | select(.booted) | .["container-image-reference"] // ""' <<< "$status")" + pending="$(jq -r '.deployments[0] | select(.booted == false) | .["container-image-reference"] // ""' <<< "$status")" + matches_image() { + local reference="$1" scheme="$2" + [[ "$reference" == "$scheme$image:latest" || "$reference" == "$scheme$image@sha256:"* ]] + } + if matches_image "$booted" 'ostree-image-signed:docker://'; then + rm -f /var/lib/ucore-bootstrap/reboot-attempts + touch /run/ucore-bootstrap-ready + echo "Booted signed uCore deployment: $booted" + exit 0 + fi + if matches_image "$booted" 'ostree-unverified-registry:'; then + target="$signed" + target_scheme='ostree-image-signed:docker://' + else + target="$unsigned" + target_scheme='ostree-unverified-registry:' + fi + attempts_file=/var/lib/ucore-bootstrap/reboot-attempts + if ! matches_image "$pending" "$target_scheme"; then + rpm-ostree rebase --bypass-driver "$target" + echo 1 > "$attempts_file" + else + echo "Target already pending: $target" + attempts="$(cat "$attempts_file" 2>/dev/null || echo 0)" + if ((attempts >= 2)); then + echo "Pending deployment failed to boot after two attempts: $target" >&2 + exit 1 + fi + echo "$((attempts + 1))" > "$attempts_file" + fi + echo "Rebooting to $target" + systemctl reboot --no-block +systemd: + units: + - name: ucore-bootstrap.service + enabled: true + contents: | + [Unit] + Description=Boot through the configured signed uCore image + After=network-online.target + Wants=network-online.target + + [Service] + Type=oneshot + ExecStart=/var/usrlocal/bin/ucore-rebase.sh + RemainAfterExit=yes + TimeoutStartSec=45min + + [Install] + WantedBy=multi-user.target diff --git a/ublue/butane/includes/ucore-clamps.bu b/ublue/butane/includes/ucore-clamps.bu new file mode 100644 index 00000000..59de876e --- /dev/null +++ b/ublue/butane/includes/ucore-clamps.bu @@ -0,0 +1,10 @@ +# Clamps supplies its image name to the shared uCore bootstrap service. +variant: fcos +version: 1.6.0 +storage: + files: + - path: /etc/ucore-bootstrap-image + mode: 0644 + contents: + inline: | + ghcr.io/gbagnoli/ucore-clamps diff --git a/ublue/butane/includes/ucore-joey.bu b/ublue/butane/includes/ucore-joey.bu new file mode 100644 index 00000000..3e3c74c7 --- /dev/null +++ b/ublue/butane/includes/ucore-joey.bu @@ -0,0 +1,14 @@ +# Host-selected signed image and day-zero DNS listener policy. +variant: fcos +version: 1.6.0 +storage: + files: + - path: /etc/ucore-bootstrap-image + mode: 0644 + contents: + inline: | + ghcr.io/gbagnoli/ucore-joey +systemd: + units: + - name: systemd-resolved.service + mask: true diff --git a/ublue/butane/joey.bu b/ublue/butane/joey.bu new file mode 100644 index 00000000..61b6072b --- /dev/null +++ b/ublue/butane/joey.bu @@ -0,0 +1,46 @@ +variant: fcos +version: 1.6.0 +# VM-only layout: the Skillet VM provisioner attaches FCOS as virtio /dev/vda. +# A physical installer must use an explicitly selected hardware disk instead. +ignition: + config: + merge: + - local: includes/passwd.bu + - local: includes/zram.bu + - local: includes/resolver.bu + - local: includes/data-storage.bu + - local: includes/brew.bu + - local: includes/skillet.bu + - local: includes/skillet-joey.bu + - local: includes/skillet-full.bu + - local: includes/ucore-bootstrap.bu + - local: includes/ucore-joey.bu +storage: + disks: + - # The link to the block device the OS was booted from. + device: /dev/vda + # We do not want to wipe the partition table since this is the primary + # device. + wipe_table: false + partitions: + - number: 4 + label: root + size_mib: 0 + resize: true + filesystems: + - device: /dev/disk/by-partlabel/root + wipe_filesystem: true + format: btrfs + label: root + links: + - path: /etc/localtime + target: ../usr/share/zoneinfo/Europe/Madrid + files: + - path: /etc/skillet/host + mode: 0644 + contents: + inline: joey + - path: /etc/hostname + mode: 0644 + contents: + inline: joey diff --git a/ublue/butane/tests/bootstrap.sh b/ublue/butane/tests/bootstrap.sh new file mode 100644 index 00000000..aa182d72 --- /dev/null +++ b/ublue/butane/tests/bootstrap.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_dir="$(realpath "$(dirname "${BASH_SOURCE[0]}")/..")" +tmp="$(mktemp -d)" +trap 'rm -rf -- "$tmp"' EXIT +mkdir "$tmp/bin" +yq -r '.storage.files[] | select(.path == "/var/usrlocal/bin/ucore-rebase.sh") | .contents.inline' \ + "$source_dir/includes/ucore-bootstrap.bu" > "$tmp/rebase.sh" + +cat > "$tmp/bin/rpm-ostree" <<'MOCK' +#!/usr/bin/env bash +if [[ "$1" == status && "$2" == --json ]]; then + cat "$CASE_STATUS" +elif [[ "$1" == rebase && "$2" == --bypass-driver ]]; then + printf '%s\n' "$3" >> "$CASE_REBASE_LOG" + [[ "${CASE_FAIL_REBASE:-0}" == 0 ]] +else + exit 2 +fi +MOCK +cat > "$tmp/bin/systemctl" <<'MOCK' +#!/usr/bin/env bash +[[ "$1" == reboot && "$2" == --no-block ]] || exit 2 +printf 'reboot\n' >> "$CASE_REBOOT_LOG" +MOCK +chmod +x "$tmp/bin/rpm-ostree" "$tmp/bin/systemctl" + +image=ghcr.io/gbagnoli/ucore-clamps +unsigned="ostree-unverified-registry:$image:latest" +signed="ostree-image-signed:docker://$image:latest" + +check() { + local name="$1" status="$2" expected_rebase="$3" expected_reboots="$4" + local expected_ready="$5" expected_exit="$6" attempts="${7:-}" fail_rebase="${8:-0}" + local configured_image="${9:-$image}" + local case_dir="$tmp/$name" actual_exit=0 actual_rebase actual_reboots actual_ready + mkdir -p "$case_dir/state" + printf '%s\n' "$configured_image" > "$case_dir/image" + printf '%s\n' "$status" > "$case_dir/status.json" + if [[ -n "$attempts" ]]; then printf '%s\n' "$attempts" > "$case_dir/state/reboot-attempts"; fi + sed -e "s@/etc/ucore-bootstrap-image@$case_dir/image@g" \ + -e "s@/var/lib/ucore-bootstrap@$case_dir/state@g" \ + -e "s@/run/ucore-bootstrap-ready@$case_dir/ready@g" \ + "$tmp/rebase.sh" > "$case_dir/rebase.sh" + CASE_STATUS="$case_dir/status.json" CASE_REBASE_LOG="$case_dir/rebases" \ + CASE_REBOOT_LOG="$case_dir/reboots" CASE_FAIL_REBASE="$fail_rebase" \ + PATH="$tmp/bin:$PATH" bash "$case_dir/rebase.sh" > "$case_dir/output" 2>&1 || actual_exit=$? + actual_rebase="$(cat "$case_dir/rebases" 2>/dev/null || true)" + if [[ -f "$case_dir/reboots" ]]; then + actual_reboots="$(wc -l < "$case_dir/reboots")" + else + actual_reboots=0 + fi + actual_ready=0 + [[ ! -e "$case_dir/ready" ]] || actual_ready=1 + if [[ "$actual_rebase" != "$expected_rebase" || "$actual_reboots" != "$expected_reboots" || + "$actual_ready" != "$expected_ready" || "$actual_exit" != "$expected_exit" ]]; then + echo "FAIL $name: rebase=$actual_rebase reboots=$actual_reboots ready=$actual_ready exit=$actual_exit" >&2 + cat "$case_dir/output" >&2 + exit 1 + fi + echo "PASS $name" +} + +check fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' "$unsigned" 1 0 0 +check donbot_fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ + 'ostree-unverified-registry:ghcr.io/gbagnoli/ucore-donbot:latest' 1 0 0 '' 0 \ + 'ghcr.io/gbagnoli/ucore-donbot' +check joey_fresh '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ + 'ostree-unverified-registry:ghcr.io/gbagnoli/ucore-joey:latest' 1 0 0 '' 0 \ + 'ghcr.io/gbagnoli/ucore-joey' +check unsigned_with_signed_rollback \ + "{\"deployments\":[{\"booted\":true,\"container-image-reference\":\"$unsigned\"},{\"booted\":false,\"container-image-reference\":\"$signed\"}]}" \ + "$signed" 1 0 0 +check signed_pending \ + "{\"deployments\":[{\"booted\":false,\"container-image-reference\":\"$signed\"},{\"booted\":true,\"container-image-reference\":\"$unsigned\"}]}" \ + '' 1 0 0 1 +check signed_booted \ + "{\"deployments\":[{\"booted\":true,\"container-image-reference\":\"$signed\"}]}" \ + '' 0 1 0 1 +check signed_digest_booted \ + "{\"deployments\":[{\"booted\":true,\"container-image-reference\":\"ostree-image-signed:docker://$image@sha256:$(printf 'a%.0s' {1..64})\"}]}" \ + '' 0 1 0 +check pending_retry_limit \ + "{\"deployments\":[{\"booted\":false,\"container-image-reference\":\"$signed\"},{\"booted\":true,\"container-image-reference\":\"$unsigned\"}]}" \ + '' 0 0 1 2 +check rebase_failure '{"deployments":[{"booted":true,"container-image-reference":""}]}' \ + "$unsigned" 0 0 1 '' 1 diff --git a/ublue/butane/tests/fixtures/unifi-restore/fence.nft b/ublue/butane/tests/fixtures/unifi-restore/fence.nft new file mode 100644 index 00000000..717fe29c --- /dev/null +++ b/ublue/butane/tests/fixtures/unifi-restore/fence.nft @@ -0,0 +1,26 @@ +table inet skillet_restore {} +flush table inet skillet_restore +table inet skillet_restore { + chain input { + type filter hook input priority 0; policy drop; + iifname "lo" accept + tcp dport 22 accept + udp sport 67 udp dport 68 accept + udp sport 547 udp dport 546 accept + icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept + counter drop + } + chain output { + type filter hook output priority 0; policy drop; + oifname "lo" accept + tcp sport 22 accept + udp sport 68 udp dport 67 accept + udp sport 546 udp dport 547 accept + icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept + counter drop + } + chain forward { + type filter hook forward priority 0; policy drop; + counter drop + } +} diff --git a/ublue/butane/tests/fixtures/unifi-restore/fence.service b/ublue/butane/tests/fixtures/unifi-restore/fence.service new file mode 100644 index 00000000..a842a24f --- /dev/null +++ b/ublue/butane/tests/fixtures/unifi-restore/fence.service @@ -0,0 +1,9 @@ +[Unit] +Description=Isolate disposable UniFi restore from production and cloud +Before=unifi.service +[Service] +Type=oneshot +ExecStart=/usr/sbin/nft -f /etc/skillet-unifi-restore.nft +RemainAfterExit=yes +[Install] +WantedBy=multi-user.target diff --git a/ublue/butane/tests/fixtures/unifi-restore/unifi-dropin.conf b/ublue/butane/tests/fixtures/unifi-restore/unifi-dropin.conf new file mode 100644 index 00000000..31edd1c3 --- /dev/null +++ b/ublue/butane/tests/fixtures/unifi-restore/unifi-dropin.conf @@ -0,0 +1,3 @@ +[Unit] +Requires=skillet-unifi-restore-fence.service +After=skillet-unifi-restore-fence.service diff --git a/ublue/butane/tests/hardware.sh b/ublue/butane/tests/hardware.sh new file mode 100755 index 00000000..3bbdbc18 --- /dev/null +++ b/ublue/butane/tests/hardware.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Exercise refusal paths in an isolated container with no host block devices. +set -euo pipefail +butane_dir="$(realpath "$(dirname "${BASH_SOURCE[0]}")/..")" +work="$(mktemp -d)" +trap 'rm -rf -- "$work"' EXIT +printf '{"ignition":{"version":"3.5.0"}}\n' > "$work/config.ign" +printf /dev/disk/by-id/skillet-nonexistent-test-disk > "$work/disk" +printf unencrypted > "$work/profile" +run_case() { + local expected="$1" + local status=0 + podman run --rm --network none --security-opt label=disable \ + --volume "$work:/etc/skillet-install:ro" \ + --volume "$butane_dir/bin/install-hardware:/helper:ro" \ + registry.fedoraproject.org/fedora:latest bash /helper /etc/skillet-install/config.ign \ + > "$work/output" 2>&1 || status=$? + if [[ "$status" != 1 ]] || ! grep -Fq "$expected" "$work/output"; then + cat "$work/output" >&2; echo "FAIL expected refusal: $expected" >&2; exit 1 + fi + echo "PASS $expected" +} +printf wrong-digest > "$work/ignition.sha256" +run_case 'Ignition checksum does not match' +sha256sum "$work/config.ign" | cut -d ' ' -f 1 > "$work/ignition.sha256" +run_case 'Prepared whole-disk identifier is absent' diff --git a/ublue/design/administrative-access.md b/ublue/design/administrative-access.md new file mode 100644 index 00000000..1e910cc3 --- /dev/null +++ b/ublue/design/administrative-access.md @@ -0,0 +1,29 @@ +# Administrative access lifecycle + +Production provisioning temporarily uses uCore's passwordless sudo-group policy. +Finalization is an explicit operator step after service and credential provisioning, +not part of base apply or a boot-triggered expiry service. A provisioning-complete +signal cannot be inferred from base readiness when later credentials are optional. + +`skillet access finalize --user ` installs a final user-specific sudo rule +requiring that user's password and expires its existing bootstrap password. The +operator changes the password at the next interactive login. SSH continues to use +keys; finalization does not enable SSH password authentication. The existing +bootstrap password must be known, and console recovery should be available. + +A root-only per-account journal records intent before expiry and completion after +success. Interrupted delivery is retryable; a fingerprint of the bootstrap hash +allows recovery to preserve a password already changed by the operator. Repeat +finalization restores sudo policy without expiring the password again. A root-held +lock serializes finalization. Sudo syntax is checked before and after installation; +a later sudoers include is rejected rather than risking a conflicting override. + +Smoke VMs retain temporary passwordless access unless explicitly finalized. Base +apply, boot and image rebases preserve the final policy and expiry receipt. Later +SSH provisioning automation currently assumes passwordless sudo; after finalization +use interactive sudo/root administration until an authenticated transport is added. +The policy applies to the selected account; other accounts remain unchanged. + +The current production Butane bootstrap password hash is retained by this change. +Private bootstrap-password delivery is a separate future decision; never include +hashes, password fingerprints or passwords in logs, manifests or audit records. diff --git a/ublue/design/btrbk.md b/ublue/design/btrbk.md new file mode 100644 index 00000000..cc8f9d52 --- /dev/null +++ b/ublue/design/btrbk.md @@ -0,0 +1,32 @@ +# Local Btrfs snapshots + +Install btrbk in the shared uCore image, mask its packaged daily timer, and +manage configuration and the hourly timer through a reusable Skillet module. +Each host opts in by passing explicit +subvolume paths relative to `/var/lib/data`; an empty list opts out. Clamps +selects only `syncthing`, stored at `/var/lib/data/syncthing`, with snapshots +under `/var/lib/data/snapshots/syncthing`. The module rejects absolute paths, +traversal, and the data root, and requires each selected source to be an existing +Btrfs subvolume. It never defaults to snapshotting the parent or `containers`. + +Selected snapshot sources must be leaf subvolumes. Nested subvolumes inside a +selected source are unsupported: Btrfs snapshots do not include their contents. +Syncthing's data layout is designed without nested subvolumes, and this is an +invariant of the current policy rather than a recursively discovered source +list. A future need for nested subvolumes requires an explicit design change. + +Keep rupik's hourly schedule and retention (`6h` minimum; `24h 31d 6m`). The +service reruns the shared data preparation validation before calling btrbk, so +it fails closed if the intended `/data` Btrfs mount is absent or wrong. Pi-hole +snapshot policy remains separate. + +Guard both convergence and the timer against a missing or wrong data mount. +Run one systemd timer instead of preserving Chef's cron job. This keeps +snapshots on the intended filesystem and avoids duplicate runs. Local +snapshots share the source disk's failure domain and are not an independent +backup. Verify recovery by restoring a disposable snapshot to a separate path +before relying on the schedule. + +Status: implementation and real-VM snapshot/restore verification are complete. +See the [implementation milestone](../plan/CLAMPS-BTRBK.md) and +[shared storage design](storage.md). diff --git a/ublue/design/cloudflare-ddns.md b/ublue/design/cloudflare-ddns.md new file mode 100644 index 00000000..ba80fa60 --- /dev/null +++ b/ublue/design/cloudflare-ddns.md @@ -0,0 +1,75 @@ +# Public address DDNS + +Decision, 2026-10-06: retain Rupik's public-address DDNS capability. Plan to +reuse the existing Chef image family in a rootful Quadlet container, with +caller opt-in and private configuration supplied through Skillet credentials. +This preserves the existing updater instead of introducing another guest +Cloudflare API client. Guest convergence, production credential delivery, and +journaled disposable token/record cleanup are implemented. Live disposable DNS +acceptance remains pending the test-only DDNS record-name entry. + +The host caller explicitly opts into the shared DDNS crate/service. Installing +Skillet or configuring an environment zone does not enable DDNS. Reuse +`skillet/environments//dns/cloudflare-zone-id`; no separate DDNS +zone entry is needed. The public JSON policy is an embedded workstation +configuration template. KeePassXC contains only each environment/host's +relative DDNS record name; see the [configuration template design](configuration-templates.md). + +The DDNS container owns only its explicitly configured public-address records. +The existing workstation UI lifecycle owns tailnet machine records and UI +CNAMEs. Keep those record sets disjoint. One production writer owns the retained +DDNS records during cutover; stopping Rupik's writer precedes starting Clamps'. + +Match Chef's initial policy: A enabled, AAAA disabled, TTL 300, unknown-record +purging disabled. Preserve each record's approved proxy status. Do not derive +DDNS names from the UI namespace or enable IPv6 without a usable public address +and an explicit decision. + +The workstation issuer supplies a separate zone-scoped DDNS child token; +the token creator stays on the workstation. Deliver the complete config as an +encrypted credential and mount its Podman secret at the updater's config path. +Avoid a plaintext token-bearing config file on persistent host storage. Podman's +own disk secret copy remains covered by the planned storage encryption policy. +Use existing atomic delivery, deferred activation, and lifecycle ownership. + +The selected image is upstream 2.2.0, pinned to its multi-platform index digest. +Its scratch image runs as UID/GID 0 with `/` as its working directory and +`/cloudflare-ddns --repeat` as entrypoint. The legacy JSON loader reads +`/config.json`; its repeat interval is the configured TTL (300 seconds). +Mount the complete Podman secret there as `0400`, UID/GID 0. Do not set +environment-mode token variables, which would bypass JSON configuration. +An offline run of the actual image accepted this secret mount with dummy +credentials and networking disabled. Digest pinning intentionally prevents +registry auto-update from changing this evaluated contract. +Its legacy API path reads one page of 100 A records; provisioning refuses a +zone that would exceed that limit after adding the declared records. + +The version 1 DDNS JSON template has relative `records` and explicit `proxied` +booleans. Its `$secret` marker resolves the `ddns-dns-name` vault leaf, and +`takeover_existing` defaults to false. A per-host template replaces the shared +template completely. Production preflight rejects +the whole UI namespace, duplicates, and incompatible records. Existing public +A records need explicit takeover approval; records owned by other Skillet +consumers cannot be adopted. The updater writes a distinct +`skillet-ddns:production:` comment so later delivery recognizes its +records. Approval does not stop an old writer; that remains a cutover action. + +Production delivery reuses or creates a separate persistent child token through +the same atomic token-persistence helper used by Caddy. New tokens are revoked +when vault saving fails; deterministic token names support retry recovery. +The dedicated DDNS apply phase leaves ordinary full apply independent of this +credential. Test-policy delivery is opt-in through the disposable VM provision +flow. It journals exact test record names, ownership markers, record IDs, and +bounded token identities before mutation; cleanup validates this state against +the recorded VM and deletes only records that still match. Ordinary application +smoke checks exclude DDNS. + +Source contract: [upstream 2.2.0](https://github.com/timothymiller/cloudflare-ddns/tree/7c6d5b43c1d400f4977f2b25bd44d784bb601ae3). + +Disposable tests use explicitly isolated record names and bounded credentials. +Journal and clean only test-owned resources; disposal never removes retained +production DDNS records. The first VM attempt was parked before Cloudflare +mutation because its then-required private JSON config entry was missing. Live +acceptance now requires the test `ddns-dns-name` leaf. See the +[implementation plan](../plan/CLAMPS-CLOUDFLARE-DDNS.md) and [token/DNS +lifecycle](cloudflare-ui-lifecycle.md). diff --git a/ublue/design/cloudflare-ui-lifecycle.md b/ublue/design/cloudflare-ui-lifecycle.md new file mode 100644 index 00000000..b513db94 --- /dev/null +++ b/ublue/design/cloudflare-ui-lifecycle.md @@ -0,0 +1,136 @@ +# Cloudflare UI credentials and DNS lifecycle + +Decision: the workstation issuer creates scoped Cloudflare child tokens and +reconciles owned DNS before live Caddy ACME acceptance. Do not require manually +created smoke VM ACME tokens. +The workstation keeps `skillet/cloudflare/token-creator` in KeePassXC and mints +zone-scoped child tokens through Cloudflare's account-token API; the account ID +comes from the selected zone response. Only a child token is delivered to Caddy. +The issuer is an account-owned API token with Account API Tokens Read or Write +and Zone Read on the configured zones. Zone Read allows preflight validation to +fetch the selected zone and its account ID. Skillet uses account-token +permission discovery, creation, listing, and revocation endpoints; DNS Write +remains confined to child tokens. + +## Configuration and access + +Each environment requires `skillet/environments//dns/cloudflare-zone-id` +in the vault's Password field (`prod` or `test`; CLI `production` maps to `prod`). Fetch that exact zone's domain +from Cloudflare. Its optional `skillet/environments//dns/ui-domain` is a relative +prefix, defaulting to `ui` only when absent. Always append the zone's domain: +`ui.whatever` in zone `example.com` resolves to `ui.whatever.example.com`. +This removes redundant full-domain configuration and keeps namespaces within +the selected zone. Empty or invalid prefixes fail before issuance or DNS +mutations. Former full-domain values require a deliberate vault migration; +they are never interpreted as absolute names. Production and test retain +separate configuration. Delivery and cleanup use the same resolution rule; +ignored ownership metadata retains the resolved full namespace for verification. + +Host declarations derive a machine name `.` with one A +record for its verified Tailscale IPv4 address and one AAAA for its verified, +usable Tailscale IPv6 address. A missing address family is reported explicitly; +do not invent an address or publish an unusable AAAA record. Each canonical UI +name `..` is a CNAME to that machine name. Keep all +records DNS-only with a short TTL. Address changes update the machine records +without rewriting every UI record. DNS-01 can issue certificates before +address publication, but working UI URLs require this record chain. + +Each host caller may declare zero or more aliases per UI in the same service +declaration that supplies its upstream. Aliases are domain-free relative DNS +names beneath the selected environment's UI domain, optionally containing an +explicit `{host}` label placeholder. This keeps real domains in KeePassXC +and gives test and production the same declaration. Absolute names and aliases +outside this namespace require a later explicit vault-backed design; never +broaden token scope automatically. Validate expanded names and reject wildcard, +cyclic, conflicting, or duplicate names across services before mutations. + +Each alias is a CNAME to its canonical UI name. Caddy serves the canonical +name and every alias through the same upstream and tailnet restrictions; +aliases are additional routes, not redirects. Caddy must obtain and renew +certificate coverage for every served name, whether certificates are separate +or contain multiple names. A CNAME does not give its source name the target's +TLS identity. Include the alias set in the versioned encrypted Caddy payload +and validate it against the receiving host's declaration. Verify DNS, TLS +hostname identity, and upstream responses for canonical names and aliases. +Publish no LAN or public host addresses. + +Child tokens have Zone Read and DNS Write restricted to the selected zone. +DNS Write covers A, AAAA, CNAME, and ACME TXT records, so address publication +needs no additional permission. The scope covers the whole zone, not an individual +host's records. Separate tokens allow independent revocation, while the issuer +stays on the workstation. Public DNS exposes the published names and tailnet +addresses; keep those values out of the public repository. + +## Provision, reuse, and recovery + +Persistent deployments use-or-create a token at +`skillet/environments//hosts//cloudflare/acme-token` through +the atomic KDBX writer. Reuse existing tokens; rotation is explicit. If saving +an issued token fails, revoke it or retain ownership sufficient for recovery. +Never silently replace an existing credential. + +Disposable deployments mint a bounded-lifetime token for the recorded VM +owner, using no manually populated ACME entry. Journal token ID, expiry, +environment, and target ownership in ignored mode-0600 run metadata. Keep the +token value only in memory during delivery and in the existing encrypted host +credential and Podman secret path, never in metadata or logs. Record issuance +intent before API calls and ownership immediately afterward. Deterministic +ownership markers must allow recovery after lost responses; ambiguity fails +without deleting resources. If a token value cannot be recovered after an +interruption, revoke the owned token and issue a replacement explicitly. + +Reconcile the machine A/AAAA records, canonical UI CNAMEs, and all alias +CNAMEs with the enrolled device and host declarations. Reuse matching owned +records, reject unrelated conflicts, and journal record IDs and expected type/value/ownership. Concurrent instances +claiming the same environment/host names must be refused; never append an +instance to a UI hostname silently. Install both Caddy credentials before +activation. Repeat provisioning reuses tokens, records, and certificate state. + +## Disposal and expiry + +Delete verified owned alias CNAMEs first, then canonical UI CNAMEs, then +machine A/AAAA records, and revoke the disposable token. Removing an alias +from a host declaration reconciles its owned DNS record and Caddy route; do +not delete shared machine records while other declared UIs still use them. +Caddy manages its own challenge TXT lifecycle; +never sweep other TXT records or delete records solely by hostname. Clean up +the Tailscale device and local VM only after external ownership is resolved. +Failures retain metadata for retry, including partial or already-gone guests. + +Token expiry does not remove DNS records. If the VM token is expired or +revoked, mint and journal a temporary zone-scoped cleanup token using the +workstation issuer, delete the verified records, and revoke the cleanup token. +Retained VMs need explicit token renewal for later ACME operations. Production +credentials and records are never disposed by test VM commands. + +Implementation status: `cloudflare-rs` supplies authenticated blocking HTTP +transport; custom typed JSON endpoints use that transport for token APIs and +DNS record comments that the crate's DNS models do not expose. Persistent +delivery use-or-creates and saves its scoped token in KeePassXC. The +workstation UI provisioner now persists disposable owner intent, issues and +journals its scoped token, reconciles DNS, records owned record IDs, and owns +disposable cleanup. Persistent delivery also owns tailnet device lookup, +ACME-token reuse or issuance, migration of the legacy production entry, +DNS reconciliation, complete credential-set delivery, and deferred Caddy +activation. Cleanup validates the recorded identity and current +environment configuration before mutation, removes marker-owned DNS, revokes +the disposable and temporary cleanup tokens, and removes its journal last. +Failures retain the journal for retry. Disposable guest delivery now installs +both credentials before Caddy activation, checks the explicit non-tailnet +denial response for every canonical UI and alias, and only then revokes older +tokens with the same deterministic name. Local +tests cover the SDK transport, permission group +discovery, scoped token payloads, address-family validation, and DNS planning. +Live 2026-10-02 staging acceptance issued certificates for each +declared canonical UI and alias, verified DNS and HTTPS over IPv4/IPv6, and +disposed the VM, DNS records, and child tokens. CI tests inject an ambiguous +token-create response, a DNS-create failure after a partial reconciliation, +and a DNS-delete failure during cleanup; each retry converges without +duplicates or deleting unrelated records. Opt-in smoke provisioning also +requests each configured UI from the guest's loopback source and requires +Caddy's explicit 403 response. This verifies the source-address matcher for a +non-tailnet source; denial from a separate external network and production +certificate acceptance remain pending. + +See [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md), +[secret delivery](secrets.md), and [VM lifecycle](smoke-vms.md). diff --git a/ublue/design/configuration-templates.md b/ublue/design/configuration-templates.md new file mode 100644 index 00000000..8152f4b7 --- /dev/null +++ b/ublue/design/configuration-templates.md @@ -0,0 +1,43 @@ +# Configuration templates and secret references + +Decision, 2026-10-06: keep ordinary service configuration as clear, reviewed +source templates; keep only host/environment-specific leaf values in KeePassXC. +This avoids storing and hand-editing whole JSON documents in the vault while +keeping private values out of the public source. + +Templates are JSON keyed by service, with a shared default and optional complete +host overrides. A host override replaces the entire shared template; automatic +field or array merging is intentionally undefined. A value can use the typed +marker `{"$secret":"skillet/environments/{environment}/hosts/{host}/..."}`. +Skillet substitutes only the validated host and environment components in that +entry path, then replaces the whole marker with the KeePassXC Password value as +a JSON string. Secret contents are never interpolated into surrounding text or +interpreted recursively. + +The workstation resolves references in memory before service-schema validation +and before provider mutations. Missing entries, malformed references, and +unknown placeholders in entry paths fail closed. Ordinary strings remain +literal. Rendered documents and secret values are not +logged, recorded, or persisted as workstation files. Guest apply has no vault +dependency. + +The embedded catalog is +[`configuration-templates.json`](../skillet/crates/workstation/src/configuration-templates.json); +the resolver is workstation-only. Adding a service adds its default template to +the catalog; add a host override only when a caller needs a different complete +configuration. For example, DDNS keeps policy in its template and its relative +record name in +`skillet/environments//hosts//cloudflare/ddns-dns-name`. + +When converting an existing vault-held JSON document, move its structural policy +into the source template and each private leaf into its own KeePassXC entry. +For DDNS, move `name` to `ddns-dns-name`; keep `proxied` and +`takeover_existing` in the template. Multi-record hosts can provide a complete +host override with one typed reference per record. Remove the obsolete whole +document entry after the new template and leaf values have been validated. + +Datadog resolves one shared `skillet/datadog/api-key` for both environments. +Its site is ordinary template configuration, defaulting to `datadoghq.com`, +with optional host overrides/private tags. Delivery +adds the selected environment tag and rejects a conflicting one; credentials +alone never select the Agent capability. diff --git a/ublue/design/email-delivery.md b/ublue/design/email-delivery.md new file mode 100644 index 00000000..df9aaac5 --- /dev/null +++ b/ublue/design/email-delivery.md @@ -0,0 +1,84 @@ +# Fleet email delivery + +Status: image packages and shared SMTP provisioning implemented. Native Postfix +container and rebuilt uCore capture acceptance passed. Live Mailjet submission +over verified STARTTLS and operator-confirmed Gmail receipt passed. Mail +authentication headers remain unverified; UniFi recovery email is parked by +operator decision. + +## Implementation boundary + +Use native Postfix as a submission relay with a persistent retry queue. Install +Postfix and its required SASL/TLS support in `ucore-common`; keep the shipped +service inactive until configuration and credentials are delivered. A shared +`skillet_smtp` service module owns runtime convergence, selected from the +canonical fleet host declarations. Do not put SMTP policy in `skillet_core` or +make the credential-free base/bootstrap phase require provider access. + +This gives host programs the standard sendmail interface and host-network +applications a loopback SMTP endpoint. The packages are static OS dependencies; +provider endpoints, private sender identities and credentials are runtime inputs. +A container relay is possible, but would additionally need host submission +integration. A lightweight client alone does not supply the required durable +queue without extra machinery. + +## Provider entry + +Mailjet is the existing provider. Read a single `skillet/smtp` entry: +Username and Password contain SMTP credentials; `host`, `port` and `tls` custom +fields hold the endpoint and security mode; `sender` holds the verified sender +address. Field-aware reading, auditing and encrypted delivery are implemented. +The audit requires these fields once for production fleet preparation. Default test +provisioning neither reads nor delivers provider credentials. The explicit +`--smtp-only --live-smtp` acceptance override delivers real credentials through +the same owned VM transport without changing its test identity or enabling +other production services. It sends no message itself. Only `starttls` is +accepted; production requires certificate and hostname verification. +Missing/invalid field names are public diagnostics; values are never printed. SMTP credentials are the provider’s API key/secret, rather +than the account login ([provider guide](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters)). + +## Delivery and access + +Relay only through an authenticated external provider, using verified TLS; +never fall back to direct destination-MX delivery. Keep the queue in persistent +`/var/spool/postfix`, independent of the service-data mount. Shared templates +hold policy; private configuration and credentials use the existing vault and +systemd encrypted-delivery workflow. A root-owned, Postfix-group-readable +`texthash` map (`0640`, parent `0750`) under `/run/postfix/skillet` is regenerated +from `LoadCredentialEncrypted=` before Postfix starts. Native Fedora acceptance +verified backend/worker permissions. Capture delivery and boot/rebase recovery +passed on uCore with enforcing SELinux, including production credential-map +access and live TLS/authentication. The guest preparation adapter copies the +Postfix configuration label onto RAM credential files when SELinux is enabled, +after restoring the reference file label. Disabled test containers skip labeling; +permissive hosts still receive correct labels. Trust uses Fedora’s extracted CA +bundle rather than assuming a compatibility symlink exists. +Skillet initializes the packaged queue layout and `/var/lib/postfix` before +startup, with package ownership/modes and restored SELinux labels. Image-layer +`/var` contents do not initialize a rebased host. Existing message contents are +preserved; managed directory metadata and security labels converge. Sender canonical rewriting preserves recipients. +Never bake credentials or sender domains into an image. + +Default listener: loopback only. UniFi uses host networking and can reach it. +Bridge containers need an explicitly declared internal endpoint with restricted +access, preferably authenticated submission; do not expose port 25 on the LAN +or tailnet, or trust an entire bridge containing unrelated containers by default. +Application modules own their SMTP client integration and monitoring declarations. +Postfix and credential-preparation unit health are declared by the SMTP module; +queue-age and delivery-failure metrics remain pending. + +Production fleet hosts include SMTP as shared service policy. Test machines use +an isolated capture sink by default, with no external provider delivery or copied +production credentials. Real delivery is an explicit operator-controlled test. +Retained queues cannot switch between capture and production policy; apply and +credential preparation both enforce the persisted mode. The test relay targets +loopback port 1025; messages queue until an acceptance sink is running. + +Use a provider-verified sender and SPF/DKIM/DMARC. The provider handles internet +SMTP delivery infrastructure and IP reputation; authentication does not guarantee +inbox placement. No incoming mailbox or public MX service is required. + +References: [Postfix relay configuration](https://www.postfix.org/SOHO_README.html), +[Gmail sender requirements](https://support.google.com/mail/answer/81126?hl=en). + +See [the implementation plan](../plan/CLAMPS-EMAIL.md). diff --git a/ublue/design/file-browsing.md b/ublue/design/file-browsing.md new file mode 100644 index 00000000..c1370075 --- /dev/null +++ b/ublue/design/file-browsing.md @@ -0,0 +1,45 @@ +# Private file browsing + +Decision, 2026-10-11: provide a shared file-management UI on Syncthing hosts, +with Caddy HTTPS and [verified tailnet identity](tailnet-ui-authentication.md). +The two intended users must enter without an internal password prompt. Serve +only caller-declared user folders; do not mount Syncthing configuration or the +whole data filesystem. Keep any app state outside synchronized files. The +magiusstaff second instance is deferred. + +## Selection + +| Candidate | Fit / tradeoff | +| --- | --- | +| [Mikochi](https://github.com/zer0tonin/Mikochi) | Small Go/Preact UI for browsing/upload/download/rename/delete and folders. One configured login/root; `NO_AUTH` fits the proxy-authenticated flow. Selected for the initial rollout. | +| [FileBrowser Quantum](https://github.com/gtsteffaniak/filebrowser) | Closest Filebrowser-style experience, richer sources/permissions; supports proxy authentication. More configuration and explicit old-state migration. | +| [Copyparty](https://github.com/9001/copyparty) | Existing folders, per-user/per-folder permissions, resumable uploads and WebDAV; a broader file-server feature set and different interface. | +| [Filestash](https://www.filestash.app/) | Browser client for varied storage backends; useful when protocol/cloud access is needed, adding integration choices for this local-folder use case. | + +The requested [Awesome Selfhosted section](https://github.com/awesome-selfhosted/awesome-selfhosted#file-transfer---web-based-file-managers) +provided the initial shortlist; capabilities above were checked against upstream +projects. Mikochi's latest [1.12.0 release](https://github.com/zer0tonin/Mikochi/releases/tag/1.12.0) +was published 2026-09-25 and includes authentication fixes. Its documented single +login is not a per-user permission model: with `NO_AUTH=true`, the proxy allowlist +controls access and both users share the mounted scope. Confirm supported +operations and use a current pinned image in a disposable trial. + +Mikochi 1.12.0 is declared alongside Syncthing on clamps and joey, with +`files..`, no application login, and a private backend. The +caller declares its user-file scope; the default is the Syncthing `data` directory, +separate from `config`. Both containers use the same numeric process identity +and host-user mapping. Existing synchronized folder paths are not moved. + +Real-container CI passed file operations and mapped ownership. The disposable +VM passed LocalAPI authorization, repeat apply and dual-stack backend isolation; +HTTPS browser acceptance is pending external ACME/connectivity checks. See +[the acceptance record](../butane/ACCEPTANCE.md). Broader symlink behavior remains +unverified; denial of an unmounted host target does not prove all container-path +containment. + +Disposable acceptance must test +large upload/download, filenames, folder operations, ownership, symlinks/root +containment, reboot, no second login and denial of other users/direct backend +access. Keep native login disabled only when the proxy is the sole entry point. +If different per-user folder rights become required, evaluate Quantum/Copyparty +before introducing another instance or custom authorization in Mikochi. diff --git a/ublue/design/fleet-hosts.md b/ublue/design/fleet-hosts.md new file mode 100644 index 00000000..ada2077d --- /dev/null +++ b/ublue/design/fleet-hosts.md @@ -0,0 +1,30 @@ +# Fleet host declarations + +Boxy is replaced by **joey**; rupik by clamps; calculon by donbot. +The host profile is the authority for services, UI routes, credentials, boot +expectations and acceptance. Shared recipes own behavior; host callers opt in. +Joey selects Pi-hole, Syncthing, Tailscale, btrbk, DDNS, Datadog and shared +SMTP. UniFi remains selected only where declared. + +Decision, 2026-10-11: use joey for boxy's replacement and donbot for +calculon's replacement. The rename deliberately provides no selector, package +or image aliases. Current CLI selectors, binary/package names, templates and +image builds use these identities. Do not silently rename retained VM manifests, +provider ownership journals or vault entries; historical acceptance preserves +the identities actually tested. Existing disposable runs need an explicit +owned cleanup/migration path; new provisioning uses the renamed profiles and +requires the corresponding images to be built and published first. + +Both servers share the Btrfs data layout and credential-gated full-apply unit. +The full unit applies to profiles requiring Pi-hole and Tailscale credentials; +future capability sets must select an appropriate activation unit. Per-host +includes select artifacts/images; generic units read `/etc/skillet/host`. + +Boxy's subnet-routing and private cross-host routes/probes need separate policy +and private inventory. Selecting the current shared modules does not claim full +live source parity. See [the migration plan](../plan/JOEY-MIGRATION.md). + +Calculon/donbot source services, exclusions and public/mixed ingress needs are +in [the inventory](../plan/DONBOT-INVENTORY.md) and +[migration plan](../plan/DONBOT-MIGRATION.md). The donbot image recipe exists; +its Skillet profile, host binary and Butane configuration remain pending. diff --git a/ublue/design/home-assistant.md b/ublue/design/home-assistant.md new file mode 100644 index 00000000..4a65788d --- /dev/null +++ b/ublue/design/home-assistant.md @@ -0,0 +1,59 @@ +# Home Assistant + +Status: decision recorded on 2026-10-11; implementation and acceptance pending. +See the [implementation plan](../plan/HOME-ASSISTANT.md). + +## Deployment and access + +Use Home Assistant Container as an optional shared Skillet service selected by +its host caller. Clamps is the first consumer; other hosts do not inherit it. +Run the official image as a rootful Quadlet with host networking for LAN +multicast discovery. Pin the application release and allow 60 seconds for clean +shutdown. Persist `/config` at `/var/lib/data/homeassistant/config` outside +Syncthing. Map only selected radios/devices and add Bluetooth D-Bus access when +required. Establish the effective capabilities and SELinux policy in runtime +acceptance rather than copying an unrestricted privileged example blindly. + +Container installation has no Supervisor/app store. Companion services such as +MQTT and Zigbee2MQTT require their own declared containers and backup policies. +Skillet manages deployment and explicitly owned initial settings; Home Assistant +owns its accounts, integrations, automations and internal configuration. + +Expose `homeassistant..` through the existing tailnet-restricted +Caddy/Cloudflare DNS-01 path. Add a shared host-upstream representation because +Caddy uses bridge networking and cannot resolve a host-network application as a +bridge container. Validate the Podman host gateway, proxy trust and listener +isolation together. Raw port 8123 must not bypass the private UI policy. +Retain Home Assistant login; tailnet authorization does not create an application +session. Machine A/AAAA records and UI CNAMEs remain unchanged; Pi-hole local +records and MagicDNS are not dependencies of this route. + +## Backup and recovery + +Use Home Assistant's scheduled native backups, with encryption enabled. The +Container local directory is `/config/backups`. Publish completed encrypted +archives to `/var/lib/data/syncthing/backups/homeassistant` using a bounded, +retryable exporter and atomic replacement in the destination filesystem. Keep +unfinished archives outside the synchronized namespace. This separates live +SQLite/configuration writes from replication and prevents partial backups from +becoming advertised recovery copies. + +Explicitly share the backups folder in Syncthing. Prepare typed ownership/ACLs +for its mapped identity; never recursively change the live application tree. +The destination is an ordinary directory within the existing Syncthing leaf +subvolume. Clamps' existing btrbk selection snapshots it without adding the +Home Assistant state tree or all of `/var/lib/data` to snapshot policy. + +Syncthing provides off-machine copies; btrbk provides local history. The latter +shares the source disk's failure domain, and synchronization propagates deletions. +Keep independent retention at the receiving site. Retain the backup encryption +key/emergency kit in the portable KeePassXC vault with an independent recovery +copy. Export original encrypted files, not the UI download that decrypts them. +Prove restoration into an isolated disposable instance before relying on backups. +Never run a restored production automation/integration against live devices. + +References: [Container installation](https://www.home-assistant.io/installation/linux/), +[backup integration](https://www.home-assistant.io/integrations/backup/), +[backup and restore](https://www.home-assistant.io/common-tasks/general/), +[Container backup directory](https://github.com/home-assistant/core/blob/2026.10.1/homeassistant/components/backup/backup.py), +[HTTP/proxy configuration](https://www.home-assistant.io/integrations/http/). diff --git a/ublue/design/monitoring.md b/ublue/design/monitoring.md new file mode 100644 index 00000000..5f292df2 --- /dev/null +++ b/ublue/design/monitoring.md @@ -0,0 +1,126 @@ +# Monitoring composition + +Keep Datadog for the Rupik-to-Clamps migration, retaining required host, +process, network, filesystem, and service checks. Caddy replaces nginx, so its +checks need a different implementation. Local metrics/Grafana remain a later +discussion outside this migration. + +Decision, 2026-10-06: services own their monitoring declarations. Datadog is an +optional host capability; the agent consumes the checks belonging to the +services selected by the canonical host caller. Adding or removing a service +must also add or remove its monitoring without a second service inventory. + +Container modules declare credential-free Datadog Autodiscovery labels. The +shared Datadog library renders and escapes those labels. Checks use discovered +container addresses, so private UI ports stay unpublished. Container/process +liveness and application correctness are different checks; process presence +does not establish DNS publication, synchronization, or controller health. +Non-container services contribute explicit systemd units to the host check. + +Boot discovery policy: order the Agent after the same caller-selected monitored +units, using `After=` without `Wants=` or additional `Requires=` dependencies. +The pinned Agent collector lists existing containers once, then consumes events; +Podman's boot-time event path did not recover application labels in acceptance. +Ordering makes the initial inventory follow concurrent application startup and +leaves services without delivered credentials inactive. It does not guarantee +reconciliation of missing events later in runtime. Verify reboot discovery on +real Podman; provider polling is not a supported replacement for the pinned +streaming container provider. Two live reboots restored application checks +without restarting the Agent; see the acceptance record. + + +Approved agent deployment, 2026-10-07: a pinned official rootful Agent container managed by +Skillet. Datadog recommends container deployment for Podman over native host +installation, which requires additional permission handling. The OS image +continues to provide Podman and kernel tooling; Skillet owns activation, +credentials, and changing integration configuration. Packaged community +integrations with extra dependencies require a reviewed image build; never +install them through runtime downloads. + +Decision, 2026-10-12: native service collectors may instead use the existing +static musl guest binary and submit through DogStatsD. The official Agent stays +unchanged. DogStatsD uses a Unix datagram socket under a root-only `/run` +directory, recreated by tmpfiles on boot; its UDP listener is disabled. The +Agent owns the socket and ingestion credentials. Collectors receive neither +API keys nor vault access. Module-owned timers are configured only after the +credential-gated Agent phase succeeds, and must not activate that Agent or an +optional application themselves. + +The NUT collector performs bounded read-only protocol queries. It exports a +fixed numeric inventory and UPS status flags, with `service:nut` and a declared +UPS-name tag; host/environment/location follow the Agent's existing tags. +Failed queries emit a communication failure check without invented zero +readings. Unix datagram submission proves local receipt, not cloud ingestion. +These `nut.*` gauges are custom metrics; keep their inventory and tags bounded. + +Implemented agent runtime and credential delivery. Host monitoring +needs host PID/cgroup visibility, read-only proc/cgroup/filesystem mounts, and +SELinux access. Podman Autodiscovery through the rootful API socket grants +container-management authority even with a read-only socket bind mount. +Preserving Chef's network/service monitoring additionally needs the +system-probe's eBPF capabilities and debugfs access. Those privileges must be +explicit caller policy and tested on uCore with its Secure Boot/SELinux settings. +For network monitoring, explicitly select `PERFMON`: the installed Podman +seccomp profile gates `perf_event_open` on that capability. `SYS_ADMIN` alone +left the syscall returning ENOSYS in the VM. Preserve default seccomp and +read-only debugfs; do not disable syscall filtering or make tracing writable +unless a separate diagnosed requirement warrants it. Named-VM acceptance on 2026-10-08 confirmed perf events, NPM/USM traffic +collection and automatic recovery after reboot with SELinux enforcing. The VM +uses BIOS; Secure Boot remains a physical/UEFI acceptance requirement. + +The agent has a separate credential-gated apply phase. Installing Skillet +or declaring a monitored service must not start telemetry without the Datadog +capability and credentials. Use configuration templates to combine public +policy with one shared `skillet/datadog/api-key` in KeePassXC. Site is public +template configuration, defaulting to `datadoghq.com`. The application key +used by Chef's handler is unnecessary for metric submission; future dashboard +or monitor management would be a separate workstation responsibility. Private +tags, endpoint credentials, and ping targets follow the existing encrypted +delivery path and must not appear in labels or recorded files. + +Host networking preserves host interface visibility and localhost SSH checks; +Autodiscovery reaches container bridge addresses without published UI ports. +The socket is accessed through a read-only bind of `/run/podman`; the volume +helper converges directory roots, not socket files. Integration directories +are mounted individually so the official image retains its default system and +container checks. The pinned official image +requires `DD_API_KEY` at startup, so Podman injects +API key and private tags through native environment secrets; the public site +uses an ordinary `Environment=DD_SITE` directive. No literal secret-bearing +`Environment=` directives are written. Production and test use the shared key; +delivery adds an authoritative environment tag and region. Production region +is declared by the host caller (Clamps inherits Chef's `region:ftwo`); every +test deployment uses `region:lab`, replacing any template region tags. +Monitor notification policy excludes `env:test` in the data query, preserving +legacy production hosts with `env:home` or no environment tag. Monitor metadata +tags alone do not filter host data. One-time monitor administration uses a +separate optional workstation-only application key; it is never delivered to +the Agent. One-time setup completed on 2026-10-08: the all-host Agent no-data and NTP +monitors exclude `env:test`. Provider validation, updates and query read-back +passed; notification settings were preserved. Other existing monitors target +production hosts explicitly. New monitors must preserve this exclusion. +Agent status and successful submissions confirm the delivered test/lab tags; +provider host-tag read-back returned HTTP 403 with the monitor-only key. Both +monitors returned zero test-host groups before and after reboot. PagerDuty +incident delivery was not separately observed. Test +telemetry requires explicit `--with-datadog`; destroying a VM does not remove +historical telemetry from Datadog. + +Implemented: labels for Syncthing HTTP health and process liveness for +Pi-hole, DDNS, UniFi, Caddy, and Tailscale; Agent deployment/delivery and +host/process/network/Btrfs/SSH/systemd configuration. Named-VM acceptance +confirmed host/container checks, Autodiscovery, and successful submissions with +SELinux enforcing. eBPF acceptance now passes with explicit `PERFMON`, default +seccomp and read-only debugfs. Real Syncthing bridge traffic was classified as +HTTP with nonzero byte counters and HTTP aggregates, also after reboot. Pending: +private ping targets and richer +Syncthing metrics and Pi-hole's authenticated API integration. The existing +Pi-hole community integration requires compatibility review against Pi-hole 6. + +Sources: [pinned container collector](https://github.com/DataDog/datadog-agent/blob/8e795c5e/comp/core/workloadmeta/collectors/internal/docker/docker.go), +[Podman deployment](https://docs.datadoghq.com/containers/guide/podman-support-with-docker-integration/), +[Autodiscovery](https://docs.datadoghq.com/containers/docker/integrations/), +[network monitoring](https://docs.datadoghq.com/network_monitoring/cloud_network_monitoring/setup/), +[Btrfs](https://docs.datadoghq.com/integrations/btrfs/), +[systemd](https://docs.datadoghq.com/integrations/systemd/), +[DogStatsD](https://docs.datadoghq.com/extend/dogstatsd/). diff --git a/ublue/design/pihole-network.md b/ublue/design/pihole-network.md new file mode 100644 index 00000000..04a5da8b --- /dev/null +++ b/ublue/design/pihole-network.md @@ -0,0 +1,38 @@ +# Pi-hole container network + +Run Pi-hole and future Caddy as separate containers on one Quadlet-managed +dual-stack bridge. Keep Podman's network DNS enabled and refer to Pi-hole by +its container name (`pihole`) from Caddy, so neither container needs a fixed +address and they keep independent lifecycles. Do not use a shared pod. + +Publish Pi-hole DNS on host IPv4 and IPv6 port 53 for both TCP and UDP, matching +rupik. Keep its web listener on port 8088 inside the bridge; Caddy will proxy +to `http://pihole:8088` without publishing the admin port on the host. Pi-hole +uses `dns_listeningMode=ALL` for bridge-network clients. Mark its fully +qualified registry image for Quadlet auto-update; the OS image already enables +the auto-update timer. The planned private UI route and Cloudflare DNS-only +records are described in the [private UI design](private-ui-access.md). + +The planned second Pi-hole on joey beside clamps is a separate DNS +instance on the same power and internet connection. A possible third on remote +donbot adds site diversity. All instances offered to the same clients must +serve consistent local records and filtering policy; the synchronization +mechanism and LAN client resolver settings remain to be decided before those +hosts are deployed. + +Pi-hole's host port 53 conflicts with Netavark's default Aardvark DNS listener. +When a host profile declares Pi-hole, host composition writes Podman's rootful +`dns_bind_port=54` policy once before applying its services. Container recipes +only declare their network attachments; they do not own this global setting. +This must be verified on the target uCore release. Keep the bridge +non-internal; alternate Aardvark ports have a known limitation on internal +networks. + +Podman does not update an existing network's settings from a changed Quadlet. +Skillet records the accepted network definition and refuses drift. Changing +subnets or other network options therefore requires an explicit network +recreation with its consumers stopped. + +References: [Podman network Quadlets](https://docs.podman.io/en/stable/markdown/podman-network.unit.5.html), +[Quadlet container networking](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html), +[Pi-hole Docker networking](https://docs.pi-hole.net/docker/). diff --git a/ublue/design/podman-runtime.md b/ublue/design/podman-runtime.md new file mode 100644 index 00000000..5481db44 --- /dev/null +++ b/ublue/design/podman-runtime.md @@ -0,0 +1,21 @@ +# Podman runtime configuration + +Container process identity, host volume ownership, and user namespace mapping +are separate inputs. Recipes use the image's declared user explicitly when +appropriate, or declare a named/numeric process identity. The owning +application declares any desired volume-root mode and ownership as typed data; +Podman applies it only to that root and does not recurse into existing data. A +namespace mapping is opt-in, maps an existing host account through its +subordinate-ID ranges, and never creates host accounts as a side effect. + +Host-wide Podman settings are selected and applied once by host composition. +Host composition also creates and records each shared network definition once. +Container recipes attach by network name and declare host-network mode or port +publications with typed values; they do not carry duplicate network definitions +or change global daemon policy. + +Persistent service containers use the shared `/var/lib/data` mount dependency: +their units require its preparation service, bind to the mount, and assert the +mountpoint exists. Ignition creates and prepares the Btrfs filesystem and +Podman graphroot; application recipes validate mounts and declare their +service directories and ownership. diff --git a/ublue/design/private-ui-access.md b/ublue/design/private-ui-access.md new file mode 100644 index 00000000..0679e489 --- /dev/null +++ b/ublue/design/private-ui-access.md @@ -0,0 +1,134 @@ +# Private web UI access + +## Decision + +Use a private hostname per administrative UI, with Caddy terminating HTTPS and +proxying to the named container on the shared Podman bridge. Pi-hole and +Syncthing are the first targets. Their UIs are reachable only through the +tailnet; neither needs a public listener or an OAuth2 proxy. Keep Pi-hole's LAN +DNS listener and Syncthing's peer-transfer ports separate from their UIs. + +Run Tailscale in its Podman container with `Network=host`, matching the +existing Chef configuration on rupik, calculon, and boxy. This gives the host +a normal Tailscale interface for host-level routing and port forwarding. +Persist its state under `/var/lib/data/tailscale`. Caller-selected production +exit-node policy and its enrollment migration are documented in +[host routing](tailscale.md). Use separate tagged +identities and access rules for production servers and smoke VMs. The +workstation uses a narrowly scoped OAuth client to mint one-use enrollment +keys; only those short-lived keys are delivered to a host, never the OAuth +client secret. + +Use host-specific names in existing Cloudflare-managed production and smoke-test +zones rather than `*.ts.net`, so UI URLs and Caddy routes can survive a move +between hosted Tailscale and Headscale. Publish DNS-only A/AAAA records for +`.` pointing to its tailnet addresses. Canonical UI names CNAME to that machine name; +caller-declared aliases CNAME to their canonical UI names. Caddy serves every +name with matching certificate coverage and the same upstream/access policy. +The machine/CNAME/alias model is implemented; see the +[Cloudflare lifecycle](cloudflare-ui-lifecycle.md). Do not enable Cloudflare +proxying. Keep literal +zones and records outside this public repository; public DNS still exposes the +names and addresses. Some resolvers block public answers containing tailnet +addresses as DNS rebinding, so check resolution from real clients before +cutover. Caddy gets publicly trusted certificates through Cloudflare DNS-01; +the UIs need no public route. Cloudflare's certificate is not used for DNS-only +records: Caddy serves the certificate. + +Do not configure Tailscale split DNS for the UI names. A VM can enroll before +its DNS record exists. After enrollment, the workstation reads its tailnet +addresses and reconciles the machine address records and UI/alias CNAMEs +with a short-lived token scoped to the smoke-test zone. VM destruction deletes +only its recorded DNS records and +revokes that token. Production records use a separately scoped credential; +update them if a host's tailnet address changes. Tailscale enrollment needs no +DNS-management OAuth scope. Keep the Cloudflare token creator on the workstation. + +Plan a second Pi-hole on joey beside clamps. They cover individual host +failure but share power and internet failure. A third Pi-hole on donbot, +the calculon replacement in the other country, would add a separate site. +These Pi-hole instances provide redundant client DNS service, independent of +UI name publication in Cloudflare. A healthy resolver can resolve an offline +host's name but cannot make its UI available. Give each Pi-hole UI its own +host-specific name; do not promise a floating service name without health-aware +routing. + +Restrict Caddy's UI listener to tailnet traffic and grant access only to the +intended user identities. Tailnet access authorizes the connection; it does not +create an application login. Keep application passwords for existing services until the private path +and absence of direct UI access have been verified, then decide per application +whether a second login is useful. File browsing and Gokapi administration now +have an explicit no-second-login requirement: the shared +[tailnet identity gate](tailnet-ui-authentication.md) authenticates and authorizes +the two intended users before proxying. It is implemented for Mikochi; Gokapi +integration remains planned. An IP-range check alone is insufficient +for those services. In particular, Syncthing's current wildcard +host publication of GUI port 8384 must be removed or limited to a safe local +proxy path before relying on tailnet access alone. + +Retain access to each host by its tailnet device name or address, and SSH +access for DNS recovery. Do not expose an unauthenticated UI as the recovery +path. + +## Why + +Separate hostnames avoid applications' subpath problems. Tailnet identity and +grants keep admin UIs private without maintaining a public OAuth2 proxy. +Cloudflare DNS-only records remove the Pi-hole and tailnet DNS configuration +dependency from UI name resolution, while keeping our own names across control +planes. The names and tailnet IPs are public DNS data. + +## Status and references + +The [Cloudflare ownership lifecycle](cloudflare-ui-lifecycle.md) now issues +credentials automatically and cleans owned DNS even when a disposable token has +expired. Staging ACME acceptance passed; no manually created VM token is required. + +Host UI service declarations now drive a shared versioned Caddy payload. +Provisioning derives `..` from the selected +environment's resolved namespace: the optional KeePassXC `dns/ui-domain` +relative prefix (default `ui`) appended to the zone domain fetched using the +mandatory `dns/cloudflare-zone-id`. Caddy validates the payload against the host's +declaration and renders each route through bridge DNS with tailnet source +restrictions. Shared Butane units call the generic `skillet` CLI and read the +stable host profile from `/etc/skillet/host`; VM hostnames remain independent. +Host-specific apply units and credential gates live in host-specific Butane +includes. Manual delivery accepts production or test environments. +Cloudflare token/DNS lifecycle and opt-in VM provisioning are implemented. +Staging ACME and HTTPS acceptance passed on 2026-10-02; production renewal and +outside-tailnet access remain pending. See the +[implementation plan](../plan/GENERIC-PRIVATE-UIS.md). + +Skillet configures host-network Tailscale and manages tagged enrollment and +removal for clamps smoke VMs; live smoke enrollment and cleanup were accepted +on 2026-09-30. Caddy now has a generic Quadlet configuration for the UI +services declared by the host, using bridge DNS, persistent certificate storage, Cloudflare +DNS-01, and source-address restrictions for Tailscale's IPv4 and IPv6 ranges. +Its hostname and token credentials are separate from normal full apply. +Syncthing no longer publishes its GUI port on the host. Staging Caddy/ACME and +disposable token/DNS cleanup passed live acceptance. Real-client DNS/HTTPS, +production renewal, and denial from a separate network remain open in the UI +plan. The Syncthing ownership-fix runtime check is separately parked. + +References: [Tailscale DNS](https://tailscale.com/docs/reference/dns-in-tailscale/), +[Cloudflare DNS-only records](https://developers.cloudflare.com/dns/proxy-status/), +[DNS rebinding](https://tailscale.com/docs/reference/faq/dns-rebinding/), +[Let's Encrypt DNS-01](https://letsencrypt.org/docs/challenge-types/), +[Caddy DNS challenge](https://caddyserver.com/docs/caddyfile/directives/tls). + +## ACME resolver policy + +DNS-01 propagation checks use explicit public Cloudflare resolvers, separately +from Podman DNS used for container upstreams. This keeps ACME independent of +the host's filtering/private DNS path. A diagnostic run completed issuance +with explicit resolvers; this does not eliminate external ACME validator DNS +failures, observed on the fresh VM. Keep this policy in shared rendering, +including aliases, and distinguish local reachability from issuer acceptance. + +## Planned public services + +Donbot adds public media and mixed file-sharing requirements. These are tracked +in [public service access](public-service-access.md) and +[its migration plan](../plan/DONBOT-MIGRATION.md). They require a generic access +policy extension; the current private renderer does not support them. Public +routes must preserve every existing private UI restriction. diff --git a/ublue/design/public-service-access.md b/ublue/design/public-service-access.md new file mode 100644 index 00000000..ee8cf442 --- /dev/null +++ b/ublue/design/public-service-access.md @@ -0,0 +1,48 @@ +# Public and mixed service access + +## Requirements agreed 2026-10-11 + +Calculon's replacement, donbot, needs publicly reachable HTTPS for Plex and +Jellyfin without a Tailscale client. Former OAuth-protected administrative UIs +move to tailnet-only access. Gokapi retains public sharing/upload flows with +private administration; inventory Airtrail's current public API exception and +Joplin's conditional public endpoint before selecting their policy. + +The private file-management capability should be shared by Syncthing hosts. +The magiusstaff second instance is deferred. File browsing requires no internal +password prompt; use the [shared tailnet identity policy](tailnet-ui-authentication.md) +for the two intended users and reuse it for Gokapi administration. + +## Proposed implementation, not yet accepted or implemented + +Extend the existing generic Caddy and provider lifecycle with explicit service +access policies. Keep private as the default and deny unspecified mixed routes. +Public and tailnet machine records must have independent address destinations; +UI aliases inherit their service's policy. Reuse Cloudflare DNS-01 with scoped +credentials; public endpoints use DNS-only public addresses and do not depend on +Tailscale. Domain/address values stay in private configuration. + +Use Caddy for public TLS and bridge proxying with native media authentication. +Decision, 2026-10-11: use Caddy caching for Jellyfin, via a pinned +`caddyserver/cache-handler` service image and tested filesystem storage. Start +with images, then prove protected video/range behavior; nginx slicing parity is +unverified. Cache hits must preserve authorization/revocation, and cache data +stays outside synchronized/snapshotted folders. The +[cache plan](../plan/JELLYFIN-CADDY-CACHE.md) owns implementation gates. See +[Jellyfin's proxy requirements](https://jellyfin.org/docs/general/post-install/networking/reverse-proxy/) +and [Caddy cache module status](https://caddyserver.com/docs/modules/http.handlers.cache). + +The original [File Browser](https://github.com/filebrowser/filebrowser) is +archived. [File browsing](file-browsing.md) records the shortlist, with Mikochi +as the selected shared implementation on clamps and joey; browser acceptance +is still pending. Keep any +persistent app state outside synchronized user files and serve declared folders. + +## Status + +Current code serves every declared Caddy UI with a tailnet restriction; it has +no generic public or mixed policy. Existing private behavior remains authoritative +until the versioned extension is implemented and tested. Sources and execution +gates: [inventory](../plan/DONBOT-INVENTORY.md), +[migration plan](../plan/DONBOT-MIGRATION.md), and +[private UI access](private-ui-access.md). diff --git a/ublue/design/secrets.md b/ublue/design/secrets.md new file mode 100644 index 00000000..9be938aa --- /dev/null +++ b/ublue/design/secrets.md @@ -0,0 +1,218 @@ +# Secret storage and delivery + +UI provisioning reads a mandatory Cloudflare Zone ID and optional relative +UI prefix from the selected environment in KeePassXC, fetches the zone domain, +and derives service names from the host's declarations, +and uses shared credential delivery. Cloudflare token issuance, DNS lifecycle, +and smoke VM cleanup are implemented. Staging ACME and HTTPS acceptance passed +on 2026-10-02; production renewal remains pending. +See the [generic UI plan](../plan/GENERIC-PRIVATE-UIS.md). + +Decision: use KeePassXC for durable secrets and read and write its KDBX database +directly from workstation Skillet through a Rust library. Pi-hole delivery uses +an existing entry or creates one for a fresh host. Tailscale OAuth credentials +stay on the workstation and mint short-lived, one-use enrollment keys. + +## Portable storage + +Planned disk recovery: production LUKS recovery keys use +`skillet/hosts//storage/root-recovery-key` and remain available off the +protected host. Root unlock uses the TPM, independently of vault access or +Skillet application-credential delivery. Disposable encryption tests generate +their own recovery material. Header backups require protected off-machine +storage; implementation and recovery checks are in the +[TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). + +KeePassXC is already the user's password manager, and Syncthing already moves +the encrypted database between machines. Reusing that database avoids a custom +vault format, export mechanism, or second authoritative store. Skillet defaults +to `$XDG_DATA_HOME/skillet/secrets.kdbx` (or +`$HOME/.local/share/skillet/secrets.kdbx`); a symlink may point at the existing +synced vault. Vault-dependent commands fail if that path is absent, with an +explicit `--database` override. Disposable VM provisioning needs no vault. +Skillet resolves symlinks before writing, saves a verified encrypted replacement +atomically, and retains an encrypted backup of the prior database. It rejects a +write when the database changed since opening. KeePassXC and Syncthing can +still write concurrently, so close KeePassXC before Skillet creates an entry +and resolve any Syncthing conflict copies before retrying. +Backups include the encrypted database, with recovery passwords and any key +files retained separately. Syncthing replication alone is not versioned backup. + +The workstation prompts for the database password once and caches it in a +named Linux session keyring for three hours (10,800 seconds), without +refreshing the expiry on reads. Skillet creates and joins this keyring itself, +and links it from the per-user keyring so it survives separate CLI invocations. +The password key remains in the session keyring, where the process has the +permissions needed to set its expiry. If the kernel denies expiry, Skillet +removes the new key and continues without a cache. `skillet secret lock` +removes a cached key early. A new workstation can unlock the copied database +without the old one's keyring. `skillet secret unlock` verifies the database and +establishes the same session cache without coupling unlock to provisioning. +The Cloudflare token creator is stored at `skillet/cloudflare/token-creator`. + +Vault paths are group paths plus an entry title. Single-value entries use the +Password field; structured entries declare their standard/custom fields in the +shared requirements metadata. +The prefix is consistently singular, `skillet`: +KeePassXC environment groups are named `prod` and `test`; the CLI's `production` +policy maps to the `prod` group. + +| Vault entry | Host credential and Podman secret name | +| --- | --- | +| `skillet/hosts/clamps/pihole/web-password` | `pihole_web_password` | +| `skillet/environments//dns/ui-domain` | Optional relative UI prefix; defaults to `ui`, appended to the fetched zone domain | +| `skillet/environments//dns/cloudflare-zone-id` | Environment-specific Cloudflare Zone ID lookup (`prod` or `test`) | +| `skillet/environments//hosts//cloudflare/acme-token` | Persistent `cloudflare_acme_token`, created on first Caddy delivery | +| `skillet/environments//hosts//cloudflare/ddns-dns-name` | Private relative record name consumed by the shared DDNS configuration template | +| `skillet/environments/prod/hosts//cloudflare/ddns-token` | Persistent child token, created on first DDNS delivery; distinct from ACME | +| `skillet/hosts//cloudflare/acme-token` | Legacy production credential migrated on first delivery | +| `skillet/cloudflare/token-creator` | Workstation only | +| `skillet/tailscale/provisioner-client-id` | Workstation only; OAuth Client ID | +| `skillet/tailscale/provisioner-client-secret` | Workstation only; OAuth Client secret | + +Host entries follow `skillet/hosts///`; shared credentials +follow `skillet//`. The Tailscale OAuth client is a workstation +master credential used to mint one-use host enrollment keys; it is never +delivered to a host. Exact lookups reject ambiguous entries and +entries with empty Password fields. A missing Pi-hole entry is generated only +after Skillet successfully opens the existing vault and confirms over SSH that +the host has neither an encrypted Pi-hole credential nor a Podman secret. If +the host has either one or its state cannot be checked, Skillet refuses to +create a replacement. Production values persist across workstation +changes; rotation is an explicit vault edit and redelivery. Normal host +re-apply can reuse credentials already installed on that host. + +## Configuration templates + +Secret auditing uses shared public requirement metadata to render the root +checklist and supply missing-entry instructions. Required manual entries follow +the selected canonical host capabilities and environment; template references +come from the delivery resolver, including host overrides. Generated, optional, +and planned entries are distinguished. A read-only inventory of entry paths +finds unused Skillet entries across all declared hosts and both environments, +retaining known migration paths. It never emits values, mutates the vault, or +uses provider APIs; it checks declared field presence/formats rather than provider +permissions or host recovery state. Existing entries default to Password-only +validation. Structured entries declare standard/custom fields and validators in +the shared metadata. `skillet/smtp` uses Username/Password and host/port/tls +and sender custom fields. Its shared fleet audit requires the entry for production +only. Encrypted delivery activates a dedicated native SMTP phase; disposable +machines receive a credential-free capture payload without consulting the entry. `secrets` is the canonical CLI group; `secret` remains an alias. + +Use the generic [configuration template design](configuration-templates.md) +when service configuration combines clear policy with KeePassXC values. The +DDNS template is the first implementation. + +## Delivery and consumption + +After signed boot and base readiness, the workstation sends only the target's +selected secret values through SSH stdin using its recorded/verified host key. +The host Rust command encrypts stdin with `systemd-creds` and atomically +installs root-owned mode `0600` files at +`/etc/credstore.encrypted/skillet/.cred`. +Keeping delivery after bootstrap avoids plaintext in Ignition and run artifacts. +Secret values never enter command arguments or diagnostics. + +Use host-key encryption for disposable VMs; current production delivery also +uses host-key encryption. Production can use TPM2 binding +after confirming hardware support and rebase behavior. Host-key encryption is +recoverable by someone with the guest disk and its key; these files are runtime +copies, while KeePassXC supplies portable recovery. + +The host-specific full-apply systemd unit loads its files with +`LoadCredentialEncrypted=`. +Skillet reads `$CREDENTIALS_DIRECTORY/` and sends the value through +stdin to `podman secret create`. Quadlets reference secret names with `Secret=`: +Pi-hole receives a file under `/run/secrets/` through `WEBPASSWORD_FILE`; Caddy +receives `cloudflare_acme_token` through `type=env,target=CF_API_TOKEN`. +The versioned `caddy_sites` credential contains host identity, selected UI +domain, staging policy, and the declared service/upstream list. The receiving +host checks it against its local declaration. The generated Caddyfile is +persisted under `/etc/skillet` and contains no token. A dedicated systemd apply +unit runs only after both Caddy credentials are installed. Old payloads are +rejected; redeliver after updating the environment entries. The first +production delivery migrates a legacy per-host token into the environment path. +Podman's default secret storage persists a copy on the guest disk; systemd's +credential encryption does not encrypt that copy. + +Optional DDNS uses the same encrypted delivery path and a separate apply unit. +The workstation renders the shared configuration template, resolves its typed +KeePassXC references, and combines it in memory with a zone-scoped child token. +The guest mounts the Podman secret as +`/config.json`, root-only. The master token is never delivered. Production +delivery and journaled disposable ownership/cleanup are implemented; live +disposable DNS acceptance awaits its test name entry. See +[DDNS design](cloudflare-ddns.md). + +## Disposable credentials + +The [Cloudflare lifecycle decision](cloudflare-ui-lifecycle.md) is implemented +for credential issuance, DNS reconciliation, and disposable cleanup. No manual +smoke VM ACME token is required. + +Smoke VMs get generated test passwords, never production host entries. Tailscale +enrollment reads `skillet/tailscale/provisioner-client-id` and +`skillet/tailscale/provisioner-client-secret` locally, then mints a one-use, +preauthorized, non-ephemeral key tagged for that smoke VM. The key expires in +one hour if unused and is delivered through the same SSH, systemd, and Podman +secret path as other credentials. The workstation verifies the VM's tailnet +addresses and saves its device ID and addresses in mode-0600 run metadata. On +destroy, Skillet uses `devices:core` to find and remove only the device with the +recorded name, smoke tag, and identity. If enrollment or deletion is +interrupted, pending metadata remains so cleanup can be retried. Production +uses the server tag and the same one-use-key path. + +Opt-in `test vm provision --with-ui` reads +`skillet/cloudflare/token-creator` locally and mints a 12-hour Cloudflare token +per disposable instance, restricted to the selected zone with Zone Read and DNS +Write. After Tailscale enrollment, it reconciles machine A/AAAA records for the +verified tailnet addresses, canonical UI CNAMEs, and declared alias CNAMEs. +The token supports Caddy DNS-01. DNS Write scopes access to the whole zone; +unique test names prevent record collisions, not access to other records. On +destroy, Skillet issues a short-lived cleanup token, removes only records with +the instance ownership marker, and revokes disposable tokens. Keep literal +zone names and records outside this public repository. The token creator +remains on the workstation. Staging ACME and HTTPS acceptance passed on +2026-10-02; production renewal and external access remain pending. + +VM Pi-hole secrets use the same SSH, systemd, and Podman delivery path. Keep token IDs, +expiry, and owned record IDs in run metadata, without token values. Existing +VM credentials survive re-apply and reboot. Expired tokens require explicit +renewal; cleanup and recovery belong to the [VM lifecycle](smoke-vms.md). + +Optional Datadog delivery resolves the shared `skillet/datadog/api-key` through +the templates. Site is public configuration, defaulting to `datadoghq.com`, +and has no vault entry. A single `datadog_config` +credential uses the existing encrypted SSH/systemd path and independently +activates the Agent phase. The Agent consumes native Podman environment secrets +for API key and tags; those values are never written into Quadlet `Environment=` +directives. Site uses a normal `Environment=DD_SITE` directive. Disposable +delivery is explicit and uses the shared key plus an authoritative +environment tag. VM destruction does not delete historical Datadog telemetry. + +References: [KeePassXC](https://keepassxc.org/docs/KeePassXC_GettingStarted), +[Rust KDBX reader](https://github.com/sseemayer/keepass-rs), +[Cloudflare token creation](https://developers.cloudflare.com/fundamentals/api/how-to/create-via-api/), +[systemd credentials](https://github.com/systemd/systemd/blob/main/docs/CREDENTIALS.md). + +Root recovery uses an independent printable high-entropy passphrase in Password, +with protected `luks-uuid` and `recovery-state` metadata. Save a pending entry +atomically before enrollment; verify against the same volume before marking it +verified. Retries reuse pending keys; verified keys are never silently re-enrolled +or replaced. Production paths are `skillet/hosts//storage/root-recovery-key`; +VM archives use `skillet/environments/test/hosts//instances//storage/root-recovery-key`. +VM disposal retains vault archives deliberately, so new disks require an explicit +archive removal or a fresh instance name. Keys travel only through verified SSH +stdin and a restricted guest `/run` file removed after use. This workflow requires +provisioning-time noninteractive sudo and precedes access finalization. + +LUKS headers are protected binary attachments named `root-luks.header` beside +their volume-bound recovery passphrase. The atomic KDBX writer reopens and +compares both passphrase and attachment before publishing. Plaintext header +capture stays in guest tmpfs and workstation memory; the portable backup is +the encrypted vault. Verified legacy entries without an attachment are upgraded +by the same recovery-key command. Re-running refreshes changed enrollment +metadata. Disk-clone acceptance retains separate source and replacement archives. + +Historical headers retain their keyslots, so encrypted vault backups remain +sensitive after a key is retired. diff --git a/ublue/design/skillet-architecture.md b/ublue/design/skillet-architecture.md new file mode 100644 index 00000000..e3e9d6cc --- /dev/null +++ b/ublue/design/skillet-architecture.md @@ -0,0 +1,84 @@ +# Skillet architecture refactoring + +Decision, 2026-10-03: complete the seven refactoring workstreams before further +service migrations, encryption implementation, or production cutover. Existing +service behavior and unfinished acceptance requirements remain authoritative. +Status: follow-up implementation and scripted interrupted-reboot recovery are +complete; remote CI and a Syncthing data ownership divergence found during +repeat application acceptance remain open. Rust-owned fixture smoke, retained +VM update, and the earlier capability-selected application acceptance passed +locally; see the +[follow-up plan](../plan/REFACTOR-FOLLOWUP.md) and +[acceptance log](../butane/ACCEPTANCE.md). Production acceptance remains +explicitly deferred. + +Implementation progress: `skillet_vm` now supplies validated run identity and +versioned manifests with explicit, non-destructive legacy import. Rust status +and disposal use a focused libvirt adapter and a locked cleanup orchestrator; +listing is also in Rust. Shared Cargo JSON artifact discovery selects the exact +reported binary and now serves the container runner and retained VM updates. +Updates share the ownership lock and SSH transport, preserve captured hashes, +and record deployed hashes only after verifying both installed artifacts. +Readiness now uses the same locked owner and SSH transport, with explicit +boot expectations from the canonical host profile, bounded probes and private +diagnostics. It verifies original artifacts and signed boot evidence before +recording readiness. A retry first clears the persisted Ready checkpoint and +restores it only after acceptance. Guest operations are guarded by ownership +checks before and after each call. Production and disposable-VM credential +delivery use the same verified guest transport and typed credential installer. +Workstation modules now own host credential delivery, UI/DNS derivation, +provider mutations, and recovery cleanup; the CLI opens the vault, loads the +canonical VM manifest, and dispatches typed operations. Disposable provisioning +holds the VM run lock and revalidates recorded backend ownership around each +guest operation through a shared checked transport. Transport's bounded I/O +stays in memory; payload copies are zeroized after stdin transfer. Earlier +fixture and provisioning acceptance passed. Rust now owns smoke reboot and +capability-selected application acceptance. The latter has passing injected +tests and a named-VM repeat-apply/reboot acceptance. Production acceptance +remains deferred. + +Workstream 2 adds `skillet_hosts` as the single host capability declaration. +It owns host composition and supplies boot, service, network, UI, storage, and +credential policy to entry points. The explicit `agent` baseline is the generic +no-service apply profile. Synthetic smoke fixture composition lives in a +separate test command and is not dispatched as a host identity. Workstation +Tailscale enrollment now accepts provider, profile/environment, VM identity, +state path, and guest transport as explicit inputs. Retained-VM +runtime acceptance remains pending. + +Workstream 3 implementation is complete. Account observations and +subordinate-ID files enter composition through injected resource interfaces; +missing or invalid subordinate ranges fail closed. File and directory +mutations share the typed named-or-numeric `Ownership` contract, including +numeric IDs that do not require a matching host account. Entry points pass +required systemd credentials to host composition. Host composition retains +aggregate effect interfaces during migration. The completion audit added +stricter Btrfs and service-state behavior to the fakes; those contract +corrections are tracked by the [follow-up plan](../plan/REFACTOR-FOLLOWUP.md). + +Keep application crates as reusable recipes. Separate canonical host profiles +and composition from guest runtime adapters, CLI parsing, workstation +provisioning, and synthetic test fixtures. Effects need injectable capability +boundaries covering both observations and mutations. Pure configuration and +composition use ordinary data and functions. This makes host tests independent +of the workstation and keeps one declaration authoritative across entry points. +Podman runtime identity, host data ownership, and namespace mappings remain +separate configuration concepts; see [Podman runtime configuration](podman-runtime.md). + +Move VM lifecycle orchestration from Bash into Rust. One orchestrator owns the +typed manifest, identity validation, readiness, recovery, and destruction, +including external-resource cleanup before deleting local recovery metadata. +Native and Flatpak execution remain supported. Initially invoke focused +`virsh`, SSH, and existing compiler tools through subprocess adapters; native +libvirt linkage would add requirements to the static musl build and Flatpak +workstations. Keep that choice behind an interface. Do not embed shell programs +in Rust or install workstation dependencies during the migration. + +Preserve command behavior and import existing run manifests before replacing +their format. Guest assertion scripts may remain standalone fixtures. Recording +is diagnostic; fast tests establish decisions and adapter contracts, while +named disposable VMs establish real service and reboot behavior. + +Implementation order and exit criteria: +[refactoring roadmap](../plan/SKILLET-REFACTOR.md). Existing runtime design: +[smoke VMs](smoke-vms.md), [secrets](secrets.md), and [storage](storage.md). diff --git a/ublue/design/smoke-vms.md b/ublue/design/smoke-vms.md new file mode 100644 index 00000000..37ad92f7 --- /dev/null +++ b/ublue/design/smoke-vms.md @@ -0,0 +1,194 @@ +# Disposable smoke VM lifecycle + +Decision: keep create, readiness, repeatable smoke checks, and destruction as +separate commands. Create includes a readiness barrier; the explicit ready +command supports retries. A retained VM supports inspection and repeated +convergence checks; a fresh VM establishes reproducible bootstrap. The fixture +tests real systemd/Podman convergence. Pi-hole credential delivery, +Tailscale enrollment/device cleanup, and opt-in Cloudflare/Caddy provisioning +are implemented. Live staging ACME/HTTPS acceptance passed on 2026-10-02; +production ACME and the deferred external-access checks remain unverified. + +## Identity and ownership + +The interface is `test-vm `; `list` may omit the host. +Host selects the Butane template and Skillet crate; instance identifies a run. +`clamps` and `smoke` become the internal domain `clamps-test-smoke`. +The ignored `butane/runs/-test-/` directory records domain UUID, +disk, backend, artifact hash, SSH port, temporary SSH key, and known host key. +Commands validate ownership before acting, so an arbitrary libvirt domain name +does not authorize its deletion. + +The libvirt runtime is shared across instances for a user/backend and recorded +in the manifest. Each concurrent VM has a distinct localhost SSH port. Native +libvirt and the existing virt-manager Flatpak support different workstation +OSes without installing host packages. The Flatpak runtime grants access to +the test artifacts and uses QEMU user networking. + +## Rust lifecycle ownership + +The lifecycle owner and migration order are recorded in +[the architecture decision](skillet-architecture.md) and +[the migration plan](../plan/REFACTOR-01-VM-LIFECYCLE.md). Public commands, +retained manifests, native/Flatpak support, and guest assertions are preserved. +`bin/test-vm` is a thin argument adapter. The separate `coreos-install` +lifecycle script and its hidden CLI bridge commands are retired. Rust selects +and prepares the recorded runtime, builds guest artifacts, resolves/downloads +the FCOS image, prepares and stages the run, invokes the standalone Butane +compiler, defines/starts the guest, and runs readiness. Updates build +both binaries, use verified recorded SSH trust, and save deployed hashes after +checking installed bytes and metadata. Original capture evidence is retained. +Disposal validates identity, +cleans external resources, then removes the UUID-addressed domain and local +artifacts. It also works with an already-absent guest; connection failures and +partial cleanup preserve journals for retry. Per-run locks protect individual +mutations, and a create-level lock serializes the full artifact, compilation, +and boot sequence. Readiness uses the selected profile's explicit +boot expectations and captured binaries, and records Ready only after signed +boot, unit, SELinux, resolver and user-environment checks. Bounded probes and +private diagnostics allow failed checks to be retried. It first persists +Started to invalidate stale readiness, then checks ownership before and after +each guest command/upload. Creation persists a private `Preparing` intent and +UUID first. Rust atomically prepares the disk and SSH key, stages host templates, +includes and binaries, applies VM-only Ignition changes, and records captured +hashes and compatibility metadata. The standalone Butane compiler still builds +Ignition. The generated UUID is part of native XML and Flatpak creation. Native +definition and start are separate manifest phases; failed starts retain the +defined domain for inspection and retry. Native domain XML renders through the +Rust XML library, preserving disk ownership, fw_cfg and passt networking. Unit +and adapter checks pass. Rust now owns fixture smoke delivery, guarded guest +operations, reboot detection, and readiness recovery. The named fixture smoke, +retained update, and capability-selected application repeat-apply/reboot +acceptance passed on 2026-10-05. Interrupted-reboot fault injection remains +pending in the [follow-up plan](../plan/REFACTOR-FOLLOWUP.md) and +[acceptance log](../butane/ACCEPTANCE.md). + +Tailscale enrollment intent, enrolled-device identity, and Cloudflare DNS/token +ownership metadata are persisted by `skillet_workstation::provisioning_state`. +Writes are atomic and mode `0600`; readers reject symlinks and non-regular +files. `skillet_workstation::tailscale_enrollment` owns enrollment sequencing: +it persists intent before provider or guest mutations, treats a failed guest +probe as an error, and removes intent only after the tagged device identity is +recorded. An existing pending marker must match the same VM identity, so retry +cannot silently claim another VM's cleanup journal. +New journals keep host, environment, and instance as separate fields. Legacy +smoke-run records remain readable and are checked against the exact expected +guest hostname, ownership marker, and token name rather than parsed into a new +identity. + +## States and recovery + +Planned encrypted-root tests add a software TPM2 and UEFI/Secure Boot profile. +Each VM owns independent TPM state and firmware NVRAM alongside its disk; +reboots preserve them and disposal removes only owned state. Software TPM +checks establish provisioning behavior; physical hardware acceptance remains +required. See the [TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). + +1. **Create:** build the generic CLI and host binary, allocate the run directory, + and persist a `Preparing` manifest before local artifact changes. The Rust VM + library atomically copies the selected image, records its digest, and creates + or validates the run's Ed25519 SSH key pair. It stages and specializes the + host Butane source, shared includes, and binaries, and records their hashes. + The standalone Butane compiler builds Ignition from that staged tree. + Ignition contains the public SSH key and base configuration. Large binaries travel over SSH because embedding them + in QEMU `fw_cfg` previously caused long boot delays. +2. **Ready:** wait for SSH, deliver both binaries, complete unsigned then signed + rebase, and check the booted deployment, base apply, resolver, SELinux, and + user environment. Persistent bootstrap state tolerates reboots. Executable + staging uses `/var/usrlocal/bin`, whose FCOS labeling permits execution. + Ignition's authorized-key file remains configured alongside dotfiles keys. + `/etc/skillet/host` keeps host profile identity independent from a VM's + hostname. `ready` uses the artifacts captured at creation. Explicit `update` + builds and installs current shared and host code on a retained VM, recording deployed hashes + separately while preserving the original creation snapshot. +3. **Provision applications:** `test vm provision` unlocks the workstation + KeePassXC database and supplies the Tailscale OAuth client, test policy, + VM identity, and verified guest transport to + `skillet_workstation::tailscale_enrollment`. That workflow reuses an existing + enrollment or mints and delivers a one-use smoke-tagged key through the + [shared secret mechanism](secrets.md), waits for the VM to join, and records + its device identity. The CLI then delivers a generated Pi-hole credential + and runs full apply. Repeated provisioning + reuses the Tailscale identity and installed Pi-hole credential; `--rotate` + replaces the latter. `--with-ui` also creates a 12-hour zone-scoped + Cloudflare token, reconciles tailnet A/AAAA and UI/alias CNAME records, + installs both Caddy credentials, and activates the Caddy apply unit. The + ignored mode-0600 Cloudflare metadata records its token ID, expiry, marker, + and DNS record IDs, never the token value. +4. **Smoke:** run assertions against the retained VM. Check repeat apply, + configuration/secret changes, interruption recovery, and reboot persistence. + The container sandbox uses mocked systemctl/Podman; only VM checks establish + real runtime behavior. Failures retain the VM and diagnostics for repair. +5. **Destroy:** validate ownership, mint a short-lived scoped cleanup token, + remove only DNS records carrying this instance's ownership marker, revoke + its disposable tokens, remove the recorded smoke-tagged Tailscale device, + then remove the domain, disk, SSH key, and artifacts. Failed external + cleanup prevents local destruction and retains metadata for retry, even if + the domain is already gone. + +## Live ACME boundary + +The [Cloudflare ownership lifecycle](cloudflare-ui-lifecycle.md) is implemented +for token issuance and DNS cleanup after expiry. Live ACME acceptance remains +pending; do not require a manually created VM token. + +Routine smoke checks need no Cloudflare access. The optional live scenario +enrolls the VM in Tailscale, obtains its tailnet IP, and creates a DNS-only +machine A/AAAA record set and UI/alias CNAMEs under the existing smoke-test +Cloudflare zone using a per-VM +token minted by the workstation token creator. It uses Let's Encrypt staging +and a unique hostname, preserving the zone's other records. Keep the literal +zone outside this public repository. DNS-01 needs no publicly reachable VM. +Staging certificates are untrusted; the test verifies their expected identity +using explicit staging trust. Test name resolution through a client's actual +resolver because some resolvers block public answers containing tailnet IPs. +Normal reruns reuse credentials and certificate state; fresh issuance is an +explicit scenario. Production DNS cutover is separate from VM acceptance. + +Acceptance requires a fresh create, readiness, application checks, unchanged +second apply, and reboot persistence. Destruction must also demonstrate that +external credentials and test-owned resources are cleaned up. An interrupted +run must either resume or leave enough ownership metadata for cleanup. + +## Local UI inspection + +Implemented: `test vm ssh --forward LOCAL:GUEST` exposes a guest localhost +TCP endpoint only on workstation localhost. The shared SSH adapter owns the +forward for the interactive session; the existing lifecycle holds its run lock +and checks domain ownership before and after that session. Bind failures are +fatal and duplicate local ports are refused. This allows browser inspection +without publishing a guest application port on LAN or tailnet. A forward is +not a network isolation policy; restored production controllers still require +a persistent guest fence before import. + +## Optional encrypted root + +Each manifest records a root profile; absent fields in retained manifests mean +`unencrypted`. Explicit `tpm` requires UEFI Secure Boot with enrolled keys and +an emulated TPM2 SHA256 bank. Native and Flatpak definitions share the same +XML policy for this profile; Flatpak live equivalence remains unverified. +TPM state, NVRAM, serial diagnostics and recovery artifacts belong to the named +run. Lifecycle ownership verifies the firmware/TPM paths in addition to UUID +and disk. Cold starts preserve them; disposal removes them. Independent runs +never share TPM or firmware state. + +`encryption-check` uses the same lock and guarded transport as other guest +operations. The independent recovery key is persisted before guest enrollment +so interruption is retryable. Test secrets are private ignored files, never +vault production entries, command arguments or output. A cold power cycle +checks unattended unlock and Btrfs data persistence. This validates guest +functionality, not security against a compromised hypervisor. + +The optional `recovery-key` command archives an independent printable passphrase +in an instance-scoped vault entry through the same lock and guarded SSH transport. +It never writes production entries. Archives survive disposal; reusing an instance +name for a new volume fails until its old entry is explicitly removed. This is +separate from `encryption-check`'s private disposable two-slot acceptance fixture. + +Native TPM-loss acceptance is an explicit test driver using the shared manifest, +locks, backend and guarded transport. It clones only a stopped base-only +encrypted VM, records ancestry before mutation, and defines independent TPM +and NVRAM state. It reuses the cloned guest SSH identity with the already-known +server key at the new local port. The recovery console is unlogged. Source +firmware/TPM and vault archives remain intact; normal disposal owns each VM's +artifacts. A failed recovery retains manifests and ancestry for inspection. diff --git a/ublue/design/storage.md b/ublue/design/storage.md new file mode 100644 index 00000000..1787fce9 --- /dev/null +++ b/ublue/design/storage.md @@ -0,0 +1,149 @@ +# Shared uCore persistent storage + +Decision: use the Btrfs filesystem backing uCore's writable `/var` and +`/sysroot`, with a `data` subvolume mounted at `/var/lib/data`. This follows +boxy's shared-space OS/data layout while preserving uCore's deployment layout. +This layout is shared by all uCore hosts. On the disposable clamps VM, `/` is a +composefs overlay and `/var` is on the Btrfs root partition; an `/os` root +subvolume must not be copied from boxy. The physical disk for clamps is still +undecided. + +`/var/lib/data` is the host's canonical service-state root. Create child +subvolumes only for independently managed data: initially `pihole`, +`syncthing`, and `containers`. Pi-hole's container `/etc/pihole` maps to +`/var/lib/data/pihole/etc`; any retained dnsmasq configuration and logs live +under that Pi-hole tree. Rootful Podman's graphroot is +`/var/lib/data/containers/system` on fresh installs, with runroot under +`/run/containers/storage`. Chef set `driver=btrfs` for rupik and boxy; uCore +currently uses overlay, so the shared config keeps overlay while moving the +graphroot. Its SELinux file-context equivalence matches the former graphroot. +Rootless storage is added only for a host that needs rootless containers. + +XDG base directories describe user-specific files, so they do not improve the +location of system service state. `/srv` suits site data served directly to +clients; Pi-hole's database and Podman's image layers are internal state. +`/var/lib/data` also matches boxy's and calculon's existing convention. + +The shared `butane/includes/data-storage.bu` include creates the top-level +`data` subvolume on the Btrfs filesystem backing `/var`, mounts it, prepares +the `containers` subvolume, and configures rootful Podman. It verifies the filesystem UUID backing `/var` and mounts the filesystem +labelled `root`, whether it resides on a plain partition or a LUKS mapper. Each host's Butane template must merge +the include; clamps currently does. Host Skillet creates application +subvolumes and directories. Existing populated Podman stores require an +explicit migration; changing an installed host's graphroot is not automatic. +Skillet must not format a populated disk. +Skillet, managed application services, and Podman auto-update require the +mounted data subvolume and fail when it is absent, preventing their writes to +a fallback directory. A manually invoked Podman command does not have this +unit dependency and must only run after validating the mount. +Btrfs snapshots do not include nested subvolume contents, so backup policy +must list service subvolumes explicitly. Snapshot source subvolumes are leaf +subvolumes: nested subvolumes inside them are unsupported. Syncthing follows +this rule by design. The `containers` subvolume can then be omitted from +service-data snapshots. A shared filesystem shares free space and physical +failure; snapshots are not independent backups. + +## Optional native encryption + +Decision, 2026-10-02: add LUKS2 beneath the shared Btrfs backing filesystem +and prefer local TPM2 unlock on both local and remote physical hosts. Normal +boots must not need a network or operator. This protects persistent service +state, including Podman's disk copies of secrets, while retaining the existing +subvolume layout. Tang is deferred; assess each remote host's TPM first. +EFI and `/boot` remain outside encryption. An explicit boot-integrity policy +and update/rollback compatibility must be proved before claiming protection +against theft of the whole machine. Keep an independent recovery key in the +private vault and a protected off-machine LUKS header backup. +Decision, 2026-10-10: the first profile uses native Ignition/Clevis TPM2 with +SHA256 PCR 7 and requires Secure Boot. Its accepted protection scope is removal +or loss of the encrypted disk; separate GRUB kernel/initramfs files do not +provide authenticated whole-machine boot. Custom UKIs and signed PCR policies +are deferred. No network service participates in unlock. + +Implemented for disposable installs: `unencrypted` remains the default; +explicit `tpm` selects LUKS2 beneath Btrfs and fails when prerequisites are +missing. There is no automatic downgrade. Profile selection is independent of +host identity. Filesystem UUID/label checks support both layouts. TPM enrollment +occurs during native Ignition installation; the same firmware policy must +survive the signed-uCore bootstrap. Changing Secure Boot/MOK policy requires a +working recovery path before enrollment changes. On physical hardware, enroll +any required image signing/MOK keys before the first encrypted Ignition boot; +QEMU success with its enrolled firmware keys does not replace that procedure. + +Disposable acceptance creates an independent random recovery key and header +backup under the private run directory, then checks recovery without TPM and +cold-boots through the lifecycle owner. Only TPM and recovery keyslots are +accepted. These artifacts are deleted on disposal. Production vault enrollment +is implemented with write-ahead persistence and volume identity checks. +Live VM vault enrollment/retry and cold-boot persistence passed on 2026-10-10; +invalid-passphrase rejection confirms the independent passphrase path. +Header export is implemented: the recovery-key command archives a verified +LUKS2 header as the protected `root-luks.header` attachment on the same KDBX +entry. It refreshes after enrollment changes without rotating the passphrase. +The encrypted vault, backed up independently, carries both recovery artifacts. +A header backup is not a data backup. Physical-disk selection and physical +recovery acceptance remain planned. Skillet convergence never formats an installed filesystem. +QEMU functional tests and physical acceptance are specified in the +[TPM encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md). + +The clamps VM passed fresh provisioning, mount, SELinux, reboot, repeat apply, +and unavailable/wrong-mount recovery. A post-provision OS rebase and the +physical disk choice remain open in +[the storage plan](../plan/CLAMPS-STORAGE-CREDENTIALS.md). + +References: [XDG base directories](https://specifications.freedesktop.org/basedir/), +[FHS `/srv`](https://specifications.freedesktop.org/fhs/latest/srv.html), +[Btrfs subvolumes](https://btrfs.readthedocs.io/en/stable/btrfs-subvolume.html). + + +## TPM capability audit (2026-10-10) + +The retained signed-uCore VM has Clevis 21, cryptsetup 2.8.8, systemd 259.8 +with TPM2 support, and TPM2 tools. Its deployed initramfs contains Clevis TPM2, +cryptsetup/systemd TPM token support and Btrfs. No extra package installation is +currently justified for native Clevis unlock; actual encrypted-boot acceptance +must also check the initial FCOS image and each subsequent deployment. + +The native Rocky test backend has KVM, swtpm and q35 OVMF Secure Boot firmware +with enrolled-key NVRAM templates. The workstation exposes no physical TPM; +virtual-TPM tests do not require one. Existing smoke domains boot BIOS without +TPM. New encrypted tests must explicitly select UEFI/Secure Boot, independent +TPM2 state and owned persistent NVRAM. Firmware capability is not boot acceptance. + +Current boot entries use separate kernel/initramfs files under GRUB. Neither +ukify nor systemd-measure was found on the deployed guest; no signed PCR-policy +pipeline exists in our image recipes. PCR 7 binds Secure Boot policy, not all +kernel/initramfs/command-line bytes. TPM-only unlock must not be described as +protection against whole-machine theft or tampering. Signed-policy boot integrity +requires a separate implemented update/rollback chain; adding packages alone +would not establish it. The operator accepted the narrower native profile above; stronger boot integrity +is a separate future project. Virtual acceptance does not establish physical +firmware compatibility. + +Sources: [FCOS encrypted root](https://github.com/coreos/fedora-coreos-docs/blob/main/modules/ROOT/pages/storage.adoc#encrypted-storage-luks), +[Clevis TPM threat model](https://github.com/latchset/clevis/blob/master/src/pins/tpm2/clevis-encrypt-tpm2.1.adoc#threat-model), +[systemd PCR policies](https://github.com/systemd/systemd/blob/main/man/systemd-cryptenroll.xml). + +Recovery acceptance uses a stopped, base-only disk clone with independent TPM +and NVRAM. It preserves the source TPM and archive; provider-provisioned VMs +are excluded to prevent impersonation. Recovery consoles have no serial log. +New TPM enrollment is explicit, verifies the new binding before removing the +old one, and archives the refreshed clone header separately. Routine convergence +never replaces TPM bindings. Native header restoration, TPM-loss recovery/re-enrollment, completed retry and +unattended cold boot passed on 2026-10-11; evidence is in Butane's record. + +## Physical installation media + +Physical installation uses an explicit destination whole-disk by-id path and +reuses the VM-tested root-profile transform. Workstation media generation never +opens a block device. A separate live helper checks the actual server and asks +for disk confirmation before installation; TPM media requires the final Secure +Boot/MOK policy before first encrypted boot. VM staging retains `/dev/vda`. +The live ISO contains a small bootstrap configuration, operator public key and +expected destination-Ignition digest. CoreOS's 256 KiB embedding limit excludes +our application binaries, so the full destination payload is transferred over +verified SSH (or separate data media), then checked in root-only live tmpfs. +Production media requires private bootstrap-access inputs instead of inheriting +committed keys/password hashes. USB media generation is implemented; physical +boot and installation acceptance remain unverified. See the +[hardware runbook](../butane/HARDWARE.md). diff --git a/ublue/design/syncthing.md b/ublue/design/syncthing.md new file mode 100644 index 00000000..0d478d53 --- /dev/null +++ b/ublue/design/syncthing.md @@ -0,0 +1,22 @@ +# Syncthing service + +Run Syncthing as its own Quadlet container on the host's declared bridge, with +`ContainerName=syncthing`. Caddy can reach its UI at +`http://syncthing:8384`. The GUI port is not published on the host; +peer-transfer ports remain published. Tailnet-restricted Caddy provides UI access. See the [private UI design](private-ui-access.md). + +Mount `/var/lib/data/syncthing` at `/var/syncthing`. Keep the upstream +directory layout there, including `config/` and synced folders, so a planned +state transfer can retain the device identity and folder definitions. The +directory is a Btrfs subvolume owned by the declared host account. Syncthing +runs as container UID/GID 1000; Podman's user namespace maps that container +identity to the host account and its primary group. Keep host ownership, +container identity, and namespace mapping explicit and separate. Never copy +a source server's live identity into a disposable VM. + +The bridged container can limit LAN discovery compared with host networking. +This follows the existing Chef port-published container and keeps service-name +DNS available; validate real peer connectivity before cutover. + +References: [official Syncthing container guidance](https://github.com/syncthing/syncthing/blob/main/README-Docker.md), +[shared storage design](storage.md), [Pi-hole network design](pihole-network.md). diff --git a/ublue/design/tailnet-ui-authentication.md b/ublue/design/tailnet-ui-authentication.md new file mode 100644 index 00000000..d7cdb288 --- /dev/null +++ b/ublue/design/tailnet-ui-authentication.md @@ -0,0 +1,69 @@ +# Tailnet identity for private web UIs + +Decision, 2026-10-11: file browsing must not ask for another username/password. +Reuse one caller-selected Caddy identity policy for file browsing and Gokapi +administration, allowing the two intended human users. Private policy values +and application-account mappings are delivered privately, never compiled into +source. An address-range gate alone supplies no user identity. + +Authenticate the actual connection through the existing host-network tailscaled +LocalAPI WhoIs. Require a user-owned device and an explicitly allowed complete +user identity; reject anonymous/unknown/tagged sources and lookup failures. +Private authenticated hostnames resolve to tailnet addresses: a browser using +the public IP does not supply a tailnet identity merely because Tailscale is +running. For Gokapi, prefer a private admin hostname and a public sharing hostname +if the selected app version supports their URL/origin behavior. Public routes +bypass the identity gate and never receive an administrative header. +Authorize before proxying every protected request. Never use browser-supplied +forwarded or identity headers to identify the user. Preserve the actual peer +through Podman ingress and overwrite/strip identity headers at Caddy. + +Gokapi supports [proxy header authentication](https://gokapi.readthedocs.io/en/latest/setup.html). +Map verified identity to existing application accounts with explicitly chosen +roles; permit no arbitrary account creation. Public sharing requests carry no +administrative identity. For a file UI supporting disabled authentication, +disable its internal login only behind the verified gate and a backend confined +to the proxy path. No UI host port or broadly reachable backend is allowed. +Use a private backend network accessible only through the declared proxy path; +verify isolation from other application networks. Omitted host ports alone do +not establish that boundary. No app password is required for file browsing. +App-native passwords are not the chosen user flow for these services. + +Implementation: Caddy's built-in `forward_auth` calls the Skillet Rust gate on +its private Unix socket. The gate calls only LocalAPI WhoIs and never caches +identity decisions. The full daemon socket is available to the root-owned gate, +not to Caddy or application containers. The reviewed gate is therefore trusted +with daemon access; a read-only socket mount would not restrict API operations. +This avoids the experimental Caddy plugin and a custom Caddy image. Policy is +part of the encrypted version 3 Caddy payload and converges to a root-only host +file. `skillet/tailscale/ui-users` supplies complete LoginName values separated +by commas or newlines. Both environments share that allowlist; explicit caller +UI declarations select which routes use it. Missing/invalid input fails delivery; +a missing or malformed host policy denies access. + +Mikochi runs on a dedicated backend bridge with explicit Netavark strict +isolation. Only it and Caddy join that bridge; Caddy has IP forwarding disabled. +Do not set `Internal=true`: Netavark skips the firewall rules for internal +networks, which allowed direct access in the dual-stack acceptance probe. +The selected bridge permits outbound traffic but blocks forwarded ingress from +other networks. Host root remains trusted; no backend port is published. +Strict isolation is supported by the guest's Netavark 1.17.2, independently of +Podman 6's newer default. Verify the actual bridge boundary during acceptance. +Cross-origin browser requests are rejected before authentication/proxying. + +Using the Tailscale client LocalAPI with Headscale is the intended portable +approach, but compatibility, returned identity and authorization must be tested. +This is an inference from the client-side integration, not accepted Headscale +support. Hosted Tailscale workstation enrollment remains separate. Do not match +only the username before `@`, infer people from tags or equate a tagged test +server with an authorized human client. + +Status: implemented for Mikochi on clamps and joey; Gokapi remains planned. +No-vault tests cover both allowed users, other/tagged/mismatched identities, +forged headers, daemon failure, IPv4/IPv6 and immediate host-policy revocation. +Live LocalAPI authorization, repeat apply and IPv4/IPv6 backend readiness and +sibling-network isolation passed. End-to-end HTTPS source preservation, both +human users, denied users, revocation/outage and file operations remain pending; +see [the acceptance record](../butane/ACCEPTANCE.md) for external ACME and +connectivity failures. CI file operations do not establish browser acceptance. +See [donbot migration](../plan/DONBOT-MIGRATION.md). diff --git a/ublue/design/tailscale.md b/ublue/design/tailscale.md new file mode 100644 index 00000000..cf454fe8 --- /dev/null +++ b/ublue/design/tailscale.md @@ -0,0 +1,34 @@ +# Tailscale host routing + +Run Tailscale rootfully with host networking and persistent state. The canonical +host caller selects `advertise_exit_node`; clamps and joey enable it. Production delivery +resolves that declaration into a versioned encrypted enrollment payload. Disposable +delivery always selects false. Never infer environment from a runtime hostname. +Only the decoded enrollment key becomes the Podman secret; policy stays in clear +managed Quadlet configuration. No new vault entries or OAuth scopes are required. + +Keep `TS_AUTH_ONCE=true` to preserve one-use enrollment. Extra startup arguments +alone cannot converge preferences on already authenticated nodes. A native +`tailscale set --advertise-exit-node=` runs after the container's status +healthcheck satisfies Quadlet `Notify=healthy`, including every service startup. +Explicit false withdraws a retained advertisement. No shell program is embedded. +The existing host baseline already enables IPv4 and IPv6 forwarding. + +Retained raw-key credentials remain readable and default to no advertisement. +Redeliver production Tailscale credentials to enable caller-selected routing; +new disposable credentials explicitly disable it. Invalid versioned payloads +fail without exposing values. Host composition rejects advertisements its caller +did not select. + +Tailnet exit-node approval remains operator policy; this change does not edit +access controls or make clients select an exit node. Local policy, compatibility, +rendering and Quadlet compilation are checked. Live routing exercises are omitted +by operator decision; they are not an additional acceptance gate for this slice. + +References: [exit-node policy](https://tailscale.com/docs/features/exit-nodes), +[container startup](https://github.com/tailscale/tailscale/blob/main/cmd/containerboot/tailscaled.go), +[Quadlet readiness](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#notify-defaults-to-false). + +Boxy's LAN subnet advertisement, route acceptance and disabled subnet SNAT are +not supplied by this exit-node switch. Shared private routing policy is the next +[joey slice](../plan/JOEY-MIGRATION.md); no LAN routes are guessed. diff --git a/ublue/design/unifi.md b/ublue/design/unifi.md new file mode 100644 index 00000000..ef3968c8 --- /dev/null +++ b/ublue/design/unifi.md @@ -0,0 +1,26 @@ +# UniFi controller + +Run the self-hosted UniFi Network application as a rootful Podman container +with host networking, required for device discovery/adoption, and persistent +data under `/var/lib/data/unifi`. Preserve the current controller state when +migrating; ordinary Skillet apply must not overwrite or import a database. +The container runs as image user `unifi` (UID/GID 999). Skillet sets only the +declared bind-mount root to those numeric IDs and preserves ownership of +existing descendants; the host does not need matching account names. The +ownership and mode are typed volume inputs, so generic Podman code does not +infer identities from container configuration or recursively alter data. + +Use UniFi Site Manager at `unifi.ui.com` for remote access. The cloud service +brokers access to the local controller; configuration and device management +remain on the server. Do not add UniFi to Caddy's private UI routes. Keep the +controller's local HTTPS management endpoint available on the host network. + +Rupik is ARM64 and runs UniFi 10.0.162; clamps is x86_64. Validate a supported +application backup restore across architectures in an isolated VM before +production migration. The restored test copy must be fenced from production devices and cloud +services, including across reboot; NAT is not sufficient isolation. Validate +configuration locally. Site Manager and device connectivity belong to cutover +acceptance, when the original controller is stopped. Retain the old controller +and an independent backup until clamps manages the devices successfully. + +See the [implementation plan](../plan/CLAMPS-UNIFI.md). diff --git a/ublue/design/ups.md b/ublue/design/ups.md new file mode 100644 index 00000000..866e1e53 --- /dev/null +++ b/ublue/design/ups.md @@ -0,0 +1,44 @@ +# Local UPS protection + +Decision, 2026-10-12: install Fedora `nut` and `nut-client` in the shared +uCore image, with their services/enumerator disabled by default. An optional +shared Skillet module owns `/etc/ups`, USB selection and native service +activation. Clamps selects the CyberPower CP1500 AVR (`0764:0501`) with +`usbhid-ups`; other host callers must explicitly opt in. + +Use `MODE=standalone`, with `upsd` listening only on IPv4/IPv6 loopback. +A generated, retained host-local monitor password grants only `upsmon primary`. +It needs no vault entry or operator login; root-only state and root:nut +configuration retain it through rebases and interrupted apply. Never silently +replace malformed retained credentials. This is local service authentication, +not a portable operator credential. + +Use the packaged driver/server/monitor and late shutdown hook. `upsmon` powers +off the host on critical battery state, not on every brief outage. Its power-down +flag lives under `/run/nut` so an old flag cannot persist across reboots. Actual +UPS turn-off delays and firmware restart-after-power-loss need hardware acceptance. + +The generic USB resource inspects sysfs and refreshes scoped udev rules. +A matching USB device gets a stable alias and starts the monitor dependency +chain on hotplug. Unit conditions skip absent hardware; configuration still +converges on ordinary smoke VMs. Losing the cable must not stop an already +running monitor: its last known on-battery state can require protective shutdown. +Datadog unit monitoring is selected only when the UPS is attached at agent apply. +When both NUT and Datadog are selected, the successful Datadog phase also +converges a 30-second metrics timer. Its service requires the device alias and +Agent socket to exist, and only orders startup after existing consumers. +Hardware absence skips collection; hotplug needs no credential redelivery to +start metric polling. Ignition delivers the shared musl guest binary at +`/var/usrlocal/bin/skillet`; verified VM delivery updates that same path. The +collector travels with this binary, independently of the image packages. It +reads NUT directly and sends +finite gauges/status checks to local DogStatsD, without Python, `upsc`, another +image or additional credentials. Native-unit inventory still needs reapply after +attachment. See [monitoring composition](monitoring.md). +Removing the declaration leaves existing resources unmanaged; explicit removal +is required to disable previously configured protection. + +Validate configuration with real Fedora NUT tools and a simulated driver whose +shutdown command is harmless. USB polling, real battery shutdown and UPS load +cycling remain physical acceptance; never invoke forced shutdown during routine +smoke tests. See [acceptance](../butane/ACCEPTANCE.md). diff --git a/ublue/plan/CLAMPS-BASELINE-AUDIT.md b/ublue/plan/CLAMPS-BASELINE-AUDIT.md new file mode 100644 index 00000000..546cc6ce --- /dev/null +++ b/ublue/plan/CLAMPS-BASELINE-AUDIT.md @@ -0,0 +1,51 @@ +# Clamps baseline audit + +Status: audited 2026-10-09. Subsequent operator decisions accept UID 1001 +and require passwordless provisioning followed by explicit password-required +sudo and first-login password change; see `../design/administrative-access.md`. +The original audit made no configuration changes. Compare the public Rupik +Chef role and pinned cookbook sources with Butane, Skillet, uCore image inputs +and read-only observations on the retained owned `clamps-test-smoke` VM. +Private Chef attributes and physical Clamps access remain outside this evidence. + +## Results + +| Area | Result | Remaining decision or check | +| --- | --- | --- | +| SSH | Server syntax and client configuration parse passed. Key authentication, password/interactive authentication disabled, key-only root access, TCP forwarding and internal SFTP match the Chef role. Server enabled and active; Ignition keys supported; home/SSH permissions accepted. Image and Skillet SSH files are identical. | Physical LAN/tailnet access and final key inventory. No new SSH defect found. | +| sudo | Syntax passed. uCore supplies `%sudo ALL=(ALL) NOPASSWD: ALL`; Giacomo belongs to this group. VM also has its own passwordless rule. | Operator selected temporary passwordless provisioning, then explicit access finalization requiring a sudo password and first-login password change. Live production login acceptance remains pending. | +| Eternal Terminal | Package installed, unit enabled and active, listening on port 2022 for IPv4 and IPv6. Chef supplied no custom ET configuration. | End-to-end ET client and physical firewall reachability remain unverified. | +| User identity | Core is UID/GID 1000; Giacomo is 1001 with Bash and wheel/sudo/docker membership. Ignition has five baseline keys; the live user authorized_keys has seven, plus the VM injection in the Ignition file. | UID 1001 accepted by the operator; account for existing data ownership. Verify final production keys without assuming all keys come from Ignition. Dario is not enabled by the public Rupik role; do not add it automatically. | +| Bootstrap password | Production Butane retains a fixed bootstrap password hash; automatic first-boot expiry was replaced with explicit finalization. SSH password login remains disabled. | Review the committed bootstrap hash and production console/password-change policy before cutover. Never reproduce the hash in audit records. VM staging removes forced expiry, so VM success does not accept that flow. | +| Homebrew/dotfiles | Both oneshots completed successfully, markers exist and Brew is installed. Current bootstrap runs the dotfiles installer with `-b core -V`. | `-V` skips Vim plugins. Their installation remains deferred by the operator's earlier choice. Existing parity text saying the entire install hook was deferred was stale. | +| OS hardening | SSH/sysctl convergence implemented; image copies identical. IPv4/IPv6 forwarding enabled, SELinux enforcing, auditd enabled and active. | Skillet's OS-hardening function remains a placeholder. Broader Chef policy has not been reconciled with uCore defaults; see below. | +| Wake-on-LAN | Common image and VM have Fedora `wol`. | Chef generated per-target shortcuts, but its public target map is empty. Obtain private target inventory only if those shortcuts are required; actual wake-up is unverified. | + +## Hardening reconciliation needed + +The pinned Chef OS cookbook manages login/password policy, limits, PAM, +profile/umask policy, securetty, audit configuration, filesystem module +blacklists and selected access permissions. Their equivalence on uCore has +not been established by the SSH/sysctl implementation. + +Observed login defaults are maximum/minimum/warning days `99999/0/7`, while +Chef's server role declares `730/-1/30`. Chef-specific limits, profile and module +blacklist files are absent. This establishes a policy difference; it does not +establish that Fedora lacks equivalent protections elsewhere. Auditd and SELinux +already run. Do not copy obsolete distribution-specific Chef rules wholesale. + +## Follow-up order and exit criteria + +1. UID 1001 is accepted. Post-provisioning password-required sudo and one-time + expiry are implemented through explicit access finalization. Verify production + keys and accept the interactive password-change/login flow on a fresh install + without VM-specific account overrides. Preserve working administrative access. +2. Reconcile each Chef OS policy with the current uCore baseline. Implement only + retained requirements through the correct image/runtime owner, or document the + deliberate replacement. Remove the misleading placeholder status only after + requirements are accounted for; validate syntax, repeat apply and effective state. +3. Keep Vim plugin installation deferred unless explicitly resumed. Confirm WOL + targets privately if needed. Complete physical SSH/ET/firewall access at cutover. + +Evidence: `../butane/ACCEPTANCE.md`. The later access-policy implementation does not finalize any existing VM or +production account. Physical login/password-change acceptance remains pending. diff --git a/ublue/plan/CLAMPS-BTRBK.md b/ublue/plan/CLAMPS-BTRBK.md new file mode 100644 index 00000000..3eca218c --- /dev/null +++ b/ublue/plan/CLAMPS-BTRBK.md @@ -0,0 +1,101 @@ +# Next milestone: btrbk snapshots on clamps + +Status: implemented and verified on the retained disposable clamps VM. Scope is +local snapshot parity for rupik; remote backup and Pi-hole snapshot policy are +separate work. +The [snapshot design](../design/btrbk.md) records the chosen scope and why. + +## Starting point + +- Rupik's [Chef recipe](../../site-cookbooks/rupik/recipes/btrbk.rb) runs btrbk + hourly against `/srv/sync`, with `timestamp_format long`, + `snapshot_preserve_min 6h` and + `snapshot_preserve 24h 31d 6m`. +- Clamps now stores Syncthing in the real Btrfs subvolume + `/var/lib/data/syncthing`; `/var/lib/data` is mounted from `/data`. The + `containers` subvolume must not be captured by a parent-directory snapshot. +- Fedora [packages btrbk](https://packages.fedoraproject.org/pkgs/btrbk/btrbk/) + with systemd units. Verify the package and timer on the actual uCore base. + Upstream documents the [volume/subvolume layout and dry run](https://github.com/digint/btrbk#example-local-regular-snapshots). + +## Implementation sequence + +1. Done in `ucore-images`: add `btrbk` to the shared OS recipe + `recipes/ucore-common.yml` and mask its packaged daily timer. Build the + common image before the host images as their CI requires. Do not install + packages at runtime or copy Chef's source-build recipe. +2. Done in Skillet: add a reusable btrbk module and make hosts opt in with + explicit paths relative to `/var/lib/data`. Clamps passes only `syncthing`; + no host implicitly snapshots the root data subvolume or Podman containers. + The module requires the expected data mount and an existing source + subvolume, then manages retention/config and its timer idempotently. +3. Implemented: mask the package's daily timer in the shared image and manage + one hourly Skillet systemd timer. The service runs the shared data mount + validation before btrbk, and is bound to the mount. + The service must verify the actual `/data` Btrfs mount on every run, using + the same checks as `butane/includes/data-storage.bu`; a mountpoint assertion + alone does not reject a wrong filesystem. Do not enable a second packaged + timer or create a cron job. Apply the shared module from clamps' host + convergence only after Syncthing storage exists. +4. Done: isolated unit tests cover caller-selected rendering, source path + safety, opt-out, repeat apply, and fail-closed behavior for a wrong data + mount. +5. Done: rebuilt a named disposable clamps VM from the image with btrbk, + provisioned dummy credentials, and validated the real systemd timer and + Btrfs subvolume. + +## Exit criteria + +- Shared `ucore-images` recipe contains btrbk. Workspace format, pedantic + Clippy, unit tests, the CI container command, and the named VM smoke pass. + The VM uses real systemd and Podman. +- `btrbk -n -v run` targets only the intended Syncthing subvolume; a real run + creates a read-only snapshot under the intended directory. Repeated Skillet + apply preserves the config, timer, and snapshots. After reboot the timer is + active and enabled, and its next run is scheduled at the next hourly boundary. +- Put a disposable sentinel in the VM's Syncthing data, snapshot it, change + the live file, and restore the snapshot into a **separate temporary path**. + Verify the original bytes without overwriting the live Syncthing tree. +- With `/var/lib/data` absent or replaced by the wrong mount, convergence and + the timer fail closed without writing snapshots to the underlying `/var`. + The real wrong-mount and absent-mount checks are recorded in + `butane/ACCEPTANCE.md` under shared Btrfs data storage. + +Local snapshots share the source disk's failure domain; this milestone does not +claim independent backup. Nested subvolumes inside a selected snapshot source +are unsupported by design; Syncthing data sources must remain leaf subvolumes. + +## Current verification + +- Image workflow [run 36552978960](https://github.com/gbagnoli/ucore-images/actions/runs/36552978960) + succeeded for the common image and all three host images. The published + `ucore-clamps:latest` digest is + `sha256:e6c0cd35641199ddb5e2d9ca0f281dc1d640126ae5b2a04fa87c65163cedfd7c`. +- The user created `clamps-test-smoke`; the current session can inspect its + native `qemu:///session` domain. It booted the signed clamps image at the + digest above. The guest confirms `btrbk-0.32.7-1.fc44.noarch` is installed, + the package's `btrbk.timer` is masked, and `/var/lib/data` is mounted from + Btrfs `/data` on the expected filesystem. +- The first readiness attempt exposed an incorrect inherited rule to pass + `--wait` to systemctl. Because that option waits for the unit to stop, + `systemd-sysctl.service` (`Type=oneshot`, `RemainAfterExit=yes`) hung. The + rule and all Skillet and VM helper callers were corrected to use ordinary + blocking `start`/`restart`; the updated binary completed base apply. +- Full apply created the Syncthing subvolume and configured the hourly timer. + `btrbk -n -v run` selected only `/var/lib/data/syncthing`. A real run created + `syncthing.20260929T1402`, reported `ro=true`, and a separate temporary + subvolume restored the original sentinel bytes after the live sentinel was + changed. The timer also ran at 14:00:30 and created + `syncthing.20260929T1400`. +- Repeated full apply retained config/timer hashes and both snapshots. After + reboot, Pi-hole and Syncthing were active, the Syncthing subvolume and both + snapshots remained, `skillet-btrbk.timer` was active/enabled, and the + packaged `btrbk.timer` remained masked; the next timer run was 15:00 CEST. + `test smoke clamps` passed its real-systemd/Podman cases through reboot. +- Local validation after the correction: `cargo fmt --check`, all 45 workspace + tests, pedantic Clippy with warnings denied, the CI-style container command + (`cargo run --release -p skillet -- test run clamps --phase base --image + fedora:latest`), ShellCheck and `bash -n` for `test-vm-ready`, and + `git diff --check` passed. The container command ran from a writable + temporary workspace copy because the checked-out workspace is mounted + read-only to shell commands. diff --git a/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md new file mode 100644 index 00000000..fc002702 --- /dev/null +++ b/ublue/plan/CLAMPS-CLOUDFLARE-DDNS.md @@ -0,0 +1,169 @@ +# Cloudflare DDNS port + +Status, 2026-10-07: implementation complete. The test-only DDNS leaf is now +configured. `clamps-test-monitoring` activated the updater and published one +journaled A record; repeated provisioning verified publication and renewed +the bounded disposable token. Repeat apply preserved its container ID. +Publication after reboot and owned disposal passed; see `../butane/ACCEPTANCE.md`. Production DNS +handover and explicit credential-rotation acceptance remain separate work. + +Template review validation, 2026-10-06: six resolver tests cover environment +paths, complete host overrides, nested references, safe JSON string escaping, +malformed/missing references, and downstream DDNS schema validation. All 282 +workspace tests, formatting, pedantic Clippy, and the CI Fedora base container +scenario passed locally on the musl target. The container scenario confirmed +two successful applies with no service start/restart on repeat apply. These +checks used dummy values; live DDNS ownership acceptance remains parked. + +Read `../AGENTS.md`, `../skillet/AGENTS.md`, `../design/cloudflare-ddns.md`, +`../design/configuration-templates.md`, +`../design/secrets.md`, `../design/cloudflare-ui-lifecycle.md`, and +`../design/smoke-vms.md`. Install nothing on the workstation. Use Rust +resources/crates for orchestration and provider APIs; never embed shell programs. + +## Source contract + +`site-cookbooks/rupik/recipes/cloudflare_ddns.rb` is included by Rupik's default +recipe. It supplies one Cloudflare zone and private subdomain configuration; +A is enabled, AAAA disabled, TTL 300, and `purgeUnknownRecords=false`. +`site-cookbooks/cloudflare_ddns/recipes/default.rb` runs the image family +`docker.io/timothyjmiller/cloudflare-ddns` with a JSON config at `/config.json`. +It also creates a host account and sets PUID/PGID environment variables. Do not +assume these variables control the process or reproduce unnecessary host accounts. + +The live image version, polling cadence, full record/proxy configuration, and +any second updater on Rupik need private inventory before production activation. +The legacy `rupik::network` cron is not selected by the default include path. + +## 1. Establish the configuration template and image contract + +Implemented: upstream 2.2.0, index digest +`sha256:5f2471be9efd9f0c95f973645cc87f05d501020ded94d380a2120fbfdf812d3d`. +Actual image accepted a Podman secret at `/config.json`, mode `0400`, UID/GID 0, +with dummy credentials and no network. Schema v1 rejects malformed names, +duplicates and unsupported policies. Private live inventory remains pending. + +- Inspect official image source/docs for the selected version, JSON schema, + config-file access, refresh cadence, runtime identity, and credential support. + Pin an evaluated image version/digest in the implementation; do not invent its + behavior from the Chef variables. Record findings in the design. +- Initial support is one selected environment zone, obtained from existing + `skillet/environments//dns/cloudflare-zone-id`. Verify the retained + DDNS records actually belong to it; an additional zone needs an explicit schema + extension, never automatic token-scope broadening. +- The embedded workstation template defines versioned JSON with relative record + names and explicit proxy state; KeePassXC contains the individual + `skillet/environments//hosts//cloudflare/ddns-dns-name` + leaf. Resolve record names directly below the selected zone, independently of + `dns/ui-domain`. +- Validate names, duplicate/conflicting records, and overlap with the host's UI + records before token issuance or delivery. Existing unrelated records require + an explicit takeover decision. Do not populate production values in this repo. + +Exit: documented image/config contract and pure validation/rendering tests using +reserved example names. Secret-file consumption is confirmed before activation +work. Private source inventory remains explicit if the host is unavailable. + +## 2. Declare the service and compose its Quadlet + +Implemented: `ServiceConfig::Ddns` selects `skillet_ddns`; Clamps opts in. +`--phase ddns` and generic `skillet-ddns-apply.service` consume only +`cloudflare_ddns_config`. Full apply skips DDNS. Tests cover opt-in, missing +credentials/storage, root-only secret mount, no ports, no-op, rotation and retry. +Ordinary application acceptance excludes the separately provisioned DNS writer; +`acceptance_plan_with_ddns(true)` supplies its checks for the future live scenario. + +- Add a DDNS capability and credential consumer to the canonical host profile; + Clamps opts in, other callers may opt in. Keep declarations free of real DNS + values. Service/UI/credential eligibility derives from that same declaration. + The host binary explicitly selects the shared DDNS crate/service; having an + environment zone in KeePassXC must never implicitly enable it. Reuse the + existing environment zone entry, with host/environment-specific values in + typed template references. +- Add an independently credential-gated DDNS apply phase/unit, following the + existing Caddy separation. Base/full convergence must continue to work before + DDNS credentials exist; declaring the service must not add its credential to + the ordinary full-apply prerequisites. Keep this dispatch shared across hosts. +- Add the small service module using existing Podman and convergence resources. + Require the shared mounted service-data/Podman store. Use a rootful container, + restart/boot policy, network-online ordering, and the evaluated image. + Avoid a private-UI declaration or host port publications. +- Planned credential/Podman secret name: `cloudflare_ddns_config`. Parse and + validate the delivered payload before installing the secret. Mount the secret + at `/config.json` with the numeric UID/GID/mode proved by the image contract; + explicitly model process identity and namespace mapping if needed. +- If the image cannot consume this secret file, stop this slice and document a + revised integration rather than writing token-bearing config to disk silently. +- Connect changed config/secret revisions to the existing restart/retry behavior. + Unchanged convergence must preserve container identity. Missing credentials must + leave DDNS inactive without breaking unrelated service apply. + +Exit: CI covers caller opt-in, required credentials, secret mount, runtime identity, +missing data mount, no-op repeat apply, rotation, and failed-activation retry. +Routine CI requires no vault, external IP discovery, Cloudflare, or hypervisor. + +## 3. Issue and deliver the DDNS credential + +Production implemented: `secret deliver ddns` uses the environment zone, +rendered workstation template and persistent `ddns-token`. Caddy and DDNS share the +atomic use-or-create helper. JSON may include `takeover_existing: true` to approve +adopting an existing public A record; UI namespace and other Skillet ownership +remain protected. New records receive a DDNS-specific ownership comment. +Fake-provider/transport tests cover token reuse, failed delivery retry, explicit +vault token rotation, failed saving/revocation and collisions. Test policy uses +an opt-in VM flow with bounded issuance and a mode-0600 ownership journal. + +- Reuse the workstation Cloudflare SDK/account-token issuer and atomic KDBX writer. + Planned token path: + `skillet/environments//hosts//cloudflare/ddns-token`. + Persistent policy uses-or-creates a separate zone-scoped Zone Read/DNS Write + token; it never delivers the token creator or reuses the Caddy token implicitly. +- Build the image-specific JSON in memory from validated config and child token. + Deliver through existing encrypted SSH/systemd credential handling, then create + the Podman secret. Never put the token or complete payload in argv, logs, + manifests, recordings, or a plaintext host config file. +- Deliver all credentials needed by the selected activation phase before starting + consumers. Preserve existing commands; add shared eligibility/phase support as + necessary rather than a Clamps-specific command path. +- Use the existing issuance journals, verified transport, run lock, immutable + target checks, and retry policy. Rotation is explicit; repeated delivery reuses + existing persistent credentials. Keep provider dependencies workstation-side. + +Exit: fake-provider tests cover use-or-create, secret-safe errors, interrupted +delivery/activation, and rotation. No guest apply depends on KeePassXC or the +workstation issuer. + +## 4. Disposable acceptance and production handover + +Implementation complete; live publication and repeat apply passed on the +monitoring VM. Publication after reboot and owned disposal passed; see the acceptance log. The disposable +provisioner persists VM/environment/zone identity, a unique test ownership +marker, exact configured record names, token identity and cleanup-token state +before provider mutation. It records created record IDs after verifying the +updater has published its marked A records. Disposal revalidates the journal +against the exact test VM and configured name allowlist, deletes only records +that still match the ownership marker and recorded IDs, then revokes the +bounded DDNS and cleanup tokens. A mismatch aborts cleanup without broadening +the deletion set. + +- On a named VM verify a record receives the discovered public IPv4, stays + DNS-policy compatible, leaves unrelated A/AAAA/CNAME/TXT records untouched, + survives repeat apply/reboot, and reuses its credential. Test credential rotation + and disposal without printing private values. Park live DDNS record checks + until `skillet/environments/test/hosts//cloudflare/ddns-dns-name` is + present in KeePassXC. Record results in + `../butane/ACCEPTANCE.md`. +- Before production: privately inventory Rupik's exact records, TTL/proxy state, + image/version/cadence, and active updater services/cron. Approve the takeover + record set; stop the old writer, activate Clamps, verify records and services, + and keep a rollback path that runs only one writer. Do not trigger this handover + as part of an implementation test. + +Exit: local checks pass and named-VM results or exact unavailable checks are +recorded. Production mutation and DNS-writer handover remain a separate cutover. + +## Handoff + +Implement slices in order with focused commits. Update this plan/design and the +parity checklist after each slice; do not claim production parity from rendering +tests. Datadog port and UniFi backup/restore are the following work items. diff --git a/ublue/plan/CLAMPS-DATADOG.md b/ublue/plan/CLAMPS-DATADOG.md new file mode 100644 index 00000000..da5032ea --- /dev/null +++ b/ublue/plan/CLAMPS-DATADOG.md @@ -0,0 +1,192 @@ +# Datadog port + +Update, 2026-10-12: optional NUT metric collection is implemented in the static +guest binary (`metrics nut`). The official Agent enables Unix-socket DogStatsD +with UDP disabled. Successful Datadog apply configures the selected NUT module's +timer; ordinary application apply does not enable telemetry. Offline protocol, +transport, CLI and repeat-apply tests are in routine workspace CI. An explicit +ignored consumer test validates the pinned Agent without network access or a +real key. Live forwarded UPS collection and isolated Agent parsing passed. +Native uCore timer/SELinux/reboot recovery and cloud ingestion for these new +metrics remain pending; record their acceptance separately. + +Status, 2026-10-08: rootful Agent, credential delivery and caller-derived boot +ordering are implemented. Two reboots of `clamps-test-discovery` automatically restored one Syncthing +HTTP and four process checks, without an Agent restart. Offline checks pass +(301 tests, Clippy, formatting, Fedora repeat apply), and named-VM smoke passed. +Previous Cloudflare/Tailscale cleanup and VM disposal passed. +NPM/USM VM acceptance now passes with explicit `PERFMON`, default seccomp and +read-only debugfs. `clamps-test-datadog` recovered automatically after reboot, +with real bridge HTTP traffic, application discovery, lab tags and submissions. +Secure Boot, provider host-tag read-back and richer metrics remain pending; +see `../butane/ACCEPTANCE.md`. + +Prior validation, 2026-10-07: formatting, pedantic Clippy, 299 workspace tests, +Fedora base repeat apply, and named-VM Agent startup/delivery passed. The earlier +missing KVM observation was sandbox visibility; native session libvirt works. +`clamps-test-monitoring` confirmed Agent 7.84.1, SELinux enforcing, hostname, +valid API key, successful submissions, default host/container checks, Syncthing +HTTP health and four process instances, and no-op container identities. +Live acceptance found and fixed socket-as-directory convergence, HTML escaping +of Quadlet labels, and shadowing of built-in integrations. eBPF tracing remains +failed: perf probe attachment returned “function not implemented” and fallback +registration could not write to read-only tracing files. Review the required +kernel/seccomp/tracing access before claiming network-monitoring parity. +Named-VM smoke passed. Both optional services and submissions recover after +reboot, but HTTP/process Autodiscovery does not: restarting the Agent restores +one HTTP and four process checks. Fix boot discovery/reconciliation before +claiming application monitoring recovery. Owned Cloudflare/Tailscale cleanup +and VM disposal passed; evidence is in the acceptance log. +Private ping targets and richer application metrics remain additional work. + +Read `../AGENTS.md`, `../skillet/AGENTS.md`, `../design/monitoring.md`, +`../design/secrets.md`, and `../design/configuration-templates.md`. +Install nothing on the workstation. Keep routine CI offline and vault-free. + +## 1. Module declarations + +Implemented: shared `skillet_datadog` Autodiscovery renderer, consumed by each +container recipe. Syncthing owns an internal unauthenticated health probe; +Pi-hole, DDNS, UniFi, Caddy, and Tailscale own process-liveness declarations. +Labels contain public configuration only. They are inert without an agent. + +Local validation, 2026-10-06: all 284 workspace tests, pedantic Clippy and +formatting passed on the musl target. The installed Quadlet generator accepted +the passive fixture and retained `%%%%host%%%%` in ExecStart, which systemd +expands to Datadog's `%%host%%`. The CI Fedora base scenario passed two applies +without service start/restart on repeat apply. No live agent checks or telemetry +ingestion were exercised. + +- Validate labels through the actual Quadlet generator, especially escaping of + Datadog `%%host%%` variables through generated systemd ExecStart specifiers. +- Cover JSON quoting and actual service declarations in CI. +- Keep discovery independent of fixed container IPs or publicly exposed UIs. + +Exit: renderer tests and actual Quadlet generation preserve the JSON and +Autodiscovery placeholders; relevant local CI checks pass. + +## 2. Agent and host profile + +Implemented: official Agent container as an optional canonical host service. +Clamps opts in. Agent image index evaluated for this port: +`registry.datadoghq.com/agent@sha256:161a43ad2b290f7527a70e70c8880c1b3d65b39411552a8cb41398049546daf5`. +Pinned metadata and live runtime both report Agent 7.84.1. + +Reviewable runtime boundary: + +- Rootful Agent with host PID/cgroup namespaces and host networking for + container HTTP probes; no published Agent, DogStatsD, or APM ports. +- Rootful `/run/podman` directory mounted read-only, exposing `podman.sock`, for supported Docker API + Autodiscovery. Enable/start `podman.socket` only for the opted-in agent. The + socket can mutate containers despite the read-only bind mount. +- Read-only `/proc`, `/sys/fs/cgroup`, `/run/systemd`, and host filesystem mounts + for process, Btrfs, disk, and systemd data. Never relabel host paths with `Z`. +- SELinux label separation disabled for this monitoring container if required + by the evaluated runtime; record the reason and actual uCore test evidence. +- Explicit `network_monitoring` caller policy. To retain Chef's eBPF network and + service monitoring, add the documented system-probe capabilities: + `SYS_ADMIN SYS_RESOURCE SYS_PTRACE NET_ADMIN NET_BROADCAST NET_RAW IPC_LOCK CHOWN` + and required debugfs access. Do not enable unrelated APM/log collection. + +Use the Podman-socket backend first. Do not infer the standard Podman graphroot: +this fleet stores containers under `/var/lib/data`, and any DB-backend alternative +must use the authoritative storage configuration. Verify bridge-network probes +and eBPF packet visibility before claiming parity. + +Exit: optional eligibility, required data mount, generated privileges/mounts, +no plaintext credentials, no-op repeat apply, secret rotation, and failed-start +recovery are covered by local tests. Ordinary base/full apply and smoke do not +require Datadog credentials or start telemetry. + +## 3. Configuration and delivery + +Implemented: template, schema validation, native Podman environment secrets, +separate credential-gated phase, production delivery, explicit disposable +enrollment, and authoritative `env:prod`/`env:test` tags. The official image's +startup requires `DD_API_KEY`; file-secret injection is not used. Live encrypted delivery, API-key validation and successful submissions passed; +account-side graph/dashboard acceptance remains separate. + +- Use the public `datadog` template with one typed secret reference to + `skillet/datadog/api-key`, shared by production and test. Site is ordinary + template configuration, defaulting to `datadoghq.com`. Host overrides can + select a different site and private tags. Do not require an application key for + ingestion. Version and validate the rendered payload before delivery. +- Add a shared `--phase datadog`, `skillet-datadog-apply.service`, and + `datadog_config` encrypted credential. Production command: + `secret deliver datadog`. Use existing verified stdin-only transport. +- Mount API keys as native Podman secrets, preferably files if supported by the + pinned image; native secret environment injection is an explicit supported + fallback. Never use literal `Environment=DD_API_KEY=...`. +- Disposable enrollment is explicit through `--with-datadog`, with the shared + key, `env:test` tagging, and the actual VM hostname; record that remote + telemetry/host retention is distinct from VM/token cleanup. Use dummy + credentials and disabled networking for offline checks. + +Exit: successful template validation, deferred activation, rotation, and missing +credential handling; no vault/API requirement in routine CI. + +## Test alert isolation + +Implemented: authoritative `env:prod`/`env:test` tagging plus caller-declared +production region (`ftwo` on Clamps) and `lab` for every test deployment. +Templates cannot override the region policy. Optional workstation administration +key: `skillet/datadog/monitor-setup-key`, with `monitors_read`/`monitors_write`. + +Completed one-time setup, 2026-10-08: key-pair validation and six-monitor +inventory succeeded. Added `.exclude("env:test")` to the all-host Agent no-data +and NTP monitors. Both full-policy validations, query-only updates and read-back +checks returned HTTP 200. Notification options, thresholds, messages and routing +were unchanged. Private rollback snapshots were retained outside the repository. +Other monitors have explicit production targets. Existing Chef `env:home` and +untagged production hosts remain included. + +Live VM follow-up: Agent status confirmed `env:test` and `region:lab`, with +successful submissions before and after reboot. Both monitors returned zero +test-host groups in their evaluation state. Provider host-tag read-back returned +HTTP 403 with the monitor-only key; verifying received tags needs appropriate +host-read access. PagerDuty delivery was not separately observed. New monitors +must also exclude test data. Existing retained hosts require redelivery to get +`lab`; this acceptance used a separate disposable VM. + +## 4. Checks and acceptance + +Follow-ups from live acceptance: + +1. Implemented: order Agent startup after caller-declared monitored units, + without activating optional consumers. Pinned workload metadata/provider + source uses an initial inventory followed by events, not periodic collection. + Offline tests cover caller-derived order, absence of activation dependencies, + and self-dependency rejection. Both live reboots and repeat apply passed; + named-VM smoke passed. Later runtime event + reconciliation remains outside this boot fix's evidence. +2. Implemented and accepted on the named VM: explicitly add `PERFMON` when + network monitoring is selected. The installed Podman seccomp profile gates + `perf_event_open` on it; `SYS_ADMIN` alone returned ENOSYS. No seccomp bypass, + writable tracing/debugfs or additional host installation was needed. The + syscall succeeded, NPM/USM/event-monitor modules started, and actual bridge + HTTP traffic produced byte counters, HTTP classification and aggregates. + Repeat apply preserved container IDs; smoke/reboot passed and recovered the + same modules and application checks without an Agent restart. SELinux was + enforcing on kernel `7.2.5-200.fc44.x86_64`. Secure Boot was unavailable in + this BIOS VM and must still be tested on UEFI/physical hardware. + + +- Agent owns shared host system/process/network/Btrfs metrics and localhost SSH + TCP check. Aggregate systemd unit contributions from the canonical service + declarations, including Btrbk's timer; do not maintain a second service list. +- Configure private ping targets through templates and reviewed integration + dependencies. Preserve retained Chef tags privately after inventory. +- Verify Syncthing's no-auth health endpoint on the deployed image. Later add + synchronization metrics through a reviewed supported integration or exporter; + do not claim that HTTP health establishes folder/device synchronization. +- Review Pi-hole 6 authentication and community-check compatibility before + installing it. DNS-query checks and API statistics must have separate evidence. +- DDNS process liveness is insufficient to prove publication; keep its separate + ownership/publication acceptance evidence and production cutover plan. +- Named VM: verify Agent status, detected containers, check success, host rather + than container metrics, Btrfs data, systemd units, eBPF under Secure Boot, + no-op/reboot recovery, and explicit test ingestion. Record results in + `../butane/ACCEPTANCE.md`; report unavailable checks honestly. + +Exit: retained Chef capabilities and deliberate changes reconciled in parity +docs. Rich application checks remain explicit if acceptance is unfinished. diff --git a/ublue/plan/CLAMPS-EMAIL.md b/ublue/plan/CLAMPS-EMAIL.md new file mode 100644 index 00000000..7464beee --- /dev/null +++ b/ublue/plan/CLAMPS-EMAIL.md @@ -0,0 +1,92 @@ +# Fleet SMTP implementation + +Status: native Postfix image packages and shared provisioning implemented. +Isolated native-consumer and rebuilt uCore capture acceptance passed. +Live Mailjet verified-TLS/authentication and operator-confirmed Gmail receipt passed. +Mailjet entry fields, including sender, pass the production vault audit. Sender/domain +authentication headers and provider account limits remain unverified. +See [the design](../design/email-delivery.md). + +## Findings + +- Chef configures SMTP directly for Wanderer and AdventureLog using application + secrets. No fleet-wide Postfix/relay recipe was found. `secrets/` contains only + its README locally, so the old provider cannot be identified from this checkout. +- `ucore-common` owns packages/static files; Skillet already owns credentials, + canonical service composition, convergence and monitoring declarations. +- Operator identified Mailjet (`in-v3.mailjet.com:587`) and created one + `skillet/smtp` entry with Username/Password and host/port/tls custom fields. + Shared field-aware vault reading and required-field auditing are implemented; + no provider authentication or outgoing email is performed by the audit. + +## Ordered slices + +1. **Partially complete:** Mailjet identified and the one-entry vault audit + passed. Confirm the verified sender and domain, account limits and provider + authentication during explicit live acceptance. Required STARTTLS on port + 587 is the selected policy. Prepare brief exact vault + requirements through shared metadata/root checklist; keep provider password + and private sender/endpoint leaves out of the public repo. Use a shared + credential where its actual account scope allows it, with explicit test policy. +2. **Implemented:** add native Postfix and required authentication/TLS packages + to the common image, initially inactive. Check the base image for existing packages and + units first. Keep all runtime values and authentication material out of it. +3. **Implemented; capture and live Mailjet gates passed:** add a cohesive `skillet_smtp` service + crate and typed configuration template. + Declare fleet inclusion centrally, separate from credential-free base apply. + Deliver the entire prerequisite set before activation using existing encrypted + credential workflow. Validate map backend, file permissions, SELinux and + service/worker access using the actual runtime. Preserve queued mail across + repeat apply, reboot and image rebase. Require authenticated verified-TLS + upstream; no direct-MX fallback. Queue lives in `/var/spool/postfix`. +4. **Host/loopback implemented; application setup pending:** support host + sendmail and loopback submission first. Add explicit bridge + client access only for declared applications; do not publish LAN/tailnet SMTP + or broadly allow arbitrary bridge clients. Applications own their SMTP client + integration. UniFi recovery email is parked by operator decision; see below. +5. **Unit health implemented; queue/failure metrics pending:** add module-owned + monitoring for service health, deferred queue age and send + failures without credential/message-body disclosure. Test hosts use a local + capture sink and environment tags; provider delivery remains explicit opt-in. + +## Exit criteria and acceptance + +- Routine CI needs no vault/provider: typed rendering, missing/invalid inputs, + prerequisite activation, repeat apply, metadata preservation and recipient/access + restrictions tested through injected boundaries. +- Named disposable VM: host sendmail and intended SMTP clients reach the sink; + unauthorized clients cannot relay; TLS certificate/authentication failures do + not trigger plaintext delivery. Restart/reboot/rebase preserve queued messages + and retry after a simulated upstream outage without regenerating credentials. +- Explicit live acceptance after provider setup: send a message to an + operator-controlled Gmail inbox. Inspect SPF/DKIM/DMARC results and actual + delivery; no guaranteed inbox placement claim. UniFi recovery email is parked. + Do not send during routine smoke runs. +- Record exact passed/pending checks in `butane/ACCEPTANCE.md`, maintain designs, + README usage and the root secrets checklist; focused validated commits, no push. + +References: [Postfix relay configuration](https://www.postfix.org/SOHO_README.html), +[Gmail requirements](https://support.google.com/mail/answer/81126?hl=en). + +## Next acceptance slice + +The rebuilt image passed named disposable capture acceptance with enforcing +SELinux, loopback-only submission, boot map regeneration, and queue persistence +through reboot and signed image rebase. Record: `butane/ACCEPTANCE.md`. + +Live acceptance passed production credential-map access under enforcing SELinux, +verified STARTTLS, SMTP authentication, and one operator-confirmed Gmail delivery. +The original deferred message was retried after fixing the trust-bundle path and +RAM credential labels; no duplicate was submitted. + +Remaining SMTP follow-ups: inspect SPF/DKIM/DMARC headers. +Explicit certificate/authentication failure cases remain unverified; the isolated +no-STARTTLS refusal check passed. Bridge client access and queue-age/send-failure +monitoring remain separate implementation work. No provider mail is sent by +routine smoke runs. + +## Parked by operator decision + +UniFi SMTP/recovery-email configuration is parked (2026-10-09). It is not the +next milestone or a required cutover gate. Shared host mail delivery is accepted; +application email setup can resume only when requested. diff --git a/ublue/plan/CLAMPS-MIGRATION.md b/ublue/plan/CLAMPS-MIGRATION.md new file mode 100644 index 00000000..cccb5509 --- /dev/null +++ b/ublue/plan/CLAMPS-MIGRATION.md @@ -0,0 +1,213 @@ +# Clamps migration draft + +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + +Status: reviewed against source and recorded acceptance, 2026-10-11. +Shared services, native TPM recovery and physical USB media preparation are +implemented. Clamps is not production-ready until the physical/cutover gates +below pass. The parked Flatpak/Syncthing and deferred network checks remain +open; previous passing native VM checks do not close them. + +## What remains for clamps + +1. Boot the prepared USB on the S13; inspect firmware/TPM and select its stock + SSD explicitly. Enroll the trusted MOK when using Secure Boot. Validate the + selected plain or TPM profile on hardware, recovery archive and cold boot. +2. Implement private runtime Pi-hole DNS configuration delivery and v6 + convergence: current code writes `custom.list` from a static declaration; + the accepted temporary v6 `dns.hosts` test does not validate that recipe path. + Supply production DNS/DDNS records and keys, migrate service data + and identities from rupik, and decide retained cross-host routes and WOL + targets. Keep the old DNS service and Samsung SSD until the cutover. +3. Validate real LAN IPv4/IPv6 UDP/TCP DNS, peer connectivity, physical SSH/ET, + UniFi devices/cloud access, production ACME/renewal and outside-tailnet denial. + Approve the production exit node; routed-traffic checks remain omitted by + operator decision. Finish automated delivery before interactive access + finalization and first-login password-change acceptance. +4. Verify physical repeat apply, reboot and OS update; take service-data backups, + choose off-machine backup retention, rehearse rollback and agree the cutover. + Finish or explicitly defer the outstanding boot-policy/transport-interruption + tests. Broader OS hardening is still a placeholder, not an accepted port. + +No new clamps service crate is required to start +[boxy → joey](JOEY-MIGRATION.md). Acceptance details remain in +[ACCEPTANCE.md](../butane/ACCEPTANCE.md) and the service plans. + +## Goal and starting point + +Provision clamps with uCore + Skillet and migrate the required behavior and persistent state of the Chef-managed rupik. Complete one milestone at a time; add reusable Skillet resources when a service needs them. + +Implemented: base/full apply separation, activation recovery, fixture smoke +checks, temporary VM SSH keys, binary delivery, resolver configuration, signed +bootstrap, user environment, exact KeePassXC reads, and encrypted Pi-hole +credential delivery, and the shared Btrfs data layout. A dummy-credential +Pi-hole container runs in the VM with data and Podman storage under +`/var/lib/data`. Shared service networking, test Cloudflare lifecycle, and +staging ACME/HTTPS have passed named-VM acceptance. Final physical disk, +production/network acceptance, and remaining rebase/bootstrap checks stay open. + +The [source parity checklist](CLAMPS-PARITY.md) records retained behavior and +deliberate changes. [Cloudflare DDNS](CLAMPS-CLOUDFLARE-DDNS.md) has its guest +service, production delivery, and disposable ownership/cleanup implementation. +Live disposable DNS publication and cleanup passed; rotation and production +cutover remain separate. Datadog live acceptance, isolated UniFi backup restore +and operator UI inspection passed. Shared SMTP delivery to Gmail passed; +UniFi email setup is parked by operator decision. + +After the refactoring prerequisite: complete Pi-hole LAN acceptance and configure +the actual custom DNS +records once their values are supplied. On 2026-10-02, the running smoke VM +passed IPv4/IPv6 UDP/TCP DNS queries and a reversible custom-record test. +Its libvirt user-mode network forwards only SSH, so client ingress and firewall +behavior remain unverified; use a bridged/physical clamps network for that +check. Keep production DNS on rupik. Generic private UI staging acceptance is +recorded in [ACCEPTANCE.md](../butane/ACCEPTANCE.md); two deferred UI checks are +tracked in [generic private UI provisioning](GENERIC-PRIVATE-UIS.md). +Syncthing passed the named disposable-VM service and repeat-apply checks, and +the [btrbk milestone](CLAMPS-BTRBK.md) is complete. +See the [private UI design](../design/private-ui-access.md), [Syncthing +design](../design/syncthing.md), [Pi-hole network design](../design/pihole-network.md), +[storage design](../design/storage.md), [secret design](../design/secrets.md), +[VM lifecycle](../design/smoke-vms.md), and +[storage/credential plan](CLAMPS-STORAGE-CREDENTIALS.md). The final physical +disk remains open. + +## Deferred live acceptance batch + +Run these together when a suitable client network and the required production +configuration are available; record each result in +[ACCEPTANCE.md](../butane/ACCEPTANCE.md): + +1. Pi-hole: query from a real LAN client over IPv4/IPv6 and UDP/TCP, confirming + host-firewall behavior. Use isolated test records; add actual custom DNS + records only after their values are supplied. +2. Private UIs: verify a request from outside the tailnet gets Caddy's denial + response. Workstation loopback is not an outside-tailnet source. +3. Cloudflare production: accept production certificate issuance and a bounded + renewal scenario using the production environment and its existing DNS. + +The smoke VM uses libvirt user-mode networking and cannot satisfy item 1; use a +bridged test VM or the physical clamps host on an isolated/test network. + +Native [TPM-encrypted Btrfs root](TPM-ENCRYPTED-ROOT.md), QEMU TPM2 tests +and recovery are implemented and accepted. Physical media generation is +implemented; stock-SSD hardware acceptance remains required before cutover. Tang is +deferred; remote hosts will first be assessed for usable TPM support. + +## Boundaries + +- Image: packages, static OS files, baseline unit enablement/masking. Preserve common-before-host image builds. +- Ignition: installation layout, initial access and bootstrap configuration. +- Workstation Skillet: KeePassXC reads and guarded entry creation, a three-hour + kernel unlock cache, and secret delivery over SSH after base readiness. +- Skillet: continuing convergence of host values, application configuration, secrets, storage resources and containers. +- Public repositories and images must exclude secret values, including secrets compiled into host binaries. Decide which topology values also require private delivery. +- Keep the production Samsung SSD and functioning DNS on rupik until cutover. Test on the stock clamps SSD or disposable VM disks. +- Leave desktop `ublue/tailscale/` and `ublue/llama/` outside this work. +- Preserve Rust safety/error-handling rules and idempotency. Replace the blanket `systemctl --wait` rule with waiting for startup job completion: `--wait` waits for service termination and can hang on persistent services. + +## Remaining bootstrap verification + +- Exercise interrupted bootstrap and capture resolver continuity across intermediate boots. +- Reconcile the acceptance log's repeatability status with its recorded second fresh VM; do not infer unrecorded checks passed. +- Confirm automatic base apply before readiness explicitly restarts the unit, and collision refusal for unrelated domains/disks. +- Physical USB media generation now selects a whole-disk by-id identifier and + reuses the shared root-profile renderer; VM creation retains `/dev/vda`. Follow + [the hardware runbook](../butane/HARDWARE.md). Actual disk selection, firmware + checks, MOK enrollment and stock-SSD installation remain unverified. Fresh + native `/dev/vda` regression reached signed boot and full readiness after + transient bootstrap/SSH retries; evidence is in the acceptance record. + +## Milestone 3: storage and credentials for the first application + +Resolve only the decisions needed for Pi-hole first. + +- The shared `/var/lib/data` mount and Pi-hole persistence path are implemented + and accepted on a disposable VM. Chef used `/srv/pihole` on rupik. +- Put formatting/partition creation in the installation path. Skillet must not format existing data during convergence. +- Skillet and the managed Pi-hole service now depend on the real data mount; + the missing and wrong-mount VM checks passed. +- Directory/subvolume/mount checks and system Podman's graphroot under + `/var/lib/data` passed fresh-VM acceptance. Merge the shared Butane storage + include when adding each future host template. +- Extend the implemented direct KeePassXC read and SSH delivery to other named credentials as services need them. Disposable Pi-hole passwords are generated in VM tests. +- A signed image update and explicit digest-pinned rebase preserved the encrypted credential; post-rebase readiness and full apply passed. Recovery after atomic credential installation followed by failed full-apply activation also passed. A transport interruption during streaming/encryption and production TPM binding remain open. Atomic replacement, rotation, repeated apply and reboot passed in the VM. +- Account for Podman's on-disk secret copy in the production disk-protection decision. + +Done when: Pi-hole's storage and password can be provisioned, recovered after interruption, and rotated without leaking values into repository files, command arguments or logs. Reboot/rebase recovery works. The persistent-data mount failure case is exercised. + +## Milestone 4: working Pi-hole on an isolated test address + +- Implemented the reusable Quadlet network resource. Pi-hole now has a dual-stack bridge, network DNS enabled, container name `pihole`, and registry auto-update; see the [network design](../design/pihole-network.md). +- VM apply found Aardvark DNS colliding with Pi-hole's host port 53. Skillet now configures rootful Aardvark DNS to listen on port 54. On the named uCore VM, Pi-hole starts, Aardvark resolves its dual-stack container addresses from another container, an HTTP request reaches `http://pihole:8088/admin/`, and Pi-hole answers DNS over UDP and TCP through host IPv4 and IPv6 addresses. `ss` confirms host publication on IPv4 and IPv6 for both transports. +- Replace the Chef web pod with separate containers on the shared bridge. Caddy can resolve `pihole` and proxy to port 8088 without static container IPs or a host-published admin port. +- The Chef DNS listener behavior is represented as wildcard IPv4/IPv6 TCP/UDP port 53 publication. The running smoke VM answers queries on its IPv4/IPv6 interface addresses, but its `passt` user-mode network only forwards SSH; verify ingress from a real LAN client through the host firewall before accepting LAN reachability. +- Pi-hole password-file consumption and startup identity/permissions passed on the VM. Pi-hole v6 accepted and served a temporary `dns.hosts` custom record; the setting was restored empty. Configure actual custom records only after their values are supplied. +- Replace placeholder DNS records with supplied configuration. Support multiple names per address. +- Keep host bootstrap DNS independent of Pi-hole availability. Verify IPv4/IPv6 TCP and UDP DNS, web authentication, persistence, rotation, repeated apply and reboot. +- Decide whether Nebula Sync is required and which instance is authoritative before enabling it. + +Done when: clients can use the isolated clamps instance and all required Pi-hole configuration survives restarts. Production DNS remains on rupik. Network Quadlet unit tests pass, and the VM has confirmed service-name web access and host-port DNS publication; remaining network and persistence checks are listed above. + +## Milestone 5: migrate one remaining service at a time + +Each row is a separate implementation and validation step. Adjust order for dependencies; do not implement all rows as one batch. + +| Order | Capability | Behavior and state to preserve | +| --- | --- | --- | +| 1 | Syncthing (implemented; ownership correction committed; live retry parked) | Data, device identity, folder configuration, UID/GID, required ports; production peer and state transfer remain | +| 2 | btrbk (implemented and verified) | Follow the [btrbk milestone](CLAMPS-BTRBK.md): real Btrfs subvolume, hourly snapshots, Chef retention policy, and restore exercise. Local snapshots are not an independent backup. | +| 3 | UniFi (implemented; isolated restore and operator inspection passed) | Rootful host-network container and persistent data accepted; production devices/cloud access remain cutover checks. See [UniFi port plan](CLAMPS-UNIFI.md). No Caddy route. | +| 4 | Tailscale (implemented; smoke enrollment and cleanup accepted) | Host network, state, OAuth enrollment, and cleanup exist. Caller-selected production exit-node advertisement is implemented, disabled for smoke VMs. Existing forwarding policy is retained; approve the production device in tailnet policy. Live routing exercises are omitted by operator decision. UI names use Cloudflare DNS-only records. | +| 5 | Private UI access with Caddy + ACME (implemented; staging accepted) | Generic declarations/aliases, encrypted delivery, scoped account tokens, A/AAAA/CNAME reconciliation and cleanup exist. Staging ACME/HTTPS passed. Production renewal, real-client resolution and separate-network denial remain; see [UI plan](GENERIC-PRIVATE-UIS.md). | +| 6 | Cloudflare DDNS | Guest service, production delivery, and journaled disposable lifecycle implemented; live disposable publication/cleanup passed; explicit rotation and production one-writer handover remain separate; see [DDNS plan](CLAMPS-CLOUDFLARE-DDNS.md) | +| 7 | Datadog and remaining host baseline | Datadog implementation and live VM acceptance passed. Baseline audit and access finalization are implemented; physical checks remain in the [parity checklist](CLAMPS-PARITY.md) | + +For each service: inspect effective Chef inputs, settle its open decisions, add only needed Skillet support, migrate a copy of state, validate functionality and repeat convergence. Account for ARM-to-x86 application/image and data compatibility. Avoid activating duplicate production identities or DNS writers during testing. + +Syncthing validation used an empty disposable data subvolume, creating a VM-only +device identity. `syncthing` resolved to both bridge addresses, its UI returned +HTTP 200 by container name, all Chef-published dual-stack ports were present, +and a repeated full apply preserved its container ID and config hash. Testing +connectivity with a real peer and migrating rupik's identity/data remain +cutover tasks; never run both copies of the production device identity at once. + +Legacy NAT rules and the separate updater live in a recipe that is not included by rupik's default recipe. Confirm whether they are active or still required before porting them. Argon One support is Pi-specific and excluded. + +## Milestone 6: cut over with a recovery path + +- Reconcile the parity checklist against effective production configuration and explicitly record intentional omissions. +- Record the final disk/data transfer approach; take usable backups and perform final synchronization with stateful writers stopped where necessary. +- Transfer chosen addresses, DNS/DHCP settings, names and service identities without running conflicting instances. +- Exercise client DNS, synchronization, UniFi, VPN/exit-node operation, proxy authentication, certificate renewal, DDNS and restores as applicable. +- Verify a reboot, an OS update/rebase and a repeated Skillet apply on the target. +- Retain a documented way to return service to rupik until clamps has passed the agreed observation period. + +Done when: all retained capabilities and data work on clamps, routine convergence and updates work, and the user accepts retirement of rupik's production role. + +## Decisions deferred until needed + +- Storage: stock-SSD test layout, final disk arrangement, and post-provision + OS rebase acceptance. The disposable VM mount-failure and graphroot checks + passed. +- Credentials: production TPM/rebase policy and protection of Podman's on-disk copy; portable storage and delivery are decided in the secret design. +- Services: actual DNS/DDNS records, retained cross-host routes, exit-node operation, + Nebula Sync, and legacy networking. Datadog is retained and its live VM acceptance passed. +- Cutover: address/identity transitions, outage window and rollback duration. + +## Optional additions + +[Home Assistant](HOME-ASSISTANT.md) is a requested new service, separate from +Chef parity and the rupik retirement gates. Its plan covers an opted-in rootful +host-network container, generic private UI access, native encrypted backups +published into Syncthing, existing btrbk history, and isolated restore acceptance. +All Home Assistant implementation and acceptance remain pending. + +## Technical references + +- [systemctl waiting semantics](https://raw.githubusercontent.com/systemd/systemd/main/man/systemctl.xml) +- [Quadlet enablement](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#enabling-unit-files) +- [Podman secret drivers and replacement](https://docs.podman.io/en/latest/markdown/podman-secret-create.1.html) +- [Pi-hole configuration and password files](https://docs.pi-hole.net/docker/configuration/) diff --git a/ublue/plan/CLAMPS-PARITY.md b/ublue/plan/CLAMPS-PARITY.md new file mode 100644 index 00000000..f886e7c5 --- /dev/null +++ b/ublue/plan/CLAMPS-PARITY.md @@ -0,0 +1,56 @@ +# Rupik to Clamps parity checklist + +Status: source/plan review updated 2026-10-11; baseline VM observations recorded in +`CLAMPS-BASELINE-AUDIT.md`. This compares the repository's Chef include +path with current Skillet, Butane, and image definitions. It is not an inventory +of effective private Chef attributes or live Rupik state. Never copy production +domains, addresses, record names, zone IDs, tokens, or backups into this file. + +Sources: `roles/rupik.rb` includes `role[server]`, `server::wol`, and `rupik`. +`site-cookbooks/rupik/recipes/default.rb` selects the application recipes below; +`server::default` supplies hardening, users, and Chef maintenance. + +| Chef capability | Clamps implementation / deliberate change | Remaining work | +| --- | --- | --- | +| Btrfs `/srv` and system/user Podman stores | Shared `/var/lib/data` subvolume; system graphroot under `containers/system`, overlay driver on uCore | Native TPM/plain profiles and USB preparation implemented; physical acceptance and state transfer; rootless store only if a retained workload needs it | +| Disable resolved; Pi-hole | Mask/resolver setup, dual-stack port 53, persistent service data, encrypted password, shared bridge | Private runtime custom DNS delivery/v6 convergence (current static `custom.list` path is not validated by the manual v6 test), real LAN/firewall acceptance, data transfer | +| nginx, ACME, OAuth proxy, web pod | Separate bridge containers; generic Caddy routes, Cloudflare DNS-01, tailnet restriction, per-service names and aliases | Production certificate/renewal and external-source denial; inventory any legacy cross-host routes still needed | +| Syncthing | Container and leaf data subvolume; peer ports retained; host GUI publication removed; explicit host/container identity mapping | PARKED: live ownership fix acceptance; production peer connectivity and identity/data migration | +| btrbk | Caller-selected Syncthing snapshots, hourly timer, retention and restore accepted | Off-machine backup policy and physical cutover acceptance; nested source subvolumes unsupported | +| UniFi | Rootful host-network container; numeric application data ownership; empty-controller/repeat-apply, isolated backup restore, restored-data persistence and operator UI inspection accepted | Production device adoption and Site Manager access at cutover; SMTP setup parked; see `CLAMPS-UNIFI.md` | +| Tailscale | Host network, persistent state, OAuth enrollment, disposable identity cleanup, DNS acceptance disabled; caller-selected production exit-node advertisement implemented, disabled for disposable VMs | Approve the production exit node in tailnet policy and select it on clients; live routing exercise omitted by operator decision; see `../design/tailscale.md` | +| Cloudflare DDNS | Caller-selected rootful updater, clear shared template with KeePassXC leaf references, production delivery, and journaled disposable token/record cleanup implemented; separate from UI DNS publication | Explicit rotation acceptance and private record/proxy inventory and one-writer cutover; see `CLAMPS-CLOUDFLARE-DDNS.md` | +| Datadog | Retained; approved rootful Agent, service-owned labels, host checks and credential delivery implemented | Live host/container checks, submissions and automatic boot discovery passed; NPM/USM perf/HTTP traffic and reboot acceptance passed with explicit PERFMON; physical Secure Boot, provider tag read-back and richer application checks pending; see `CLAMPS-DATADOG.md`. Later local metrics/Grafana is separate work | +| EternalTerminal | Common image installs `et`; VM unit active/enabled and dual-stack port 2022 listening | Physical firewall and end-to-end client access | +| Wake-on-LAN | Common image installs `wol`; Chef emitted per-target helper scripts | Determine needed target shortcuts and supply MACs privately; package availability alone is not shortcut parity | +| Users, SSH, sudo, shell/dotfiles | SSH syntax/effective configuration accepted; uCore supplies passwordless sudo; Brew and dotfiles installer completed, with Vim plugins explicitly skipped | UID 1001 accepted; explicit post-provisioning sudo/password-expiry finalization implemented, live interactive login pending; review production keys; broader OS-hardening function remains a placeholder. See `CLAMPS-BASELINE-AUDIT.md` | +| Ubuntu package/Chef updates | Signed uCore images and Podman auto-update replace that maintenance mechanism | Physical OS update/rebase acceptance; no Chef daemon migration | +| Argon One | Omitted intentionally for x86_64 Clamps | None | + +## Recipes outside the default path + +`rupik::network` contains legacy NAT rules and a second Cloudflare update cron, +but `rupik::default` does not include it. Do not assume the cron/rules are absent +on the running host: inspect effective run lists and retained system state before +cutover. Do not port or enable a competing DNS writer merely because its source +exists. Additional private run-list recipes remain an inventory question. + +## Work order + +1. Disposable DDNS publication/recovery/cleanup passed; finish explicit rotation + acceptance and privately inventory production records; + do not mutate production DNS. +2. Datadog eBPF and isolated UniFi restore/UI acceptance passed. Shared SMTP + delivery to Gmail also passed; UniFi email setup is parked. Retain physical + Secure Boot, provider tag read-back and richer monitoring follow-ups in + `CLAMPS-DATADOG.md`. +3. Production exit-node selection, baseline audit and explicit administrative + access finalization are implemented. Native TPM recovery/header archival and + physical USB generation passed disposable acceptance. Actual S13 firmware, + first-login password change and deployment remain unverified; see + `TPM-ENCRYPTED-ROOT.md` and `../butane/HARDWARE.md`. +4. Run the parked VM and deferred network/production acceptance when their + required infrastructure is available; transfer state only with a recovery path. + +Track migration ordering in `CLAMPS-MIGRATION.md`, refactor evidence in +`REFACTOR-FOLLOWUP.md`, and live results in `../butane/ACCEPTANCE.md`. diff --git a/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md new file mode 100644 index 00000000..6e43667a --- /dev/null +++ b/ublue/plan/CLAMPS-STORAGE-CREDENTIALS.md @@ -0,0 +1,140 @@ +# Milestone 3 discussion: storage and credentials + +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + +Status: remaining work, 2026-09-27. Base provisioning is implemented. The +storage mount path and subvolume layout are decided in +[the storage design](../design/storage.md); final physical disk remains open. +Credential decisions are recorded in [the secret design](../design/secrets.md). +Shared Butane storage setup, rootful Podman graphroot configuration, Skillet +mount guards and Pi-hole path changes passed fresh-VM, reboot, repeat-apply, +and mount-failure checks; see [the acceptance record](../butane/ACCEPTANCE.md). +The include is host independent and must be merged by each future host's +Butane template. + +3B progress: exact KDBX lookup and safe use-or-create for a fresh host, an XDG +data-home vault default, a three-hour kernel unlock cache, encrypted SSH +delivery through the host Rust binary, a full-apply unit, and Pi-hole secret +file wiring are implemented. +Workspace checks, dummy credential delivery, repeated apply, rotation, reboot, +and VM smoke passed; +see [the acceptance record](../butane/ACCEPTANCE.md). The first smoke exposed +that the workstation denied expiry on keys +stored directly in the user keyring. Skillet now uses a named session keyring, +retained across CLI invocations through a link from the user keyring; its +permission and cross-invocation behavior were verified with a dummy key on +2026-09-28. Credential persistence passed an updated signed image deployment +and explicit digest-pinned rebase. Recovery after atomic credential install and +full-apply failure passed; a disconnect during streaming or encryption remains +untested. Production TPM assessment remains open. The Cloudflare creator uses +the implemented KDBX/account-token path; DDNS is a separate planned consumer. + +## Split into two small steps + +3A establishes the application-data mount. 3B delivers a disposable Pi-hole +password and proves recovery and rotation. Start with 3B on the existing VM; +settle storage before migrating production Pi-hole state. + +## 3A: application storage + +Decided: one Btrfs filesystem sharing space between OS and data, with a `data` +subvolume mounted at `/var/lib/data` and child `pihole` and `containers` +subvolumes. See [storage design](../design/storage.md). Final disk selection +remains open; the Samsung remains in rupik until cutover. + +Read-only SSH inspection of boxy confirmed: + +- `nvme0n1p2`, label `boxy`, supplies both `/os` mounted at `/` and `/data` mounted at `/var/lib/data`. +- System Podman uses driver `btrfs`, graphroot `/var/lib/data/containers/system`, runroot `/run/containers/storage`. +- Giacomo's rootless graphroot is `/var/lib/data/containers/giacomo`, also with driver `btrfs`. +- `sync` and `snapshots` exist under the data root. Listing all subvolumes requires privileges; passwordless sudo was unavailable, so the full subvolume inventory is not verified. +- Current mount flags include `noatime,lazytime,compress-force=zstd:1,discard=async,space_cache=v2,commit=120`. These are observed settings, not automatically selected defaults for clamps. + +User-reported existing fleet differences: calculon mounts a separate Btrfs filesystem at `/var/lib/data`; rupik mounts one at `/srv`. Boxy's layout is the preferred model for the new fleet. + +| Arrangement | Design consequence | +| --- | --- | +| Samsung for both OS and data | Requires a backup/restore or other explicitly designed repartitioning/install procedure; never assume the current data survives an OS installation | +| Stock SSD for both | Size the application data and headroom before committing; migrate data over the network or another agreed transfer route | + +After that choice, establish whether the Samsung's existing filesystem/subvolume structure should be preserved or its data restored into a new layout. Use a read-only inventory of disk identifiers, filesystem types, sizes, subvolumes and used space when access is available. Do not infer these from Chef's `/dev/sda3` default. + +Implementation decisions and checks: + +- Keep uCore's composefs root and Btrfs `/sysroot` and `/var` layout. The + existing VM uses `/dev/vda4` for the writable backing filesystem; prove the + new `data` mount across reboot and rebase. +- Mount the `data` subvolume at `/var/lib/data`. Place Pi-hole under + `/var/lib/data/pihole`, with its container `/etc/pihole` at `pihole/etc`. +- The shared Butane include sets system Podman's graphroot to + `/var/lib/data/containers/system` on a fresh installation and keeps overlay. + Verify SELinux labeling and the effective graphroot on a fresh VM. Existing + populated stores require an explicit migration or fresh VM. +- Use stable disk/filesystem identifiers. Ignition handles installation-time + formatting only for selected new/test storage; the shared Butane units create + and mount `data`. Skillet verifies the mount and converges application + directories, permissions and subvolumes without formatting populated disks. +- Make the data subvolume mount a prerequisite for Skillet, managed container + services and auto-update. A failed data mount should leave SSH available + where the OS can still boot, with dependent applications stopped and the + failure visible. Manually invoked Podman is outside these unit dependencies. + Loss of the shared physical disk also loses the OS; subvolumes do not provide + device independence. +- Model the selected layout on one disposable VM disk. Test a failed/unavailable data subvolume mount rather than removing the shared OS disk. + +Exit criteria for 3A: + +1. Correct data filesystem mounted at the intended resolved path; ownership/modes survive reboot and repeat apply. +2. A sentinel file survives reboot and an OS rebase. +3. An unavailable or wrong data subvolume mount prevents dependent application startup and prevents writes into a fallback OS directory. +4. Restoring the correct subvolume mount permits recovery through normal convergence without formatting or data loss. +5. Repeated apply changes neither mount configuration nor directory metadata unnecessarily. + +Fresh-VM graphroot, Pi-hole bind paths, SELinux label, reboot, repeated apply, +unavailable mount, wrong mount and recovery passed on `clamps-test-storage`. +The sentinel survived reboot and both mount-failure recoveries. Criterion 2 +remains partial until an OS rebase preserves the sentinel. Final disk selection +and a stock-SSD install are also open. + +Snapshots, Syncthing data transfer and final disk cutover are later milestones. Do not introduce all service subvolumes before their requirements are known. + +## 3B: one credential end to end + +Implement the [KeePassXC and delivery decision](../design/secrets.md): + +- Read exact KDBX entries in workstation Rust code. Use a disposable test database + to verify lookup, unlock failure and recovery on another workstation. +- Migrate the existing Cloudflare token creator from the desktop keyring to + `skillet/cloudflare/token-creator` through KeePassXC; never print its value. +- Start delivery with the existing `pihole_web_password` credential. Production + reads `skillet/hosts/clamps/pihole/web-password`; smoke uses a generated dummy + value, never the production entry. +- Deliver through SSH stdin after base readiness. Encrypt on the guest and + atomically install `/etc/credstore.encrypted/skillet/pihole_web_password.cred`. + Wire the full-apply service's `LoadCredentialEncrypted=` and Podman consumer. +- Preserve secret bytes according to an explicit input format; do not trim + meaningful whitespace implicitly. Validate encrypted output before replacement. +- Use host-key encryption for VM acceptance. Confirm production TPM support, + rebase compatibility, and disk protection for Podman's separate secret copy. +- Rotation must recreate consumers; unchanged credentials must not restart them. + A missing vault entry may create a production value only after the existing + vault is unlocked and the host confirms neither an encrypted credential nor + a Podman secret exists. A new workstation with a missing vault cannot create one. + +Cloudflare per-VM issuance and cleanup follow in the live ACME milestone, using +the [VM lifecycle decision](../design/smoke-vms.md). + +Exit criteria for 3B: + +1. One documented provisioning path delivers the dummy credential automatically; a fixture verifies receipt without printing it. +2. Source control and generated unit/Quadlet text contain no plaintext value; command arguments and captured logs do not expose it. +3. Restarts, reboots and an OS rebase retain credential usability under the chosen policy. +4. Interrupted encryption/delivery/cleanup can be retried without losing the only usable credential. +5. A rotation reaches the running fixture; a subsequent apply causes no further recreation. +6. Missing, corrupt or undecryptable credentials produce a clear failure and follow a documented recovery procedure. They never fall back to an empty/default production password. + +The first Pi-hole VM apply followed credential delivery. Storage acceptance is +required before production-state migration. Physical disk and production TPM +details still need resolution. diff --git a/ublue/plan/CLAMPS-UNIFI.md b/ublue/plan/CLAMPS-UNIFI.md new file mode 100644 index 00000000..7ea3a36e --- /dev/null +++ b/ublue/plan/CLAMPS-UNIFI.md @@ -0,0 +1,162 @@ +# UniFi container on clamps + +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + +Status: implementation and empty-controller acceptance passed on the +disposable x86_64 VM on 2026-10-03. UI backup import and restored-data +persistence passed on 2026-10-09. +Operator authenticated UI inspection and owned disposable cleanup passed. +Production migration remains pending. + +## Current behavior + +Chef recipe: `site-cookbooks/rupik/recipes/unifi.rb`. + +- Rootful Podman, `Network=host`, image `docker.io/jacobalberty/unifi:latest`, + `User=unifi`, `TZ=Europe/Madrid`, `Restart=always`, network-online ordering, + and registry auto-update. +- Rupik is ARM64; the running image package is + `10.0.162-32076-1`. Clamps is x86_64. +- `/srv/unifi` is bind-mounted to `/unifi`, currently 1.2 GiB on Btrfs. Data + files are owned by 999:999. The image user is UID/GID 999; Chef's host + account `unifi` UID/GID 2666 is not the data owner. `/unifi/run` is a + separate generated Podman volume. +- Active listeners: TCP 8080, 8443, 6789, 8843, 8880; UDP 3478, 10001. Local + HTTPS on 8443 returned 302. The Chef repo has no UniFi nginx route. +- Eight small `.unf` backups were listed through October 2026. Restore validity + and off-host availability are unverified. + +## Scope and implementation + +Implemented: reusable `skillet-unifi` module, called by clamps full apply. It +uses rootful Podman with mandatory host networking for device discovery and +adoption, `/var/lib/data/unifi` mounted at `/unifi`, the Chef image family, +`Europe/Madrid`, restart-at-boot, and registry auto-update. It requires the +shared data mount and prepares the service subvolume. The image's `unifi` user +owns the application state as UID/GID 999; Skillet sets those numeric IDs on +only the subvolume root and preserves existing application-owned files. The +host does not need matching account names. Rupik UID 999 is +`systemd-coredump`, not Chef's separate UID 2666 `unifi` account. + +Do not add a Caddy route or a new credential flow for UniFi. UniFi Site Manager +at `unifi.ui.com` provides remote access to a locally running controller. The +control plane, configuration, and device management remain on the server; local +management remains available on the host network. Remote access must be enabled +for the controller's UI account. + +## Migration and acceptance + +1. **Passed:** Test the Quadlet/module on a named disposable x86_64 VM with an + empty controller: service starts, remains active, uses intended ownership, + has `Network=host` for discovery/adoption, and survives repeated apply. + See `butane/ACCEPTANCE.md`. Reboot survival and local HTTPS passed; complete + expected host listener coverage remains a cutover check. Isolated restore + acceptance needed no Caddy/Cloudflare setup. +2. **Passed on 2026-10-09:** Restore one private rupik `.unf` Network backup + in a dedicated x86_64 VM + using a compatible application version. Complete the isolated acceptance + below. Site Manager access is a separate cutover check: the restored copy + must not contact devices or the cloud while rupik remains the live controller. +3. Before cutover, restore the selected backup on clamps, verify remote access + and device connectivity, and retain rupik plus an independent backup for + rollback. Do not run two controllers against the same devices concurrently. + +Routine tests already cover host-network Quadlet rendering, numeric data-root +ownership, mount refusal, repeated apply, and preservation of existing files; +the canonical profile selects UniFi for Clamps. Empty-controller runtime +acceptance passed on 2026-10-03. UI import, restored-data persistence, operator +inspection and disposal passed on 2026-10-09. Production/device acceptance +remains pending. Record new commands and results in `butane/ACCEPTANCE.md`. +Actual device migration remains unverified until scheduled cutover succeeds. + +Read `skillet/AGENTS.md`, `design/storage.md`, `design/secrets.md`, and +`design/smoke-vms.md` before implementation. + +References: [UniFi cloud architecture](https://help.ui.com/hc/en-us/articles/30319146145175-Understanding-UniFi-Cloud-Architecture), +[remote access](https://help.ui.com/hc/en-us/articles/360012192813-Introduction-to-UniFi), +[local management](https://help.ui.com/hc/en-us/articles/28457353760919-UniFi-Local-Management). + +## Isolated backup-restore acceptance + +Completed on 2026-10-09: UI import, repeat module apply, restored configuration +hashes, reboot persistence, operator authenticated inspection and final disposal +passed. Production/device/cloud acceptance remains separate. On 2026-10-08 +the workstation reached rupik but SSH +failed with `Permission denied (publickey)`. The user supplied a private October +1 Network backup (212,880 bytes); its original automatic-backup filename +identifies application version 10.0.162. Current source runtime access remains +unverified. Private `.unf` files are ignored by the Skillet checkout. + +1. Obtain the Network application backup through its Backups UI or read-only + source access. Keep it outside the checkout in a mode-0700 directory, with + mode-0600 files. Record its date, byte count and source application version + privately; never commit the backup, restored configuration, identities or + credentials. Retain an independent copy for eventual rollback. +2. Create a dedicated named disposable VM using the existing Rust lifecycle. + Keep the existing smoke VM untouched. Record the target application version + and image digest; the target must support the selected backup. Pull the + image before fencing network access. Do not copy the ARM MongoDB data tree. +3. Stop UniFi before importing anything. Install a **VM-only, persistent** + isolation policy ordered before UniFi starts: permit guest loopback, + the owned SSH management connection, DHCP and IPv6 neighbour discovery + needed to retain the management link after reboot; reject other IPv4/IPv6 + input and output. Disable automatic image updates during this test. NAT alone is + insufficient: it still permits outbound access to production networks and + cloud services. Do not enroll this restored controller for cloud access. + Before first activation on the empty guest, arm a short automatic rollback + timer. Prove fresh SSH connectivity, cancel the timer, then recheck the fence + before starting the controller or uploading the backup. Never arm a timer + that can open networking once production state is present. + Validate allowed SSH/local HTTPS and denied LAN, tailnet and public egress + before import and after reboot. An unavailable probe target does not prove + isolation; inspect the rules and their deny counters as well. +4. Inspect the installed application's supported restore interface. Prefer + its documented command if available; otherwise use its `.unf` restore UI. If authenticated UI + interaction is required, use a local browser through an ownership-checked + tunnel; add that capability to the shared VM transport if needed, never + bypass the lifecycle with a separately assembled SSH command. Do not guess + an undocumented restore API or transmit account passwords in CLI arguments. + The isolated driver is `cargo build --release -p skillet_unifi --example + acceptance_apply`: deploy its reported executable through the owned VM + transport and run it as root only on a prepared disposable guest. It invokes + the production UniFi recipe alone, without importing state or needing + credentials for unrelated services. Record module-only acceptance separately + from full-host acceptance. +5. Verify locally that restoration completes and the expected sites, networks, + WLANs and device inventory are present. Record only aggregate counts and + pass/fail evidence publicly. Devices staying offline is expected in this + isolated test. Verify rootful host networking, UID/GID 999 data ownership + and local management HTTPS. No Caddy/Cloudflare provisioning is required. +6. Repeat the production UniFi module apply with the isolated driver and + confirm the restored state remains intact. Record full-host acceptance + separately; the isolated driver does not configure the other services. + Reboot using the owned lifecycle, prove the fence remains effective before + UniFi starts, and confirm HTTPS and restored state survive. Ordinary apply + must never perform the import. Capture private failure diagnostics. +7. Stop the restored controller and destroy only the owned disposable VM. + Verify cleanup and retain the independent private backup. Record evidence + in `butane/ACCEPTANCE.md`; mark restore as passed only after these checks. + +Production adoption, inform-address changes, device forgetting/resetting and +Site Manager registration are excluded from the isolated test. Verify them +only during an explicitly scheduled cutover with the old controller stopped +and rollback available. + +Restore reference: [Ubiquiti Network backups and migration](https://help.ui.com/hc/en-us/articles/360008976393-Backups-and-Migration-in-UniFi). + +### Completed isolated acceptance — 2026-10-09 + +- Dedicated `clamps` / `unifi-restore` VM restored the supplied ARM controller + backup using x86_64 UniFi 10.0.162-32076-1. Aggregate state: two sites, one + network, four WLANs, three devices, one admin. +- Operator successfully logged in and confirmed devices/configuration look + correct. Repeat production module apply and reboot preserved network/WLAN + collection hashes; local HTTPS and persistent isolation passed. +- Stopped the controller and disposed the owned VM. Run artifacts are absent, + independent backup copies retained, and the existing smoke VM untouched. +- See `butane/ACCEPTANCE.md` for evidence and limitations. Complete individual + listener checks, device connectivity and Site Manager at scheduled production + cutover, with rupik stopped and rollback available. +- Email delivery is tracked in [the follow-up plan](CLAMPS-EMAIL.md). diff --git a/ublue/plan/DONBOT-INVENTORY.md b/ublue/plan/DONBOT-INVENTORY.md new file mode 100644 index 00000000..2616c075 --- /dev/null +++ b/ublue/plan/DONBOT-INVENTORY.md @@ -0,0 +1,228 @@ +# Calculon → donbot: source inventory + +Status: source review completed 2026-10-11. This inventories declarations, not +running services. Private Chef overrides, deployed image versions, effective +nginx configuration and live storage have not been inspected. The target name +is **donbot**. Implementation is tracked in [the migration plan](DONBOT-MIGRATION.md). +No literal domains, addresses, zone IDs or credential values belong here. + +## Source and scope + +Entry points: [role](../../roles/calculon.rb), +[default recipe](../../site-cookbooks/calculon/recipes/default.rb), and +[attributes](../../site-cookbooks/calculon/attributes/default.rb). +`default.rb` includes media, magiusstaff (which includes primary Syncthing), +Joplin, Airtrail and Gokapi, plus shared infrastructure and the excluded apps. +Several services require private attributes; inclusion alone does not establish +successful deployment. Default portal/media/notes domains are unset. + +Reuse uCore/Skillet's existing OS, users, hardening, SSH/ET, Podman storage, +bridge DNS, Quadlets, Tailscale, Pi-hole, btrbk, Datadog and SMTP support. Do not +reimplement Chef repository/kernel installation, Rocky Btrfs packages, zram, +Podman engine setup, web pods, nginx/OAuth scaffolding or distrobox installation. +App data ownership, mount semantics and routing choices still require migration. + +## Applications and access + +Ports below are **current backend ports**, not a proposed public firewall list. +Paths beginning `/` in the access column are nginx portal routes. OAuth applies +to the portal, with dedicated exceptions described below. + +| Service | Source image | Current access | Target access / scope | +| --- | --- | --- | --- | +| Prowlarr | `lscr.io/linuxserver/prowlarr:latest` | OAuth portal `/prowlarr`, 9696 | Tailnet-only UI | +| Radarr | `lscr.io/linuxserver/radarr:latest` | OAuth portal `/radarr`, 7878 | Tailnet-only UI | +| Sonarr | `lscr.io/linuxserver/sonarr:latest` | OAuth portal `/sonarr`, 8989 | Tailnet-only UI | +| Lidarr | `lscr.io/linuxserver/lidarr:nightly` | OAuth portal `/lidarr`, 8686 | Tailnet-only UI; confirm release channel before restore | +| putioarr | `ghcr.io/wouterdebie/putioarr:latest` | Shared web pod, 9091; no declared nginx UI route | Internal download integration for Arr; no public UI needed | +| Jellyfin | `lscr.io/linuxserver/jellyfin:latest` | Dedicated public HTTPS vhost when media domain is set, backend 8096, no OAuth | Public HTTPS, native application authentication | +| Plex | `docker.io/plexinc/pms-docker:latest` | Direct dual-stack host ports; **no Plex nginx vhost in this cookbook** | Public HTTPS; native Plex authentication and client discovery | +| Main Syncthing | `docker.io/syncthing/syncthing:latest` | OAuth portal `/sync`; GUI 8384, peer TCP/UDP 22000 | Tailnet-only GUI; peer connectivity is separate | +| Main Filebrowser | `docker.io/filebrowser/filebrowser:latest` | OAuth portal `/files`; backend 8080 via bridge-gateway port 8385 | Shared file-browsing UI on Syncthing hosts, tailnet-only | +| Tenant Syncthing | Same Syncthing image | Separate OAuth portal `/sync`; GUI gateway port 8386, peer TCP/UDP 22200 → 22000 | Deferred: preserve source data; no second instance in this migration | +| Tenant Filebrowser | Same Filebrowser image | Separate OAuth portal `/files`; backend 8080 via gateway port 8387 | Deferred alongside tenant Syncthing | +| Joplin Server | `docker.io/joplin/server:latest` | Backend 22300: public dedicated notes vhost **if configured**, otherwise OAuth portal `/joplin` | Preserve effective mode after private inventory | +| Airtrail | `docker.io/johly/airtrail:latest` | Dedicated vhost, backend 3214: OAuth **except `/api/*`** | Private UI; inventory whether authenticated public API access is still needed | +| Gokapi | `docker.io/f0rc3/gokapi` (implicit latest) | Backend 53842: OAuth admin plus public downloads/uploads/assets | Mixed: private administration, explicit public sharing routes | +| PostgreSQL: Joplin | `docker.io/postgres:18` resource default | Shared web pod, 5959; app connects over loopback | Internal named-container connection, no host publication | +| PostgreSQL: Airtrail | Same resource default | Shared web pod, 5858; app connects over loopback | Internal named-container connection, no host publication | +| Pi-hole | `docker.io/pihole/pihole:latest` default | Included through Tailscale recipe; web-pod backend 8088 by default, no calculon-specific UI vhost | Shared selected DNS service and private UI | +| Pi-hole synchronization | `ghcr.io/lovelaze/nebula-sync:latest` | Conditional hourly oneshot, only with enabled primary/replica/password settings | Inventory whether active; declare explicitly if retained | + +Sources: [media](../../site-cookbooks/calculon/recipes/media.rb), +[Syncthing/Filebrowser](../../site-cookbooks/calculon/recipes/syncthing.rb), +[tenant](../../site-cookbooks/calculon/recipes/magiusstaff.rb), +[Joplin](../../site-cookbooks/calculon/recipes/joplin.rb), +[Airtrail](../../site-cookbooks/calculon/recipes/airtrail.rb), +[Gokapi](../../site-cookbooks/calculon/recipes/gokapi.rb), and +[PostgreSQL resource](../../site-cookbooks/calculon/resources/postgresql.rb), +[Pi-hole](../../site-cookbooks/pihole/recipes/default.rb), and +[conditional Pi-hole sync](../../site-cookbooks/pihole/recipes/nebula_sync.rb). + +The tenant recipe logs “Skipping” for absent settings but does not return. +Check whether it is actually active; do not treat that log as evidence of absence. +The static portal also contains external links; a link is not another proxied app. +`mybibliotheca` has a path attribute but no application definition in this cookbook. +A Google Photos Takeout Helper binary is installed by the Syncthing recipe; +it is a manual utility, not a web service. An rclone template exists without a +recipe reference. Inventory manual uses before deciding either utility's fate. + +## Persistent state and jobs + +All paths below are **Chef defaults**. Confirm overrides, subvolumes, sizes, +ownership, ACLs, labels and application paths before any copy. + +| State | Current default under `/var/lib/data` | Migration consequence | +| --- | --- | --- | +| Arr configuration | `prowlarr`, `radarr`, `sonarr`, `lidarr` → `/config` | Preserve databases, API keys, base URLs and download-client configuration | +| Download integration | `putioarr` → `/config`; `media/downloads` → `/downloads` | TOML contains Put.io token, client password and Arr API keys; move private leaves through shared delivery | +| Media library | `media/{movies,series}/{downloads,library}`, additional yoga/pilates library | Downloads bind-mounted into movie/series trees; do not copy bind views twice | +| Music | `sync/music`; Lidarr `/music`, media servers `/data/music` | Preserve shared ownership; cross-subvolume moves cannot be assumed atomic/hardlinked | +| Jellyfin | `jellyfin` → `/config` | Accounts, library metadata and settings; series mount currently has `/dara/tvshows` typo—inspect stored library paths before correcting | +| Plex | `plex/config` → `/config`, `plex/transcode` → `/transcode` | Preserve server identity/library state; transcode contents are temporary | +| Main synchronization | `sync` → `/var/syncthing`; `.filebrowser` holds DB/settings | Move to declared target layout explicitly; keep browser state outside synchronized user data | +| Tenant synchronization | `magiusstaff/sync`, its own `.filebrowser`, default UID/GID 2002 | Keep separate from primary media/data account (default 2001) and Syncthing identity | +| Gokapi | `gokapi/files` → `/app/data`, `gokapi/config` → `/app/config` | Files plus SQLite metadata/config, salts and any encryption material must be restored together | +| PostgreSQL | `dbs/{joplin,airtrail}` → `/var/lib/postgresql` | Confirm running major/layout; source image default 18 is not runtime evidence | +| Airtrail uploads | `sync/airtrail` → `/app/uploads` | Pair uploads with a consistent database backup | +| Proxy state | `www`: nginx configuration, certificates and caches | Import only required runtime values; regenerate Caddy configuration/certificates, discard cache contents | + +Existing jobs: + +- btrbk snapshots **only `sync` and `magiusstaff/sync`**, hourly; minimum 6h, + retention `24h 31d 6m`. No whole-data snapshot policy. Existing shared btrbk + supports caller-selected paths; preserve leaf-only selection. +- Daily PostgreSQL `pg_dumpall` compressed with zstd into + `sync/backups/`; prune archives older than seven days. Restore helpers + exist. Atomic publication and coordination with uploaded files need review. +- Daily Airtrail uploads mirror into `sync/backups/airtrail/files`, with deletion + and up to four hours jitter; its timer is independent of the database dump. +- Daily Arr cleanup deletes files/directories older than seven days from shared + downloads. Replace only after defining how active/incomplete downloads are + excluded; age alone does not establish completion. +- Registry auto-update comes from the shared Chef container resource. Record + effective images/digests and choose compatible restore versions before enabling + updates; do not upgrade application and database majors during the initial copy. + +## nginx features to account for + +### Jellyfin + +The media vhost has **two server-side caches**, not just image headers: + +- Images: `/Items/(.*)/Images`, 15 GB maximum, 30-day inactivity, revalidation + and request locking. +- Video: regex under `/Videos/…`, 2 MB slices, 35,000 MB maximum, 90-day + inactivity, 30-day validity for 200/206/301/302, stale responses on failures. + A custom key includes selected transcoding parameters and byte slice. +- Browser cache headers vary by content type. There are `/` and `/web/` + redirects, `/socket` WebSockets, a method filter, CSP/security headers and + extension rejection rules. + +The video cache ignores upstream `Expires`, `Cache-Control`, `Set-Cookie` and +`X-Accel-Expires`; its key contains no user/authentication identity. This is a +source finding, **not a demonstrated live exploit**. Any retained shared cache +must prove authorization and revocation still apply on cache hits. Cache-Control +headers alone do not reproduce these disk caches. + +Caddy supports reverse proxying and WebSockets; Jellyfin recommends it for +HTTPS and requires correctly configured known proxies. See +[Jellyfin proxy guidance](https://jellyfin.org/docs/general/post-install/networking/reverse-proxy/) +and [Caddy reverse proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy). +Response caching needs an extra component: +[Caddy's cache module is nonstandard](https://caddyserver.com/docs/modules/http.handlers.cache), +with a [cache-handler implementation](https://github.com/caddyserver/cache-handler). +Neither source establishes parity with this nginx video slicing configuration. +Update, 2026-10-11: Caddy caching is selected for the target. Build and validate +`caddyserver/cache-handler` with a filesystem storage provider; see the +[Jellyfin cache plan](JELLYFIN-CADDY-CACHE.md). nginx video-slice parity remains +unproven and must be tested, not presumed from generic HTTP caching support. + +### Plex + +Chef publishes TCP 32400/8324/32469/1900 and UDP 32410–32414 on wildcard +IPv4/IPv6. Its firewall resource is named `syncthing`, also used by the actual +Syncthing recipe: inspect effective rules before relying on either declaration. +Discovery ports are not a proposed public streaming requirement. +Neither media container has a GPU mapping in the source. + +Plex supports custom server access URLs for reverse proxies. A proposed Caddy +endpoint should be advertised explicitly as `https://:443`: +omitting the port can make Plex use its configured Remote Access port instead. +Require client authentication; never add the proxy bridge to Plex's +“allowed without auth” list. Prove secure **direct** playback on actual clients, +not fallback through Plex Relay. See [Plex network settings](https://support.plex.tv/articles/200430283-network/). + +### Gokapi and Airtrail + +Gokapi skips OAuth for `/api/uploadrequest/chunk/…`, assets/css/js/static, +favicon/manifest, `/publicUpload…`, `/downloadFile…`, and broad `^/d.*` routes. +It permits 200 MB requests with 300-second proxy timeouts. The app trusts +`X-Forwarded-Email` (`Authentication.Method=2`); removing OAuth requires an +explicit replacement for that identity contract. Its config template uses +version 22 and salt fields that are not supplied by the recipe's template +variables. Restore effective state instead of regenerating it from that template. + +Derive an exact path/method list from the selected Gokapi version; retain public +sharing and guest-upload flows while denying administration and administrative +APIs externally. Use the planned verified tailnet identity gate plus Gokapi header +authentication for private administration, without a second password prompt; +strip client-supplied identity headers. Confirm whether the live zone is actually +Cloudflare-proxied before retaining `GOKAPI_USE_CLOUDFLARE=true`. +[Official Gokapi setup](https://gokapi.readthedocs.io/en/latest/setup.html) +describes native and header authentication; it does not justify copying these +legacy broad exemptions into Caddy unchanged. + +Airtrail exempts every `/api/*` request from OAuth. Its `ORIGIN`, +`SCRIPT_NAME=/airtrail`, and loopback-only app/DB settings need review when +moving to separate containers and a dedicated hostname. Establish which API +clients exist and whether application authentication protects those requests. + +## Filebrowser selection + +The legacy [File Browser project](https://github.com/filebrowser/filebrowser) +is archived and states it will receive no further security fixes. Keep the +requested private file-management capability, but review the application choice +before deploying that image fleet-wide. [FileBrowser Quantum](https://github.com/gtsteffaniak/filebrowser) +is a candidate to evaluate, not a selected replacement or a proven drop-in DB +migration. Preserve old browser DBs/configs until account/permission migration +is settled. [Mikochi and other candidates](../design/file-browsing.md) are now +being evaluated for the primary instance. The target uses Caddy tailnet identity +and no internal password prompt; no application has been selected yet. + +## Infrastructure retained through shared support + +Pi-hole is included by `tailscale.rb`; Tailscale is host-networked and persists +under `tailscale`. Chef advertises container subnets and an exit node, accepts +routes, disables subnet SNAT and enables masquerading. Current shared Skillet +supports selected exit-node policy but **does not yet implement all those routing +preferences**. Reconcile needed routing through the shared routing work in +[joey's plan](JOEY-MIGRATION.md); keep values private. + +Datadog checks include SSH/ET, Btrfs, process/network monitoring, nginx status +and both Filebrowser/Syncthing instances. Reuse the Agent; service modules own +new checks and test/lab alert exclusions. Preserve intended production location +through private configuration instead of copying old provider labels blindly. +The included Pi-hole cookbook can also run hourly Nebula Sync: DNS/DHCP and +other selected settings plus gravity data, with primary/replica credentials. +Its presence depends on private configuration; this behavior is not implemented +by the current Skillet Pi-hole module. Keep one declared synchronization owner +and disable production replication in smoke tests. + +No calculon-specific DDNS updater was found; opt in only if public addressing +actually requires it. nginx's `cloudflare true` configures trusted-proxy support; +it does not establish that DNS records are proxied through Cloudflare. + +## Deferred tenant + +Decision, 2026-10-11: do not port magiusstaff now. Keep both tenant rows above +as source evidence; its accounts, data, peer identity and snapshots remain +outside donbot implementation. Do not extend Skillet for multiple Syncthing +instances to satisfy this migration, or delete the source tenant state. + +## Explicit exclusions + +Do not migrate AdventureLog (frontend, backend and its PostGIS database/uploads), +Wanderer (web, PocketBase database and Meilisearch), or Openclaw and their ingress, +credentials and scheduled jobs. This does not authorize deleting source data or +removing shared dependencies needed by retained services. diff --git a/ublue/plan/DONBOT-MIGRATION.md b/ublue/plan/DONBOT-MIGRATION.md new file mode 100644 index 00000000..cd87ae76 --- /dev/null +++ b/ublue/plan/DONBOT-MIGRATION.md @@ -0,0 +1,283 @@ +# Calculon → donbot migration + +Status: **draft, inventory first**, 2026-10-11. Source inventory is complete; +private/live inventory and donbot implementation remain pending. The shared +file browser and identity gate are implemented for clamps/joey; their live +acceptance is tracked separately below. Read +[DONBOT-INVENTORY.md](DONBOT-INVENTORY.md) before changing code. Follow the +[refactoring roadmap](SKILLET-REFACTOR.md) and keep its parked acceptance visible; +this plan does not close those gates or claim fresh donbot acceptance. + +## Scope and prerequisites + +Retain Arr/putioarr, Plex, Jellyfin, Gokapi, Syncthing and a private file-browsing +UI. Joplin and Airtrail are retain candidates; establish their effective +mode/use in slice 0. **Defer the magiusstaff tenant**, including its second +Syncthing/Filebrowser instance and snapshot selection. Do not add multi-instance +support for this migration. Preserve its source data. Exclude AdventureLog, +Wanderer and Openclaw. + +Reuse the existing fleet baseline. Do not port Chef host/Podman setup. Only add +OS image packages where an actual service requires them; applications stay in +rootful containers. `ucore-images/recipes/ucore-donbot.yml` exists as a thin image; +a donbot profile/binary/Butane configuration does not exist yet. Do not clone +clamps-specific service choices such as UniFi or Home Assistant. + +Accepted access requirements: former OAuth-protected UIs become tailnet-only; +Plex/Jellyfin are reachable publicly without a tailnet client; mixed services +keep explicitly required public flows. HTTPS is mandatory for all web endpoints. +Architecture constraints and proposals are in +[public service access](../design/public-service-access.md). + +## Implementation slices and exit criteria + +Implement one slice at a time, with focused validated commits. Do not silently +turn the source inventory into an assumption about running state. + +### 0. Complete the effective inventory + +Read effective private Chef settings and live service/container/nginx definitions +without recording credentials. Record privately: image versions/digests, +application URLs and accounts, public IPv4/IPv6 and NAT/firewalls, +media sizes/mounts/subvolumes, hardware acceleration, backup/restore state, and +actual Jellyfin cache hits/load. Check manual putioarr binary overrides and the +Plex/Syncthing firewall resource collision. + +**Exit:** each retained service has a version, state/mount map and access policy; +resolve Joplin public versus private mode, Airtrail public API +clients, conditional Pi-hole synchronization, source subvolumes inside +synchronized data, and public reachability. +Keep private evidence outside Git. No source service is stopped or altered. + +### 1. Add donbot using existing baseline + +Add the canonical host profile, thin host binary, Butane selector/install inputs +and matching image policy. Select existing Syncthing, host-network Tailscale, +btrbk, Datadog and SMTP. Include Pi-hole for the intended third resolver and +mask resolved in the host image if selected. Choose a data account mapping that +preserves the source's shared media permissions without assuming workstation IDs. +Keep `/dev/vda` VM inputs and physical disk/root-profile choices distinct. + +**Exit:** profile drives credentials/UIs/monitoring/readiness; strict Butane and +local CI pass; a fresh named donbot VM reaches signed-image base readiness, +repeat apply and reboot preserve the shared data mount. No source peer identity, +production routes or public DNS writer is imported into a smoke VM. +Shared subnet routing/private DNS gaps stay tracked in joey's plan. If live +Nebula Sync is needed, add it as an explicit shared capability with private +primary/replica delivery and a single production owner; smoke must not replicate +production settings or leases. + +### 2. Extend generic ingress and verified tailnet authentication + +The current Caddy renderer makes **every** declared UI tailnet-only and the +Cloudflare lifecycle targets tailnet machine addresses. Extend the shared typed +model with explicit public/private policy and mixed path/method rules; default +to private. Host callers select services and policy; private values come from +templates/vault. Keep one renderer/delivery/provider ownership path, with a +versioned migration for retained private payloads. + +Add a reusable caller-selected identity gate for file browsing and Gokapi admin, +following [tailnet UI authentication](../design/tailnet-ui-authentication.md). +Resolve the actual connection peer through the existing host-network daemon +LocalAPI; validate user ownership and the explicit allowlist before upstream +access. Preserve Caddy-observed source addresses through rootful port forwarding. +Do not trust browser headers or grant every tailnet user administrative access. +Keep the allowlist/mapping in privately delivered configuration. The shared +Rust forward-auth gate now implements identity authorization for Mikochi. It +uses the existing daemon, without enrolling Caddy as another node. Expose only +the authorization endpoint to Caddy through a bounded trust boundary; +a read-only mount is not an API authorization policy. Validate the Headscale +LocalAPI identity path separately; current workstation enrollment is hosted +Tailscale-specific and this work does not claim Headscale lifecycle support. + +Keep public machine address records separate from tailnet machine records; +CNAMEs/aliases must target the declared access destination. Reuse DNS-01 and +zone-scoped issuance/cleanup. Address updates and certificate renewal have +different owners: select DDNS only when needed and reject competing writers. +Public sites use DNS-only records by default. Advertise AAAA only when routed +IPv6 has been verified. Public 443 must work with Tailscale disconnected; port +80 may redirect to HTTPS, but never serves an application in plaintext. + +**Exit:** CI verifies actual adapted Caddy routing with dummy names: public sites +reachable; private sites denied externally; mixed routes default-deny; aliases +have identical policy; unknown Host/SNI and spoofed identity/forwarded headers +cannot select a private upstream. Identity tests distinguish the two allowed +users, a denied user, a tagged device, unavailable WhoIs and altered headers; +Gokapi receives only verified identity and file browsing has no second login. +Use LocalAPI fixtures for CI, then an actual user-owned client for acceptance +(the tagged server VM cannot stand in for a human client). IPv4/IPv6 rules, +payload migration, DNS +ownership/retry/cleanup and credential redaction have tests. Named-VM HTTPS +acceptance covers both allowed and denied routes; separately prove real internet +reachability because passt/localhost testing cannot establish it. + +### 3. Add shared private file browsing + +Mikochi is selected for the current shared-root scope; Quantum and +Copyparty remain alternatives. See [file browsing](../design/file-browsing.md). +The shared module is implemented alongside Syncthing for clamps and joey. +Reuse canonical caller composition when donbot is added. Declare served folders, +read/write policy and data ownership. Keep app DB/config outside synchronized +user files, on persistent service storage; do not expose the entire data mount. +Use container DNS and a private Caddy hostname with no host GUI port. +**No internal username/password login:** reuse the verified tailnet-user policy +for the two intended users. For Mikochi, configure `NO_AUTH=true` only after +Caddy identity enforcement and backend isolation pass. A private IP-range +matcher alone does not satisfy this requirement. The shared authentication work +in slice 2 is a dependency before activating this UI. Confine the unauthenticated +backend to a dedicated private network with Caddy as its only proxy client; +prove isolation from host/LAN/tailnet and other application networks rather than +assuming that omitted host ports provide it. No file-browser password entry is +required by this policy; shared identity configuration remains audited. + +**Exit:** all declared Syncthing hosts select the same module; credentials and +monitoring follow that declaration. Tests cover mounting/ownership, absent +state, private ingress, duplicate names and repeat apply. A named VM can upload, +read and delete a sample file with expected permissions, persists settings after +reboot and denies an outside-tailnet request. Migration of existing browser +accounts/settings is explicit and the original DB is preserved. Both allowed +users enter without an app password; other users, tagged devices, anonymous +clients and direct backend connections are denied. A daemon outage fails closed. + +### 4. Port Arr and download integration + +Use a cohesive media module with typed per-app configuration and shared helpers, +not a new broad system trait or a parallel host service registry. Declare +Prowlarr/Radarr/Sonarr/Lidarr and putioarr as separate bridge containers. Replace +pod loopback references with container DNS; prefer dedicated private hostnames +and deliberately migrate old application URL bases. + +Resolve Put.io credentials, putioarr password and Arr API keys through existing +templates/delivery. Avoid literal secret environments; use supported secret +files or existing secret-aware runtime configuration. Preserve restored API keys +unless an intentional rotation updates every consumer. Review latest/nightly +channels and database compatibility before enabling automatic updates. + +Declare media/download/library mounts and identity mappings once. Prefer common +container paths for downloads/imports, following the +[Servarr mount guidance](https://wiki.servarr.com/en/sonarr/installation/docker); +verify hardlinks/atomic rename on the +actual filesystem instead of assuming every Btrfs subvolume shares that behavior. +Preserve separate synchronized music policy explicitly. Replace the age-only +cleanup job with a confirmed completed-download policy or leave it disabled. + +**Exit:** restored synthetic app configs can connect over bridge DNS, a harmless +sample transfer/import reaches the right library with correct ownership, private +UI routes work and no backend UI port is published. Repeat apply/reboot preserve +app state; secrets are absent from Quadlets/recordings. Any path remapping and +copy-versus-hardlink behavior is documented. + +### 5. Port Jellyfin, then Plex, with public HTTPS + +Use separate public hostnames, Caddy TLS and named bridge upstreams. Keep native +application authentication; backend HTTP remains confined to the bridge. +Migrate Jellyfin's known-proxy/client-IP policy, library paths and WebSockets. +Inspect the legacy `/dara/tvshows` path before fixing it. Declare GPU access only +for hardware actually present; preserve a CPU-only configuration. + +For Plex preserve server identity and library state. Publish the explicit custom +access URL `https://:443`, and test intended Plex clients through +that route. Do not expose discovery ports publicly or grant unauthenticated +access to the proxy subnet. A native Plex TLS port is an alternative only if +client acceptance demonstrates a need and its security policy is reviewed. + +**Exit:** a real client without Tailscale can authenticate, play, seek/range, +resume and load subtitles through trusted HTTPS on each service. Test direct +play and an available transcode path; Plex reports a secure direct connection, +not Relay fallback. Unauthenticated restricted APIs/media stay denied. +Unknown hosts and direct backend ports are inaccessible from the internet. + +**Caddy cache milestone selected:** implement the pinned Souin-based +`caddyserver/cache-handler` in a controlled Caddy service image. Follow the +[Jellyfin cache plan](JELLYFIN-CADDY-CACHE.md): image caching first, then prove +video/range support against the former nginx behavior. Keep disk state bounded +and outside Syncthing/snapshots. No nginx cache fallback or automatic waiver is +selected. An unsupported video feature remains an explicit migration gap; do +not claim slicing parity or enable unsafe caching to close it. + +### 6. Port Gokapi's mixed exposure + +Pin a compatible version and restore config plus file/metadata state together. +Replace OAuth header identity with the shared verified tailnet-user gate. +Use Gokapi header authentication with the complete verified identity mapped to +precreated application accounts for the two allowed users; no internal password +prompt. Decide their application roles explicitly and disable automatic creation +of arbitrary users. Public sharing routes get no administrative identity. Derive a version-specific public +path/method allowlist for downloads, guest uploads/chunks and required assets; +keep admin, account, API-key and management routes private. Do not translate the +broad legacy `^/d.*` exemption directly. Strip untrusted identity headers. + +Preserve the public sharing URL and existing links where required. Decide one +mixed hostname versus separate administration hostname based on supported base +URLs/cookies/origin behavior. Prefer a tailnet-addressed admin hostname and +public-addressed sharing hostname; verify that generated links and frontend API +calls use the correct entry point. Being connected to Tailscale does not identify +a browser request sent over the public IP. Carry file/chunk size and timeout requirements; +`GOKAPI_USE_CLOUDFLARE` follows actual proxy topology, not DNS provider choice. + +**Exit:** without Tailscale, an existing/restored share downloads and a guest +upload completes; expiration/limits remain effective. Admin routes and APIs deny +external clients even with forged headers, encoded paths or alternate methods. +Tailnet admin authentication works. Backup/restore retains metadata/files and +repeat apply preserves application-managed configuration. Do not cache share +responses unless expiry and counters are proven unaffected. + +### 7. Port PostgreSQL-backed apps + +Add a reusable PostgreSQL composition/backup helper for Joplin and Airtrail; +use named-container connectivity and secret delivery, not pod-local loopback. +Keep each app's state/version/credential declaration authoritative. Restore +logical backups into a compatible database major before planning upgrades. +Publish completed backup archives atomically into declared synchronized backup +folders; coordinate Airtrail files and DB state. Reuse shared SMTP where needed +without creating a remotely accessible unauthenticated relay. + +Implement Joplin's chosen public/private mode and Airtrail's agreed API policy. +The second synchronization/browser tenant stays deferred; preserve its source +state without importing it or selecting its snapshots. No multi-instance API +extension is required by this slice. + +**Exit:** Joplin client sync and Airtrail attachments/API work in their selected +modes, backup restore succeeds on disposable state, and repeat apply/reboot +preserve app/database state. No live production peers receive restored smoke +changes. + +### 8. Production rehearsal and cutover + +Record per-service copy/restore/rollback steps and verified backups, capacity, +ownership/ACLs and final path mappings. Snapshot only caller-selected leaves; +never snapshot all `/var/lib/data`. Raw database clusters and temporary caches +must not be synchronized. Rehearse using isolated state before final copy. + +**Exit:** all retained services have acceptance evidence; public HTTPS succeeds +from an external client without Tailscale; private/mixed denials are tested; +backup restore, reboot and repeat apply pass; DNS/DDNS has one production owner. +Stop source writers for the final consistent copy and preserve calculon for +rollback. Excluded app data is left untouched. Do not describe image publication +or VM tests as physical deployment or production acceptance. + +## Validation and handoff + +Each code slice runs the applicable checks from `AGENTS.md`, with routine CI +requiring no vault or external API. Real tests use a named disposable VM through +the Rust lifecycle owner; cache unlock prompts never block independent work. +Do not install workstation tools. Record honest evidence in +[ACCEPTANCE.md](../butane/ACCEPTANCE.md), update designs and this plan, and maintain +the shared secret-requirement catalog/root checklist when exact entries are added. +The shared identity allowlist is implemented at `skillet/tailscale/ui-users`. +Gokapi application-account mapping and other migration-specific inputs remain +to be designed. + +**Next action:** slice 0, a bounded read-only effective inventory. First code +slice is donbot baseline/profile, reusing the shared tailnet authentication +and file UI after their acceptance gates. Public ingress must be validated before importing public +service state. + +## Shared file browsing implementation (2026-10-11) + +Mikochi and the generic LocalAPI identity gate are implemented for existing +Syncthing callers (clamps and joey), including encrypted allowlist delivery, +canonical DNS/Caddy declarations, ownership, private backend and CI regressions. +Donbot still needs its profile; the shared composition will include file browsing. +Gokapi identity/account mapping and Headscale acceptance remain pending. See +[Butane acceptance](../butane/ACCEPTANCE.md) for live checks and limitations. diff --git a/ublue/plan/GENERIC-PRIVATE-UIS.md b/ublue/plan/GENERIC-PRIVATE-UIS.md new file mode 100644 index 00000000..ed0b420d --- /dev/null +++ b/ublue/plan/GENERIC-PRIVATE-UIS.md @@ -0,0 +1,281 @@ +# Generic private UI provisioning + +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + +Status: shared UI declarations, aliases, environment lookup, Caddy payloads, +Cloudflare account-token issuance, DNS reconciliation, and opt-in smoke VM +cleanup are implemented and passed live staging ACME/HTTPS acceptance on +2026-10-02. Unit tests cover ambiguous token creation, retry after partial DNS +reconciliation, and retry after interrupted DNS cleanup. The opt-in UI VM +provision path now probes each UI from the guest's loopback source and checks +Caddy's explicit 403 response. Remaining: denial from a separate network +outside the tailnet and production ACME/renewal acceptance; these are grouped +with Pi-hole LAN DNS acceptance in the [deferred live acceptance batch](CLAMPS-MIGRATION.md#deferred-live-acceptance-batch). + +Current revalidation (2026-10-04): the fresh disposable VM, real-runtime smoke, +credential-empty Tailscale enrollment, test Cloudflare lifecycle, and Caddy +staging certificate issuance passed; `test vm destroy` cleaned the test DNS, +token, Tailscale device, and VM. The issuer was confirmed in Caddy's guest +journal for every declared UI name, and HTTPS tailnet-denial probes passed. +Tailnet-client URL/DNS resolution, cryptographic certificate SAN/chain +verification, separate-network denial, and production ACME/renewal remain +unverified. + +## Goal and fixed decisions + +Every host declares its UI services. Shared provisioning derives each public +DNS name as `..` and delivers the configuration and +Cloudflare credential through the existing encrypted SSH delivery mechanism. +Syncthing is intended on every host; Pi-hole is included only where configured. +The caller selects the environment and declares the services. Caddy, DNS name +derivation, token issuance, and credential delivery must contain no clamps or +smoke special cases. Machine A/AAAA records are shared by all UIs; canonical +UI names and caller-declared aliases use CNAMEs. Every served name needs +certificate coverage and the same Caddy upstream/access policy. + +`host` means the host configuration name, such as clamps. It does not mean a +libvirt domain, Tailscale device name, or VM instance. Environment selects +configuration and policy; SSH target selects where to deliver. These are +separate fields. VM commands select the test environment; ordinary delivery +defaults to production. Never infer environment from hostname substrings. + +Keep every real UI domain, zone identifier, and credential in KeePassXC, never +in code, committed examples, plans, Ignition, or images. Values are stored in +the Password field, following the existing exact-lookup convention: + +| Entry | Purpose | +| --- | --- | +| `skillet/environments//dns/ui-domain` | Optional relative prefix; absent means `ui` (`` is `prod` or `test`) | +| `skillet/environments//hosts//cloudflare/acme-token` | Durable token for a persistent host (`` is `prod` or `test`) | +| `skillet/cloudflare/token-creator` | Workstation-only token issuer | +| `skillet/environments//dns/cloudflare-zone-id` | Environment-specific Cloudflare Zone ID of the authorized existing zone (`prod` or `test`) | + +Production and test are environment names, not distinct implementations. +Fetch the mandatory Zone ID's domain from Cloudflare and always append it to +the optional relative prefix. `ui.whatever` in zone `example.com` resolves to +`ui.whatever.example.com`; absent means `ui.example.com`, while empty is invalid. +Former full-domain values must be deliberately replaced or removed locally; +never silently treat them as absolute names or rewrite the vault. Cleanup must +resolve the prefix identically and compare the full namespace with recorded +ownership. Dispose of old UI deployments under their original configuration +before migrating. Keep unrelated host secret paths unchanged. + +The shared environment policy selects ACME staging for test and the normal +issuer for production. Token scope is Zone Read plus DNS Write for the selected +zone. This permits editing the whole zone, not just the derived names. The +master token stays on the workstation. Persistent hosts use-or-create their +token in KDBX; disposable deployments use a temporary token and recorded +ownership, with no disposable token value persisted in run metadata. + +## Read before implementation + +- `AGENTS.md`, `skillet/AGENTS.md`, `design/secrets.md`, + `design/private-ui-access.md`, and `design/smoke-vms.md`. +- `skillet/crates/cli/src/{main,secret_delivery,tailscale}.rs` and the vault + cache and secret-delivery tests. Reuse the current KDBX writer, conflict + checks, three-hour kernel cache, SSH host-key verification, and stdin delivery. +- `skillet/crates/cli-common/src/hosts.rs`, `skillet/crates/caddy/src/lib.rs`, + the Syncthing/Pi-hole resources, and `butane/includes/skillet.bu`. +- Current CI commands in `.github/workflows/ci.yml` and the real VM smoke + harness. Preserve the pending work; do not reset unrelated changes. + +## Implement in this order + +### 1. Shared host UI declarations and environment configuration (implemented) + +Introduce a typed UI declaration containing a stable service name and its +bridge upstream (container DNS name and port). Each host caller supplies its +enabled services and Podman network through one reusable host definition. +Use that same declaration for Caddy and workstation DNS planning; do not +maintain two lists that can drift. Clamps declares Syncthing and Pi-hole; +joey now declares both through the same profile mechanism and its own +bridge. Service-free and injected capability subsets test hosts without a +Pi-hole dependency. See [its migration plan](JOEY-MIGRATION.md). +Declaring future services must not require editing Caddy's renderer. + +Add a validated environment configuration and one hostname derivation helper. +Resolve the UI namespace from the Cloudflare zone domain plus the optional +relative prefix (default `ui`) using the same helper for delivery and cleanup. +Validate DNS labels, domain length, duplicates, empty service lists, and +upstream inputs before SSH delivery or remote API mutations. Reject schemes, +paths, ports, whitespace, and configuration injection in the base domain. +Validate the selected zone actually contains the base domain; never broaden +scope to another zone automatically. + +Exit: a non-clamps host with only Syncthing derives exactly one route; the +same host definition works with either environment. Use reserved example +domains in tests. Missing mandatory Zone ID config fails with the entry path, +without values; an absent relative prefix uses `ui`. + +### 1a. Caller-declared UI aliases (implemented; environment isolation tests pending) + +Extend each shared UI service declaration with a list of relative alias names +beneath the environment's UI domain; an empty list preserves current behavior. +Allow a `{host}` placeholder only as a complete DNS label. For example, +`sync.{host}` derives `sync..`; `sync` derives +`sync.`. Keep real domains out of caller code. Reject absolute +names, other placeholders, wildcards, and names outside the selected namespace. +Validate all expanded names globally: aliases cannot equal the machine name, +collide with another UI/canonical/alias name, or create loops. Validate before +unlocking for mutations or issuing tokens; environment values require an +unlocked vault before their validation. + +Derive one shared DNS plan containing the machine name, canonical UI names, +aliases, and CNAME targets; use the same resolved names for Caddy delivery. +Extend and version the Caddy payload for aliases, validate it against the +receiving caller declaration, and require explicit redelivery for old payloads. +Render each service's canonical name and aliases with the same upstream and +access restrictions. Caddy manages TLS coverage/renewal for every name; aliases +must serve the application directly rather than redirect to a canonical URL. +Certificate file/count layout is not an acceptance requirement. + +Exit: a host with zero aliases behaves as before; a host with multiple aliases +serves every name on its declared upstream. Separate tests cover name/target +collisions, invalid expansions, payload mismatch, and alias isolation across +environments. Use only reserved domains in tests. Document application host or +origin allowlists when an upstream requires changes to accept its aliases. + +### 2. Generic Caddy configuration and delivery (implemented) + +Replace `CaddySites { pihole, syncthing, ... }` with a versioned service-list +payload and explicit issuer policy. Keep the host credential name `caddy_sites` +unless changing it is necessary. Derive hostnames on the workstation from +the shared declaration; validate the payload again on the receiving host. + +Remove the clamps guard in Caddy apply. Pass the caller's network and services +into shared apply. Remove hardcoded `/var/usrlocal/bin/skillet-clamps` from +the delivery helpers; resolve the target binary from the validated host +definition. Shared Butane units invoke the generic `skillet` CLI with the +stable host profile in `/etc/skillet/host`; they must not derive host identity +from the VM hostname or bake a host binary name into shared configuration. +Host-specific apply units and credential gates belong in host-specific Butane +includes. The host include also stages the host credential CLI; the shared +Butane compiler builds and stages both static binaries when they are not +provided. Remove the CLI's clamps-only delivery parser/guard, but reject +unsupported hosts and undeclared services before unlocking/mutating resources. +Do not pretend unfinished host templates are ready for VM provisioning. + +Preserve deferred installation of both Caddy credentials followed by one +service start. Preserve recovery after partial delivery, idempotent Podman +secrets, tailnet access restrictions, persistent certificates, and secret-free +Caddyfiles. Use normal blocking systemctl starts, without `--wait`. + +CLI target: `skillet secret deliver caddy --environment production` +with the existing SSH and vault options. Production delivery uses the shared +host definition, environment config, credentials, and SSH transport. VM +provisioning reuses the Caddy payload and Cloudflare operations with its +recorded SSH transport and test environment. + +Exit: rerunning unchanged delivery preserves container identity. A host with +Syncthing only has no Pi-hole route. Existing old `caddy_sites` payloads and +per-service hostname entries require explicit redelivery; old payloads are +rejected rather than silently reinterpreted. First production delivery migrates +an existing legacy token into the environment-scoped vault entry. + +### 3. Shared Cloudflare token and DNS reconciliation (implemented; expand tests) + +Follow [Cloudflare UI lifecycle](../design/cloudflare-ui-lifecycle.md). Do not +use manually created VM ACME tokens as an interim acceptance path. + +Environment lookups use the DNS paths above. The configured Zone ID is fetched +exactly and the returned zone name must contain the UI domain before issuance +or record changes. The former zone-name entry is never treated as an ID. +The workstation issuer is an account-owned token with Account API Tokens Read +or Write and Zone Read on the selected zones. Skillet gets the account ID from +the zone response and uses account-token endpoints for permission discovery, +creation, listing, and revocation. The issuer needs no DNS Write. + +Use `cloudflare-rs` for authenticated blocking HTTP transport and custom typed +JSON endpoint specs for token APIs and DNS comments not represented by its DNS +models. Do not use a Cloudflare CLI or shell program. Discover permission IDs +through the API; do not guess or bake IDs in. +Persistent token creation must use the existing atomic KDBX writer and conflict +protection. If saving fails after issuance, revoke the newly issued token or +retain recoverable ownership before returning an error. Never replace an +existing host token implicitly; rotation is explicit. + +After Tailscale enrollment, reconcile one machine name `.` +with A and AAAA records for its verified Tailscale IPv4 and usable IPv6. +Report a missing family explicitly rather than creating an invalid record. +Create each `..` as a CNAME to the machine name; +create each caller-declared alias as a CNAME to its canonical service name. +Use short TTLs and `proxied=false` for every managed record. Zone DNS Write +covers A, AAAA, CNAME, and ACME TXT records with no extra permissions. +Reuse matching owned records; refuse unrelated conflicts, including existing +A/AAAA data at a desired CNAME name. Do not create a zone or configure +Tailscale split DNS. No deletion by name alone. Update machine addresses +without duplicating address records per UI. Reconcile removed aliases without +disrupting other routes or services; remove only owned obsolete records. + +Separate credential lifetime from UI configuration: a persistent deployment +reuses its vault token; a disposable deployment records token ID, expiry, +record IDs, environment identity, and target ownership using existing run +metadata. Both use the same issuance, DNS reconciliation, and delivery functions. +No token value, domain value, or secret appears in routine logs or diagnostics. +Local ignored mode-0600 metadata may retain private configuration needed for +recovery, but never token values. + +Journal external ownership immediately after each successful mutation. Use +deterministic ownership markers to recover a response-loss/crash window; refuse +ambiguous resources. Disposable cleanup removes only recorded/verified alias +CNAMEs, canonical CNAMEs, and then machine A/AAAA records before revoking its +token. If the token expired, mint and journal a temporary scoped +cleanup token through the issuer and revoke it afterward. Token expiry does +not delete DNS records. Do not sweep Caddy challenge TXT or unrelated records. +Failures retain enough state for retry and prevent discarding the run +directory. Persistent cleanup/rotation requires an explicit +operation and is never triggered by a VM disposal command. + +Exit: new DNS paths and Zone ID validation are covered; issuance requires no +manually populated VM ACME entry; cleanup works after token expiry; both +environments use the same reconciler; a second apply creates no +duplicate token or records; failures at each external mutation can resume or +clean up. The retry tests inject API failures after token creation, between DNS +record creates, and during DNS deletion. Concurrent deployments claiming the same host/environment names +are refused. Do not append VM instance names to URLs silently; supporting +parallel instances requires separately selected base domains/environments. + +### 4. Wire VM lifecycle and verify live ACME (staging verified; two checks deferred) + +`test vm provision --with-ui` explicitly opts into Cloudflare +and Caddy. Standard fixture smoke remains offline from Cloudflare. Provisioning +uses the test environment, journals disposable ownership, then creates the +token, records, delivers the config, and activates Caddy after Tailscale joins. +Destroy reconciles Cloudflare resources and Tailscale identity before local +ownership metadata is deleted. Missing vault entries name only required paths. + +Reuse the retained VM for development, then run one fresh destroy/create/ +provision cycle for final acceptance. User unlocks the KDBX in their own terminal; +subsequent commands reuse the three-hour cache. Do not print secret values. + +Exit: staging DNS-01 succeeds, certificates have the derived service names, +and HTTPS reaches every enabled upstream through its canonical name and all +aliases from an authorized tailnet client. Verify the machine A/AAAA and UI +CNAME chains through the real client resolver, including IPv6 when available. +Staging certificates are untrusted: verify staging trust/identity explicitly; +do not claim trusted-browser acceptance or use unchecked `curl -k` as proof. +Verify actual client DNS resolution, denial outside tailnet, absence of direct +Syncthing GUI host publication, unchanged repeat apply, reboot persistence, +interrupted two-credential delivery recovery, and cleanup on destroy. Production +values and DNS records remain untouched. Verify renewal using a bounded explicit +scenario; if not exercised, record it as outstanding rather than passed. + +## Required validation and documentation + +Add meaningful separate test modules covering hostname/config validation, +single/multiple service and alias rendering, host/environment selection, +machine A/AAAA and CNAME reconciliation, alias removal, API ownership, +idempotency, cleanup, and interrupted issuance/delivery. Mock HTTP for CI so +routine CI needs no vault, Cloudflare token, or real DNS zone. Cover a second +host definition to catch clamps dependencies; do not require unfinished hosts +to boot before their templates exist. + +Run workspace formatting, pedantic Clippy, tests, the workflow's integration +command, and affected real VM checks. Record exact commands/results and remaining +limits in `butane/ACCEPTANCE.md`. Update README command/vault setup instructions +and the three design documents as implemented behavior lands. Link this plan +from the migration plan; remove it only after preserving all remaining tasks. +Make focused commits only after required checks pass; do not install workstation +packages or push without the user's instruction. diff --git a/ublue/plan/HOME-ASSISTANT.md b/ublue/plan/HOME-ASSISTANT.md new file mode 100644 index 00000000..9bf11128 --- /dev/null +++ b/ublue/plan/HOME-ASSISTANT.md @@ -0,0 +1,156 @@ +# Home Assistant on clamps + +Status: planned, 2026-10-11. No Home Assistant deployment or acceptance is +implemented. This is an optional addition, not a Chef parity requirement or an +additional gate for retiring rupik. + +Prerequisite: respect the remaining gates in the +[refactoring roadmap](SKILLET-REFACTOR.md). Implement one slice at a time and +validate it before the next. Follow `../AGENTS.md` and `../skillet/AGENTS.md`. +Read the [Home Assistant decision](../design/home-assistant.md), +[storage](../design/storage.md), [Syncthing](../design/syncthing.md), +[btrbk](../design/btrbk.md), [private UI access](../design/private-ui-access.md), +[secrets](../design/secrets.md), [monitoring](../design/monitoring.md), +and [VM lifecycle](../design/smoke-vms.md). + +## 1. Optional container and persistent state + +- Add a reusable Home Assistant service module using the existing resource and + Podman APIs. Declare it in the canonical clamps host profile; UIs, monitoring + and credentials must consume that same selection. Other profiles stay opted + out unless their caller explicitly selects it. +- Pin a current official `ghcr.io/home-assistant/home-assistant` release and + record its tested digest. Use rootful `Network=host`, restart-on-failure/boot, + and a 60-second container stop timeout with sufficient systemd stop time. + Disable unattended registry auto-update initially; application upgrades need + a completed backup and a tested restore path because database migrations can + prevent rollback by merely reverting the image. +- Require the shared data mount before preparation and startup. Prepare service + storage under `/var/lib/data/homeassistant`; bind its `config` directory to + `/config`. Use typed ownership without recursively rewriting existing files. + Routine apply preserves Home Assistant-managed accounts, integrations, + automations, database and internal `.storage` state. +- Model optional devices explicitly, using stable serial identities where + available. Add D-Bus/Bluetooth permissions only when selected. Validate the + minimum working capabilities and SELinux policy; do not disable host SELinux + or assume the upstream privileged example is a required global policy. +- Add module-owned Datadog process/health declarations when the monitoring + capability is selected. Never require a Datadog key solely to run this service. + +Exit: routine tests cover selection/omission, rendering, missing/wrong mounts, +ownership, retained state, stop timeout and repeat apply. A named disposable VM +starts an empty instance and preserves synthetic state across apply and reboot +with SELinux enforcing. Real radio discovery remains a physical check. + +## 2. Generic private UI access + +- Extend the shared UI upstream model to distinguish bridge service DNS from + a host listener. Preserve existing Pi-hole/Syncthing payloads through an + explicit compatibility path if the credential schema changes. Keep host, + environment, instance and SSH target separate. +- Declare `homeassistant` and derive its hostname/aliases through the existing + generic machinery. Reuse the selected environment's zone, UI prefix, token + issuance, A/AAAA/CNAME reconciliation and owned disposal. +- Keep Caddy on its bridge. Implement and verify a host gateway route, such as + `host.containers.internal:8123`; refuse a missing/unusable gateway. Configure + Home Assistant's forwarded-header trust for the actual proxy source and + restrict raw listener access so LAN or tailnet clients cannot bypass Caddy. + Network discovery must still operate independently of HTTP listener policy. +- Inspect the pinned release's supported initial HTTP/proxy configuration. + Current documentation describes UI-managed settings since 2026.8; do not + repeatedly overwrite `.storage` or assume a legacy YAML block remains the + convergence interface. Document any required one-time onboarding step. +- Preserve Home Assistant authentication. Do not add OAuth proxy, Tailscale + split DNS or Pi-hole UI-zone configuration. + +Exit: CI covers host upstream validation, existing bridge routes, aliases, +payload migration and private-value redaction without provider credentials. +Live test-environment acceptance verifies HTTPS onboarding/login, WebSocket +traffic, declared DNS ownership, and raw-port denial from unauthorized sources. +A request from outside the tailnet is denied. Use the existing bounded lifecycle +and record any unavailable source/network checks as pending. + +## 3. Scheduled backups and safe publication + +- Use Home Assistant native automatic backups, initially daily with seven local + copies and encryption explicitly enabled. Inspect/configure the supported + backup interface for the pinned release; document any one-time UI setup. + Container archives are under `/config/backups`, corresponding to + `/var/lib/data/homeassistant/config/backups` on the host. +- Add a bounded Rust export workflow using existing effect boundaries. Establish + successful completion through the supported backup state/API, not file age, + a guessed sleep, or an archive filename. Read original encrypted archives; + the ordinary UI download decrypts backups and is unsuitable for this export. +- Copy a completed archive to an ignored temporary file in the destination + filesystem, verify bytes, then rename atomically into + `/var/lib/data/syncthing/backups/homeassistant`. Interrupted work must be + safely retryable, duplicate exports must be no-ops, and malformed/incomplete + archives must never be published. Do not embed shell programs in Rust. +- Create the destination as an ordinary directory in the existing Syncthing + leaf. Configure it as a shared folder with an approved receiving peer; do not + assume that a directory under the subvolume is automatically replicated. + Reuse mapped Syncthing identity and typed ownership/ACL helpers. Give the + exporter read access to source archives and Syncthing read access to published + ones without broad access to the application tree. +- Keep btrbk's clamps selection at `syncthing`. Never snapshot the entire data + mount or add nested subvolumes. Define receiving-side history/retention + explicitly so local pruning or synchronization cannot erase every recovery + copy. Export pruning must never delete the last verified recovery copy. +- Retain the native encryption key/emergency kit in KeePassXC and independently + back up the encrypted vault. If exporting requires an application API token, + use supported least-privilege access and existing encrypted delivery, never + command-line credentials. When implementing key/token requirements, update + shared secret-audit metadata and regenerate the root secrets checklist; + distinguish operator-created recovery material from generated test secrets. +- Expose export/backup success and stale-backup failure through module-owned + monitoring, respecting existing test/lab alert exclusions. + +Exit: CI covers completed-only selection, atomic publication, interruption/retry, +identical re-export, corrupt input, missing mount, permissions and retention +using synthetic data. Live acceptance produces an encrypted archive, exports it, +proves a real peer received matching bytes, and includes it in the existing +btrbk snapshot. An unavailable peer is recorded as an open check, not success. + +## 4. Isolated recovery acceptance + +- Create a separately owned, named disposable VM/instance. Use generated test + accounts/integrations and a private test encryption key, never the production + automation identity. Establish persistent network isolation before importing + sensitive state; do not rely on NAT to block outbound device/cloud access. +- Restore a replicated archive into a fresh compatible Container installation + through its supported restore interface. Keep restored test state isolated, + including after reboot. Prove settings, synthetic automation and a known + database marker survive restoration, routine apply and reboot. +- Demonstrate wrong/missing-key refusal and preservation of the source archive. + Restore the archive from a disposable copy of the Syncthing btrbk snapshot + separately to prove historical recovery, without modifying the live leaf. +- Dispose only owned resources through the existing Rust lifecycle, including + test DNS/token/device cleanup. Never export backups or keys into public logs. + +Exit: both replicated-archive and snapshot recovery work; original backup and +independent recovery key remain usable. No restored instance contacts live +home devices or production cloud integrations. Record aggregate evidence and +limitations in `../butane/ACCEPTANCE.md`. + +## 5. Production activation and documentation + +- Confirm the actual device/integration inventory before requesting radios, + Bluetooth permissions or companion services. MQTT/Zigbee2MQTT and similar + separate containers need their own state/backup plans; Home Assistant's + Container backup does not back them up automatically. +- Configure production UI/credentials through the existing vault workflow; + validate real-client DNS, HTTPS, login and device discovery on clamps. + Keep smoke names, identities, archives and destinations separate from prod. +- Confirm daily encrypted backup, last-success monitoring, off-machine receipt, + recovery-key availability and free-space/retention policy. Keep an independent + recovery copy before an application update and test restore compatibility. +- Update designs and operational README instructions, including onboarding, + private URL, backup export, key storage, restore and upgrade rollback. + Use placeholders in the public repo; never commit real domains, identifiers, + archives or credentials. Install nothing on the workstation. + +Exit: run all applicable local CI checks and named-VM application acceptance; +record unavailable hardware/provider checks honestly. Make focused commits after +validation; do not push unless authorized. Update each slice's implemented vs +pending status, and keep physical discovery/production acceptance distinct from +VM acceptance. diff --git a/ublue/plan/JELLYFIN-CADDY-CACHE.md b/ublue/plan/JELLYFIN-CADDY-CACHE.md new file mode 100644 index 00000000..bdebebec --- /dev/null +++ b/ublue/plan/JELLYFIN-CADDY-CACHE.md @@ -0,0 +1,107 @@ +# Jellyfin caching through Caddy + +Status: implementation planned, 2026-10-11. Caddy caching is selected for donbot; +no nginx fallback is selected. Prerequisites are the shared ingress work in +[donbot migration](DONBOT-MIGRATION.md) and the existing +[refactoring gates](SKILLET-REFACTOR.md). The source image and 2 MB video-slice +caches are recorded in [the inventory](DONBOT-INVENTORY.md). + +## Target and boundaries + +Use pinned `github.com/caddyserver/cache-handler` (Souin-based). Build a controlled +Caddy service image in CI with Cloudflare DNS support and the selected storage +module; do not compile plugins on guests or install tools on the workstation. +Verify compatible pinned versions and required modules in the built image. +Application-image tooling remains separate from uCore host packages. Include the +cache/storage modules through the same build. Tailnet UI authorization now uses +the shared Rust forward-auth endpoint and does not need a Caddy auth plugin. + +Start with a filesystem provider, evaluating +[Simplefs](https://docs.souin.io/docs/storages/simplefs/) for the compatible pinned +cache release. Provider sizing/eviction semantics must be demonstrated; an entry +count, per-directory option or Go heap limit is not a proven total disk cap. +Use `/var/lib/data/caddy/cache/jellyfin` as disposable cache state, separately +mounted from certificates. Keep it out of Syncthing, btrbk and backups. Bound +both resident memory and total disk bytes; establish limits from host capacity +and source usage. No external Redis/database cluster is required by this plan. + +Expose caching as an optional typed service policy; private UIs, Gokapi, Plex and +unrelated APIs are not cached simply because Jellyfin selects it. Render ordered +routing explicitly and validate with the actual built Caddy binary. Configurable +policy includes allowed routes/methods, response sizes, TTL, storage caps and +keys. Real hostnames and any private values stay outside the repository. + +## Implementation order and exits + +### 1. Build and prove the cache component + +Add the reproducible service-image build and tests with an instrumented synthetic +upstream. Check module loading, adapted configuration and storage ownership. +Use only safe read methods and opt-in response statuses. Cache management/purge +APIs stay on a restricted local interface, never the public media site. Disable +credential-bearing debug logs and exposed cache keys. + +**Exit:** a second permitted request demonstrates a real cache hit, expiry and +purge work, the chosen provider evicts/bounds disk and memory, and cache loss or +corruption can be cleared without harming Caddy certificates or service state. +CI performs these checks without KeePassXC, Cloudflare or live library data. + +### 2. Enable approved image/static routes + +Inventory the selected Jellyfin version's exact routes and auth/cache behavior. +Start with approved images and static assets; exclude login/session, user/profile, +admin APIs and responses with `Set-Cookie` or no-store/private policy unless a +specific reviewed, isolated policy exists. Never globally bypass upstream cache +headers. A Cache-Control header alone does not establish a server cache. + +Retain scheme, host, full path/query and representation inputs in keys. For +protected requests separate authentication inputs and hash keys; query tokens, +Authorization and Jellyfin auth headers must not appear in logs or Cache-Status. +A token-specific key alone does not enforce revocation or changed library rights: +protected hits require upstream authorization/revalidation before returning bytes, +with no stale-on-error bypass. Prove that the consumer/module can enforce this; +keep unsupported protected routes uncached and report the gap. + +**Exit:** image cache hits and invalidation are proven against real Jellyfin; +changed image size/format/query parameters do not collide. Tests cover two users, +missing/changed/revoked tokens, changed library access, errors and cookies. +Protected data is never served merely because it was previously cached. Benchmark +image loads and observe upstream work/latency without recording private requests. + +### 3. Prove video and range caching before enabling it + +Evaluate the pinned module against real media routes and synthetic byte fixtures: +200/206/416, matching and different ranges, repeated seeks, If-Range/ETag changes, +HEAD versus GET, transcoding/subtitle/query variants and concurrent readers. +Preserve content length/range/encoding and request limits. Exclude live streams, +WebSockets and mutation routes. Bound the maximum cached response size and avoid +buffering entire large videos into memory. + +The former nginx 2 MB slice scheme is **not assumed to exist** in cache-handler. +Determine whether bounded segment/range caching provides equivalent useful +behavior. If it does not, record the exact missing feature and leave that route +uncached pending a Caddy-compatible solution; do not silently declare video cache +parity or substitute nginx. Image caching can be accepted as an intermediate +slice while video acceptance remains pending. Authorize protected cache hits as +in slice 2, and do not serve stale protected video after upstream/auth failure. + +**Exit:** byte-for-byte range/seek and concurrent playback tests pass, cache +contents/keys cannot cross representations or authorization boundaries, and real +clients can play/resume/transcode without excessive memory/disk growth. Record +any difference from the original slice cache before production cutover. + +### 4. Lifecycle and monitoring acceptance + +Module-owned metrics cover cache hits/misses, upstream requests, failures and +storage pressure without tokens or private URLs. Repeat apply preserves declared +policy and certificates; reboot starts correctly with retained or absent cache. +Changing/removing a cache policy must not delete application data or enable +caching elsewhere. Pin/update the service image through the shared container +workflow and record named-VM evidence in +[ACCEPTANCE.md](../butane/ACCEPTANCE.md). + +Sources: [cache-handler](https://github.com/caddyserver/cache-handler), +[storage/build integration](https://docs.souin.io/docs/middlewares/caddy/), and +[Jellyfin proxy requirements](https://jellyfin.org/docs/general/post-install/networking/reverse-proxy/). +This plan specifies acceptance targets; no plugin build, cache benchmark or video +range acceptance has run yet. diff --git a/ublue/plan/JOEY-MIGRATION.md b/ublue/plan/JOEY-MIGRATION.md new file mode 100644 index 00000000..b169f57d --- /dev/null +++ b/ublue/plan/JOEY-MIGRATION.md @@ -0,0 +1,70 @@ +# Boxy → joey + +Status: host/boot slice implemented; native base readiness and fixture +repeat-apply/reboot accepted, 2026-10-11. Full application/provider acceptance +is pending a vault unlock; the rebuilt image change and remote CI are unverified. +Host identity changed to `joey` on 2026-10-11; the earlier native acceptance +used its previous identity. Fresh joey boot/application acceptance and publication +of `ghcr.io/gbagnoli/ucore-joey` remain pending. Existing VM manifests and vault +entries are not rewritten or accepted as joey implicitly. +Review completed against +`roles/boxy.rb` and `site-cookbooks/boxy/recipes/default.rb` and its includes. +Private Chef overrides and live boxy state have not been inventoried. +Clamps remaining work is tracked in [its migration plan](CLAMPS-MIGRATION.md). + +## Source parity + +| Boxy behavior | Target / remaining work | +| --- | --- | +| One Btrfs filesystem, separate data subvolume; rootful/rootless Podman stores | Shared root + `/var/lib/data`; rootful overlay graphroot; retain rootless storage only for workloads that need it | +| Pi-hole; resolved disabled | Selected shared Pi-hole and bridge DNS policy; image/day-zero mask; private runtime DNS delivery/v6 convergence and LAN acceptance pending | +| Syncthing data `/var/lib/data/sync`, dedicated account and ACLs | Shared `/var/lib/data/syncthing` leaf, declared host ownership/user mapping; transfer folder definitions, identity and ACLs explicitly, never copy live identity into smoke | +| btrbk snapshots `sync` only | Caller opts in for `syncthing` only; preserve leaf-only snapshots and settle production retention/off-machine backup | +| nginx/OAuth/ACME, local and cross-host routes | Generic Caddy, declared Pi-hole/Syncthing UIs and service alias policy; Cloudflare credentials and tailnet ingress reused; inventory needed cross-host routes before omitting them | +| Host-network Tailscale, exit node, LAN subnet advertisement, accept routes, SNAT disabled | Shared host-network persistent Tailscale/exit-node selection; **subnet advertisement, route acceptance and SNAT policy are not yet implemented** | +| Cloudflare DDNS | Shared selected updater/template; per-environment, per-host private record-name entry; one writer at production cutover | +| Datadog host/process/network checks and private cross-host probes | Shared selected Agent, production region `ftwo`, test `lab`/`env:test`; private remote probes not yet ported | +| ET and server baseline | Common image + shared hardening/user environment; shared SMTP also selected for every fleet server | + +UniFi is absent from boxy's default recipe; joey does not select it. +No private addresses, domains, keys or effective Chef overrides belong here. + +## Implementation order and exits + +1. **Implemented; native boot/fixture accepted:** canonical `joey` crate/profile/image, shared + full-apply unit, host Butane with existing `/dev/vda` and Btrfs layout. + Use the new identity consistently without compatibility aliases. + Profile selection must drive UIs, credentials, monitoring and snapshots; + tests cover those declarations and bootstrap inputs. The matching image + recipe must mask resolved. Validate strict Butane compilation, local CI + and a fresh named VM's signed boot/base readiness. Real application and + provider checks remain separate. +2. **Tailscale routing parity:** add explicit shared caller-selected routing + policy and private delivery for subnet values. Preserve existing enrollment + compatibility, converge preferences after authentication, and force disposable + policy to omit advertisements. Test rendering, validation, repeat convergence + and no production routes on smoke machines. Do not publish the LAN subnet. +3. **Private Pi-hole configuration:** replace static/sample DNS declarations + with private runtime template delivery shared by both hosts. Use Pi-hole v6's + supported configuration API/format; current `custom.list` rendering and the + manual accepted `dns.hosts` test do not prove automatic v6 convergence. Validate + empty/configured records, repeat apply, reboot and private-value redaction. + Settle whether omission preserves restored records before production import. +4. **Named application acceptance:** unlock with the existing workflow; run + joey full provisioning and capability-selected repeat-apply/reboot checks, + then opted-in UI, DDNS, Datadog and isolated SMTP checks. Use disposable + identities, test DNS and lifecycle-owned cleanup. No production writer/peer + identity during acceptance; record failed and unavailable checks honestly. +5. **Private inventory and cutover:** inspect live boxy storage/subvolumes, + Syncthing identity/folders/ACLs, Pi-hole settings, routing and cross-host + monitoring/UI needs. Select real hardware/disk/root profile, snapshot/backup + retention and rollback. Stage copies with original writers stopped as needed; + do LAN/device and production certificate acceptance before moving addresses. + +Use `skillet test vm create joey INSTANCE --port FREE_PORT`, then +`test vm ready`, `test vm provision`, `test smoke --with-applications` and +`test vm destroy` through the shared Rust lifecycle owner. Provisioning may +need cached vault access; do independent work if unlock would block. Install +nothing on the workstation. Commit focused changes after validation; do not push. + +Evidence: [ACCEPTANCE.md](../butane/ACCEPTANCE.md). diff --git a/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md new file mode 100644 index 00000000..0c6cdc23 --- /dev/null +++ b/ublue/plan/REFACTOR-01-VM-LIFECYCLE.md @@ -0,0 +1,149 @@ +# 1. One VM lifecycle owner; migrate Bash management to Rust + +Status: implementation and required VM acceptance complete. Fresh/retained +cycles, live legacy-manifest import, and interrupted provisioning recovery +passed. See the [roadmap](SKILLET-REFACTOR.md) and 2026-10-05 acceptance entry. + +## Progress, 2026-10-03 + +Implemented foundation: `skillet_vm` owns validated host/instance identity, +backend/connection and SSH types, version-1 `vm.json`, atomic mode-0600 writes, +and explicit legacy import. Reads do not rewrite retained runs. Legacy files, +captured/deployed hashes and provider journals remain intact. The generic CLI +uses the shared identity validator. Manifest regressions cover strict import, +preserved journals/hashes, permissions, ownership, and path refusal. + +Rust now owns status inspection and disposal through `VmBackend`, a bounded +native/Flatpak libvirt adapter, and one locked cleanup orchestrator. Both +public destroy paths use it. Successful lists distinguish an absent guest +from a failed connection; UUID and disk ownership are checked before cleanup +and again after external calls. Failed cleanup retains its manifest/journals. +Rust also owns template/run listing. Shared Cargo JSON artifact discovery is +implemented and used by the routine container runner; a custom target-directory +integration check selects the reported binary. `GuestTransport`/`SshTransport` +now provide explicit target/key policy, literal remote executable arguments, +noninteractive execution and upload. Subprocess stdin/output stay in memory, +with bounded concurrent I/O and zeroized copied stdin. Seventy-five VM tests +pass. Port preflight tests inject the bind result so test success does not +depend on a free port in the reserved workstation range. +No live VM was changed or imported. + +Retained binary updates now use Rust's shared lock, ownership checks, Cargo +artifact discovery and SSH transport. Both installed artifacts are verified +before deployed hashes are saved; original capture evidence is preserved. +Readiness now uses Rust's shared transport and lock, bounded SSH/signed-boot +probes, captured artifact verification, explicit profile boot expectations, +base/user-environment checks and mode-0600 diagnostics. The CLI, create path, +`test-vm ready` and the RUN_DIR compatibility helper share that implementation. +Identity for the compatibility helper comes from validated recorded fields. +Readiness first persists `Started`, invalidating any earlier success; it saves +`Ready` and deployed hashes only after acceptance and a final ownership check. +Every guest command and upload checks ownership before and after the operation, +so a change during a long service start stops subsequent guest work. +Rust now selects and prepares native or Flatpak libvirt, resolves the FCOS +image, builds its two guest artifacts, prepares the disk/key, stages and +compiles Butane through the standalone compiler, defines/starts the guest, and +runs readiness from one create command. Matching interrupted runs resume from +the manifest. The shell `test-vm` interface only translates arguments; the +independent Bash installer and hidden CLI bridge commands are retired. No +remaining script owns VM creation, reboot, access, inspection, or disposal. +Workstream 4 moved production and disposable-VM guest commands onto the shared +verified SSH transport. Boot expectations are resolved by the canonical host +profile. Workstation provisioning state owns provider cleanup journals +separately from the VM manifest and local artifact ownership. Fresh and +retained VM create/readiness/provision/smoke/reboot/cleanup checks passed. See +[creation validation](../butane/ACCEPTANCE.md#recoverable-vm-creation-intent-2026-10-03) +and [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03), +plus [readiness validation](../butane/ACCEPTANCE.md#readiness-retry-recovery-2026-10-03). + +Progress, 2026-10-05: on the retained clamps VM, temporarily withheld `vm.json` +and ran `test vm ready clamps smoke --verbose`. Skillet imported the legacy +`run.conf`, validated the recorded UUID and disk against the live domain, +reapplied readiness, and wrote a mode-0600 versioned manifest. The legacy file +and its SHA-256 remained unchanged. + +On a fresh `clamps-test-recovery` VM, interrupted first-time provisioning after +the mode-0600 Tailscale pending journal was written but before a completed +device record existed. The journal and owned VM identity remained intact after +Ctrl-C. Retrying provisioning wrote the device record and removed the pending +marker; fixture smoke/reboot passed, and destroy removed the journaled Tailscale +device and VM artifacts. See the 2026-10-05 acceptance entry. + +## Read and locate + +- `../design/{skillet-architecture,smoke-vms,secrets,cloudflare-ui-lifecycle}.md`. +- `../butane/bin/{test-vm,test-vm-ready,butane,flatpak-virt}` and + the `virsh`/`virt-install` wrappers; `../butane/README.md`. +- `../skillet/crates/cli/src/main.rs` VM dispatch and + `secret_delivery.rs` provisioning, external ownership, cleanup, and VM SSH. + +Review findings: disposal used to bypass external cleanup or require a live +guest; those paths are fixed. Persisted intent makes interrupted creation +inspectable, and the canonical host profile supplies readiness expectations. +Fresh creation, readiness, interrupted retry, and cleanup have passed live. + +## Implementation sequence + +1. Add workstation library `skillet_vm` with `thiserror` errors and thin CLI + delegation. Define `VmRun`, a versioned manifest, backend/connection and + SSH target types. Keep host profile, instance, guest hostname, environment, + UUID, disk, SSH port/key/known-hosts, source and deployed artifact hashes + distinct. Use atomic manifest updates with restricted local permissions. +2. Import existing `run.conf`, UUID/hash files, and external ownership metadata + without deleting or recreating retained VMs. Validate paths, identities, and + supported legacy formats before conversion. Reject ambiguous state with a + useful diagnostic; missing evidence is not authorization to adopt a domain. +3. Define `VmBackend` for inspect/define/start/reboot/stop/undefine and + `GuestTransport` for execution and file delivery. Initially implement native + and Flatpak subprocess adapters invoking focused tools directly. Preserve + current connection/runtime selection and networking behavior. Do not link + native libvirt, embed shell, or install tools during this migration. +4. Move list/status/create/reboot/update into Rust. Native domain XML now uses + `quick-xml`; Flatpak uses a focused `virt-install` adapter. Rust owns atomic + disk preparation, per-run key creation/validation, structured Butane + staging, backend definition/start, tool-version capture, and domain UUID + bookkeeping. Rust invokes the standalone Butane compiler. Build + artifacts through a shared Cargo helper. Validate required + tools and SSH-port conflicts before costly provisioning. See + [native XML validation](../butane/ACCEPTANCE.md#native-xml-rendering-2026-10-03). +5. Persist creation intent and a generated UUID before domain definition; + advance through explicit recoverable phases as side effects complete. + Failed starts retain owned state for inspect/retry/disposal. Distinguish + an absent domain from an inaccessible backend. Validate actual UUID/disks + whenever a domain exists; refuse mismatches and unrelated paths. +6. Move readiness into Rust with bounded waits, progress, and retained guest + diagnostics. Use the selected profile and manifest, never a fixed host. + Preserve signed bootstrap, SELinux/resolver and user-environment checks, + base-apply verification, and captured-artifact versus update semantics. + Workstream 4 will consolidate its transport with credential delivery. +7. Make one lifecycle orchestrator own destruction. Supply external cleanup + through a narrow callback/interface backed initially by existing provider + code. Validate recorded ownership independently of domain presence; clean + external resources first, then verified local resources, then metadata. + Persist completed cleanup phases for retry. Retain metadata when cleanup + fails or a response is ambiguous; never treat a connection error as absence. +8. Route every public entry point through this orchestrator. Retire Bash VM + management or retain compatibility wrappers that only forward to Rust, + including destruction. Update READMEs and the smoke VM design with actual + implemented behavior. Guest smoke assertions can remain standalone Bash. + +## Validation and exit criteria + +- CI tests with fake backend/transport/cleanup cover successful transitions, + timeout diagnostics, port conflicts, failed definition/start, existing-name + refusal, UUID/disk mismatch, legacy import, missing domains, and cleanup + retries. Assert that failure preserves recovery state and unrelated resources. +- Native and Flatpak adapter contract tests verify arguments, environments, + connection selection and XML/network differences without starting real VMs. +- On an available supported backend run a named fresh VM through create, + ready, update, reboot/ready and destroy. Inspect a retained legacy run before + and after import, confirming its identity, SSH key, and original artifacts. +- Exercise cleanup after an owned domain is already absent; an injected + external cleanup failure must retain metadata and succeed on retry. Real + external cleanup uses only authorized disposable resources and vault access; + unavailable backend/API checks are explicitly unverified. +- No Bash script remains an independent VM lifecycle owner or provides local + destruction that discards pending external ownership. Shared shell assertion + or compiler fixtures are allowed and keep their explicit interfaces. +- Pass the roadmap checks, record live evidence in `../butane/ACCEPTANCE.md`, + and update designs, usage, and general AGENTS lifecycle rules before handoff. diff --git a/ublue/plan/REFACTOR-02-HOST-PROFILES.md b/ublue/plan/REFACTOR-02-HOST-PROFILES.md new file mode 100644 index 00000000..ccd89c70 --- /dev/null +++ b/ublue/plan/REFACTOR-02-HOST-PROFILES.md @@ -0,0 +1,86 @@ +# 2. Canonical host profiles and capabilities + +Status: implementation complete; retained clamps base/full apply acceptance +passed and synthetic-profile tests cover genericity. See the +[prerequisite roadmap](SKILLET-REFACTOR.md). + +## Read and locate + +- `../skillet/crates/cli-common/src/{hosts,lib}.rs`, `hosts_tests.rs`, and + `../skillet/crates/hosts/*/src/main.rs`. +- Workstream 1's manifest/identity types; workstation service checks in + `../skillet/crates/cli/src/secret_delivery.rs` and CLI host validation. +- `../design/{private-ui-access,cloudflare-ui-lifecycle,secrets,storage,btrbk}.md`. + +Current defects: application composition, UI declarations, credentials, and +delivery eligibility have separate host checks. Host binaries select shared +dispatch but do not own composition. Unknown full-apply profiles silently fall +back to the baseline, and UI presence is used as a proxy for service eligibility. + +## Implementation sequence + +Workstream 1 introduced explicit boot expectations, now derived from the +canonical `skillet_hosts::HostProfile` alongside composition and service +capabilities. VM readiness receives the selected profile's expectations and +remains independent of deployment names. + +1. Add canonical library `skillet_hosts` and move host definitions/composition + out of `cli-common`. Define a validated `HostId` and `HostProfile` carrying + enabled application configurations, shared network/storage policy, optional + snapshot configuration, UI declarations, and credential requirements. + Derive applicable UI/credential data from enabled services where possible; + retain explicit aliases and exposure choices in the host declaration. +2. Preserve each current host's enabled services, paths, ports, snapshots, + private configuration sources and environment policies. Remove redundant + account setup only after workstream 3 establishes its purpose and consumers. + Declare runtime identity separately from the profile selected for apply. +3. Keep one composition function per canonical profile, consumed by the generic + CLI and per-host wrappers. Keep phase/domain logic independent of Clap; + parse CLI values at entry points. Retain thin host binaries for compatibility + while documenting their role. Do not duplicate recipes into the wrappers. +4. Have provisioning/delivery consult explicit service capabilities and their + credential consumers. A service does not require an exposed UI merely to + qualify for its own credentials. Unknown or unsupported profiles/services + fail before vault unlock, remote mutation, or full apply. If a generic + baseline is useful, make it explicit rather than an unknown-profile fallback. +5. Replace fixed identity checks in readiness and smoke selection with profile + and run-manifest inputs from workstream 1. Keep test environment selection + explicit. Preserve the current UI naming rule and ownership conflict refusal: + instances sharing a host/environment DNS namespace cannot claim it together. + Do not silently append an instance to served DNS names. +6. Remove duplicated registries and capability lists from CLI paths. Keep + application-specific configuration in the service crate and host selections + in the profile library. Document the authoritative source and dependencies + in `../design/skillet-architecture.md` and update AGENTS genericity rules. + +## Validation and exit criteria + +- Table-driven tests cover every current profile's services, UI upstreams, + aliases, credential consumers, network and snapshot selection. Test a profile + with a service whose UI is unexposed and a profile with no UI/credentials. +- Generic and per-host entry points select the same canonical configuration. + Full apply and delivery reject unknown profiles and undeclared services. +- A local synthetic declaration with a different identity proves shared lookup, + readiness selection and rendering have no fixed-profile assumption. It is + test data, not a synthetic production-dispatch host. No new real host needs + to be bootstrapped merely to test genericity. +- Host/VM instance/environment/SSH target remain distinct; UI naming and + DNS ownership conflicts preserve the current design and private values. +- Pass roadmap checks and affected retained-VM base/full apply checks. Record + evidence and update profile callers, designs and usage before completion. + +## Progress + +- Implemented: `skillet_hosts` is the canonical profile and composition crate + for clamps, joey, and the explicit generic agent baseline. Profile data + owns boot image/masks, enabled services, UI exposure, credentials, bridge + network, storage requirement, and optional btrbk inputs. +- Implemented: generic and per-host apply wrappers, Caddy apply, credential + eligibility, smoke selection, and VM boot expectations query the profile. + Unknown profiles fail closed. The smoke fixture is a separate hidden test + command, not a synthetic host profile. +- Accepted: the fresh disposable clamps run selected the profile and completed + full UI provisioning and fixture smoke. Routine profile tests cover unknown + profiles and the generic baseline. +- No additional real host is required for genericity acceptance; the synthetic + profile tests exercise independent identity and service declarations. diff --git a/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md new file mode 100644 index 00000000..45037938 --- /dev/null +++ b/ublue/plan/REFACTOR-03-RESOURCE-BOUNDARIES.md @@ -0,0 +1,112 @@ +# 3. Cohesive effect interfaces and unified ownership + +Status: implementation complete; live mount/subvolume, service ownership, and +descendant-metadata preservation checks passed. This follows +workstream 2 in +[the prerequisite roadmap](SKILLET-REFACTOR.md); further feature milestones +wait for the full roadmap gate. + +## Read and locate + +- `../skillet/crates/core/src/{files,system,credentials,recorder,test_utils}.rs`. +- Account/subordinate-ID observations in `../skillet/crates/podman/src/lib.rs` + and the host library; ownership inputs in application crates. +- `../design/{secrets,storage,skillet-architecture}.md` and workstreams 2 and 4. + +Original defects: broad file/system traits accumulated unrelated capabilities, +and recipe code read live account data, subordinate-ID files and the credential +environment outside those interfaces. File and directory ownership are now +unified; remaining trait aggregates are transitional composition contexts. + +## Implementation sequence + +1. Inventory effectful reads and writes outside concrete adapters. Assign each + to its actual owner. Keep plain validation/rendering as functions, and avoid + a general command-execution trait as a replacement for domain capabilities. +2. Split interfaces into cohesive file, storage, account lookup/ensure, service, + and Podman-secret capabilities where consumers need them. Account identity + and subordinate-range reads must be injectable together with account setup. + Keep a transitional aggregate/context if needed to avoid a flag-day change; + migrate recipe signatures and remove obsolete omnibus requirements afterward. +3. Define shared named/numeric ownership types for file and directory metadata. + Resolve names through the account boundary or a concrete filesystem adapter, + and preserve numeric IDs without requiring matching account names. Model + container process identity and namespace mapping separately. Preserve the + rule that application-data root convergence does not recursively change data. +4. Load systemd credentials and ambient configuration at the guest entry point. + Pass validated credential inputs into host composition; do not construct the + credential environment reader inside recipes. If a credential-reader trait + is needed, keep it focused and provide an in-memory implementation for tests. + Keep secret-value handling separate from recorded operations. +5. Move direct live account/file reads out of host/Podman recipes into adapters. + Missing/invalid subordinate ranges must follow an explicit documented mapping + policy, not an implicit hardcoded fallback. Audit user/group creation against + real consumers; remove obsolete setup rather than keeping inert configuration + merely for compatibility with an unused model. +6. Update concrete adapters, recorders and fakes together. Fakes preserve IDs, + mode/ownership, file-versus-directory and subvolume distinctions, activity + and failure semantics needed by callers. Unsupported operations fail clearly. + Do not claim new trait fidelity by testing only that a mock was called. + +## Validation and exit criteria + +- Full host composition runs with injected dummy inputs and fakes without + consulting the test runner's accounts, subordinate-ID files, environment, + filesystem or services. Tests vary observations to prove decisions change. +- Contract cases cover absent/existing/wrong-ID accounts, invalid/missing ranges, + named and numeric ownership, mode drift, wrong object type and failed effects. + Include an unmapped numeric owner and verify descendants remain untouched. +- Core filesystem tests exercise a real temporary directory; privileged + ownership/mapping and Btrfs behavior use an explicitly named disposable VM. + Preserve mount guards and namespace behavior; do not weaken checks for CI. +- Static audit finds no live effect observations in composition/recipes that + bypass their declared boundary. Traits have cohesive consumers and the two + ownership APIs have become one contract. +- Pass roadmap checks and affected VM ownership/mount/repeat-apply checks. + Update the design and generic AGENTS interface rules with the finished API. + +## Progress + +- Implemented: host-profile and Podman composition use injected account + lookups; NSS access remains in the Linux system adapter. Podman reads + subordinate-ID files through `FileResource`, validates the selected single + range, and fails closed for missing, invalid, duplicate, or undersized ranges. +- Implemented: file and directory mutations use the same `Ownership` value + with named or numeric UID/GID. Numeric IDs do not require matching account + names. The local adapter and fake preserve partial metadata across + existence-only checks; UniFi's repeat-apply case verifies child data survives. +- Implemented: the file API, directory API, local adapter, recorder, fake, + templates, and all application callers share this ownership input. Recording + schema version 2 records named and numeric identities. Version 1 recordings + remain historical output and are not rewritten. +- Implemented: the guest CLI loads phase-required systemd credentials and + passes `CredentialInputs` to host composition. Recipes no longer inspect + `CREDENTIALS_DIRECTORY`; required names derive from profile consumers. +- Implemented: file effects now expose separate read, mutation, and storage + capabilities. Recipe signatures use those capabilities where they do not + need the entire file interface. The aggregate remains for host composition + and migration paths. Recorder and mock adapters implement the capabilities + separately. +- Implemented: system effects expose separate account lookup, account + mutation, Podman-secret, and service capabilities. The Podman, hardening, + service, and backup crates request their required capabilities; host + composition retains the aggregate during migration. Recorder and mock + adapters implement the narrow contracts. +- Implemented: a shared file mutation contract runs against both + `LocalFileResource` and `MockFiles`, checking creation, unchanged repeat, + update, deletion, and file/directory type conflicts. This catches behavioral + drift without relying on root privileges or Btrfs. +- Implemented: account lookup contract assertions run against both + `LinuxSystemResource` and `MockSystem`, checking name, numeric UID, and group + results against the current test account. This read-only adapter check avoids + mutating workstation users or services. +- Implemented: service state and Podman-secret idempotency/rotation contracts + run against both `MockSystem` and its `Recorder` decorator. Unit tests do not + mutate the workstation's real services or secrets. +- Accepted: the retained clamps VM showed `/var/lib/data` on the Btrfs `data` + subvolume; service volumes and numeric UniFi data-root ownership matched the + declared paths/identity. Fixture recovery/reboot and credential rotation + passed with the mount. +- Complete: induced root-ownership drift was repaired on the disposable VM; a + child with independent numeric ownership/mode was unchanged, and the + temporary probe was removed afterward. diff --git a/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md new file mode 100644 index 00000000..4c1e106f --- /dev/null +++ b/ublue/plan/REFACTOR-04-CLI-PROVISIONING.md @@ -0,0 +1,206 @@ +# 4. Thin CLIs and shared workstation provisioning + +Status: implementation and disposable VM acceptance complete, including +credential rotation, interrupted recovery, and cleanup. Production delivery +is deferred. This follows +workstreams 1–3 in +[the prerequisite roadmap](SKILLET-REFACTOR.md); further feature milestones +wait for the full roadmap gate. + +## Read and locate + +- Workstreams 1–3's VM transport, profiles, and guest input/resource contracts. +- `../skillet/crates/cli/src/{main,secret_delivery,cloudflare,tailscale}.rs`, + `secret_delivery/vault_cache.rs`, and their tests. +- `../skillet/crates/cli-common/src/{lib,credential}.rs` and + `../skillet/crates/core/src/credentials.rs`. +- `../design/{secrets,cloudflare-ui-lifecycle,smoke-vms,skillet-architecture}.md`. + +Current defects: CLI modules combine parsing, vault persistence, transport, +remote credential installation, external APIs and lifecycle policy. Production +and VM delivery have duplicate implementations with different validation. + +## Implementation sequence + +1. Make binary modules responsible for parsing, tracing, interactive prompts, + and dispatch. Move workstation logic into `skillet_workstation` with cohesive + vault, providers, credential delivery and provisioning modules. Use typed + inputs independent of Clap argument structures and `thiserror` library + errors with preserved sources and sanitized diagnostics. +2. Keep guest apply/profile composition and encrypted credential installation + in guest-facing libraries. Consolidate guest credential path/name validation, + state inspection and atomic installation. A guest apply/credential path must + not depend on a source checkout, workstation vault, or external API access. + Preserve the generic CLI facade and thin host wrappers while documenting + the actual dependency boundaries; do not duplicate the profile registry. +3. Encapsulate the existing KDBX reader/writer and kernel cache. Preserve XDG + defaults, symlink handling, exact entry lookup, conflict checks, encrypted + replacement verification, use-or-create safeguards, and the three-hour TTL. + Provide testable unlock/cache boundaries without exposing master passwords + through command arguments, logs, manifests or chat. +4. Use workstream 1's `GuestTransport` for production and disposable targets. + Implement one credential-delivery operation taking host/profile, credential, + consumer unit, activation policy and payload. Send payload only through stdin; + verify host keys and use focused remote executable invocations with validated + arguments. Keep multi-credential installation deferred until the set is + complete. Remove both old parallel SSH/install implementations. +5. Model production/test differences as explicit policy: durable versus + disposable values, tags, token lifetime, staging and cleanup ownership. + Share environment lookup, zone/domain resolution, site/DNS derivation and + delivery workflows. Preserve the current naming and conflict rules. Do not + infer environment from a target name or expand credential scope. +6. Keep Cloudflare's official SDK transport and local HTTP fixture pattern. + Replace Tailscale's curl HTTP adapter with a Rust HTTP client compatible with + the build constraints, and inject its base endpoint for local protocol tests. + Introduce narrow provider interfaces only for orchestration decisions that + need fakes. Retain existing provider retry/ownership protections. +7. Connect the shared providers to workstream 1's lifecycle orchestrator. + Persist pending enrollment/issuance and cleanup ownership before mutations; + honor reuse/explicit rotation and partial credential-install recovery. + Cleanup remains recoverable if the guest is absent. Remove API operations + hidden in functions named for only one provider when they own multiple + resources. Update usage and designs with completed behavior. + +## Validation and exit criteria + +- Local orchestration tests with dummy vaults and fake transports/providers + cover declared/unsupported services, existing/missing secrets, vault conflict, + partial delivery, deferred activation, API failure and cleanup retry. The + selected policy, rather than host/environment branches, determines behavior. +- Local HTTP tests cover Tailscale auth-key/device-list/removal requests and + failures, plus the existing Cloudflare regression cases. CI contacts no live + provider and prompts for no private password. +- Verify three-hour expiry and cache clearing through the cache contract and, + where available, a local dummy-key check; unavailable kernel checks are + explicitly unverified. Do not test expiry by waiting three hours. +- Guest library dependency inspection and entry-point tests establish that + guest apply/credential commands work without workstation state or checkout. + Generic/per-host entry points retain the same behavior. +- On a named disposable VM verify delivery, repeat apply, rotation and recovery + after deferred installation. Run enrollment/UI/disposal using the existing + vault workflow when authorized credentials are available; preserve sanitized + evidence and mark unavailable live checks honestly. +- Pass roadmap checks; no duplicated production/VM credential workflow remains, + and runtime/CLI/library boundaries are reflected in designs and AGENTS. + +## Progress + +- Implemented: production and disposable-VM credential installations now use + one `skillet_vm::credential::install` operation over `GuestTransport`. It + accepts host, credential, consumer unit, activation policy, and payload as + typed values; validates identifiers; sends secret bytes only on stdin; and + reports sanitized guest errors. Both verify recorded SSH host keys. +- Implemented: Tailscale API calls use a bounded `reqwest` blocking client with + an injectable base URL. Local HTTP tests cover OAuth auth-key creation, + tagged device lookup/removal, and API failures. Tailscale no longer shells + out to curl. +- Implemented: KeePassXC access and its named-session kernel password cache + live in `skillet_workstation::vault`, outside Clap and guest apply code. The + API encapsulates exact lookup, XDG path choice, symlink-safe atomic save, + conflict detection, verified encrypted replacement, recovery backup, and + three-hour cache/lock operations. It uses `thiserror` and does not implement + `Debug` for the unlocked vault value. +- Implemented: the Cloudflare SDK client, token and DNS operations, domain + validation, and local HTTP fixtures now live in `skillet_workstation`. Its + library API uses `thiserror`; the CLI contains only the current orchestration + decisions calling that API. +- Implemented: Tailscale's bounded HTTP client, OAuth auth-key creation, + tagged-device lookup/removal, and local protocol tests now live in + `skillet_workstation::tailscale`; the CLI no longer depends directly on + `reqwest` for this provider. +- Implemented: `skillet_workstation::provisioning_policy` captures production + versus test environment names and vault paths, ACME staging, Cloudflare + credential lifetimes, cleanup-token lifetime, and Tailscale device tags. + Delivery and cleanup consume this policy; the Cloudflare zone ID uses the + environment-specific `skillet/environments//dns/cloudflare-zone-id` + vault entry. +- Implemented: production credential delivery and disposable-VM provisioning + use `skillet_vm::SshTransport` with verified recorded host keys and literal + executable arguments. Caddy activation, VM status probes, and the non-tailnet + HTTP probe no longer build remote shell command strings. Both targets install + credentials through the shared typed installer, with activation deferred + until all related Caddy credentials are present. +- Implemented: Cloudflare ownership JSON, Tailscale enrollment markers, and + enrolled-device records are persisted through + `skillet_workstation::provisioning_state`. The library writes atomically with + mode `0600`, rejects symlinked/non-regular state files, and binds a repeated + enrollment marker to the same typed host/environment/instance identity. New + journals retain those fields separately; old journal formats remain readable + with exact expected-hostname/marker/token-name checks. No token value is + persisted. +- Implemented: `skillet_workstation::ui_provisioning` derives Caddy sites and + machine/service/alias DNS records from the canonical host profile, explicit + environment policy, Cloudflare zone domain, optional relative UI prefix, and + Tailscale addresses. Production delivery and disposable VM provisioning use + this shared plan, removing duplicate derivation and host-specific UI logic. +- Implemented: the Tailscale provider parses guest status JSON and validates + running state and IP addresses. VM orchestration now supplies the response + bytes and handles retry timing; provider protocol interpretation and its + malformed-response tests live with the Tailscale adapter. +- Implemented: bounded enrollment polling is also in the Tailscale provider. + The CLI supplies a guest probe and explicit timeout/interval; retry behavior + is covered with deterministic success, transient-error, and timeout tests. +- Implemented: a failed initial guest status command now aborts enrollment + before issuing an auth key. Only a successful response with no addresses is + treated as an unenrolled VM; transport and command failures remain errors. +- Implemented: `skillet_vm::credential::install_set` validates a complete, + duplicate-free credential set before remote effects and applies one shared + consumer/activation policy. Production and disposable Caddy provisioning use + it to install both the sites payload and ACME token before activation. +- Implemented: disposable Tailscale enrollment sequencing now lives in + `skillet_workstation::tailscale_enrollment`. The typed operation receives + host, environment policy, VM instance/hostname, state directory, provider, + and verified guest transport separately. It persists intent before effects, + reuses an already-joined VM, installs a one-use key only after a successful + empty status observation, verifies the tagged device, and records/removes + recovery state. +- Implemented: disposable Tailscale cleanup now lives beside enrollment in + `skillet_workstation::tailscale_enrollment`. It validates the test policy and + typed identity, verifies both current and legacy recovery records, removes + only the expected tagged device, and clears local journals afterward. A + provider failure leaves recovery state available for retry. +- Implemented: disposable Cloudflare UI issuance and DNS reconciliation now + live in `skillet_workstation::ui_provisioning`. The operation writes its + owner journal before token creation, verifies the vault snapshot immediately + before mutation, saves token ownership before DNS reconciliation, and records + owned DNS IDs afterward. Fake-provider tests cover partial failure recovery. +- Implemented: disposable Cloudflare cleanup now lives in + `skillet_workstation::ui_provisioning`. It validates the test environment, + recorded host/environment/instance, deterministic marker/token names, zone, + and resolved UI namespace before provider mutations. It removes only + marker-owned DNS, revokes disposable and temporary cleanup tokens, and only + then removes the journal. Provider failures retain the journal for retry; + symlinked or non-regular state paths are rejected. +- Implemented: persistent UI provisioning now shares one workstation + transaction for tailnet device lookup, shared site/DNS derivation, ACME-token + reuse or scoped issuance, legacy production-token migration, DNS + reconciliation, complete Caddy credential-set delivery, and deferred + activation. The CLI supplies its parsed host/environment, vault, provider + credentials, and verified SSH transport. +- Implemented: disposable Caddy guest delivery now installs the complete + credential set before activation, probes every canonical UI/alias for the + explicit non-tailnet denial response with a bounded retry per name, and only + then revokes superseded named tokens. The guest step is separately retryable + after DNS/token ownership has been journaled. +- Implemented: host Pi-hole password reuse/use-or-create and production + Tailscale auth-key delivery now live in + `skillet_workstation::credential_delivery`. They share the verified guest + credential installer and vault `SecretStore` adapter with Caddy provisioning. + Missing Pi-hole values require a successful guest absence check before + generating and encrypting a replacement; disposable Pi-hole reuse and + rotation use the same workstation module. +- Reviewed the remaining CLI dispatch: it opens the vault, selects explicit + environment/host/instance inputs, loads the canonical VM run, and sequences + workstation operations. Disposable provisioning now holds the per-run lock, + uses the recorded SSH target, and checks backend ownership before and after + every guest operation. No compatibility script or legacy `run.conf` reader + remains in this delivery path. +- Accepted: fresh test-VM credential delivery, Tailscale enrollment, Cloudflare + DNS/token lifecycle, Caddy staging issuance, denial probes, and disposable + cleanup passed end to end. +- Accepted: a first-time disposable Tailscale credential delivery was + interrupted after its durable pending journal was written. Retry reused that + journal, completed service apply and enrollment, wrote the ownership record, + and cleared pending state. Destroy then removed the journaled provider + device. Production checks require production credentials and are explicitly + deferred from this refactoring gate. diff --git a/ublue/plan/REFACTOR-05-PODMAN-MODEL.md b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md new file mode 100644 index 00000000..6b130f07 --- /dev/null +++ b/ublue/plan/REFACTOR-05-PODMAN-MODEL.md @@ -0,0 +1,100 @@ +# 5. Podman configuration with explicit host policy + +Status: implementation and clamps live acceptance complete. See +workstreams 2–4 in the [prerequisite roadmap](SKILLET-REFACTOR.md). + +## Read and locate + +- `../skillet/crates/podman/src/{lib,tests}.rs` and its Quadlet template. +- Application crates' container definitions and workstream 2's shared network + and storage declarations; workstream 3's account/ownership interfaces. +- `../design/{storage,pihole-network,syncthing,unifi,private-ui-access}.md` and + `../butane/includes/data-storage.bu`. + +Current defects: runtime user/network settings can come from both typed fields +and raw directives; unused user fields misdescribe runtime identity. Per-container +apply also owns a global DNS-port policy and converges shared networks repeatedly. + +## Progress + +- Implemented: moved the host-wide Aardvark DNS listener setting out of generic + container apply. The canonical host profile selects it when Pi-hole is + declared, and host composition writes it once before service recipes. The + focused Podman helper is idempotent; standalone container apply no longer + changes global Podman DNS policy. Named-VM validation remains pending. +- Implemented: replaced the ambiguous container UID/GID plus host-user fields + with image-default, named, and numeric process identities, and a separate + optional namespace mapping. Removed unused implicit host-account creation; + UniFi now declares its image's named process user as typed data. Invalid + names and raw `User`/`UIDMap`/`GIDMap` conflicts fail before file effects. +- Implemented: shared bridge definitions are now created once by host + composition and carry change-refusal markers; containers declare named bridge + or host-network attachments. IPv4/IPv6 port publications are typed and + validated, and conflicting raw `Network`/`PublishPort` directives fail + before effects. Pi-hole, Syncthing, Caddy, UniFi, Tailscale, and the fixture + were migrated without changing their intended ports or modes. +- Implemented: added a reusable typed mount dependency for persistent service + containers. Pi-hole, Syncthing, UniFi, Caddy, and Tailscale use the shared + data mount helper; invalid mount inputs fail before container effects. +- Implemented: typed bind volumes can declare root mode and numeric or named + host ownership. Podman converges those fields only on the mount root; omitted + metadata preserves the application's current ownership. UniFi declares its + `999:999` root and `0750` mode in its volume. A test preserves a child file's + bytes while checking the root metadata. +- Implemented: a representative comparison proves that adding root ownership + metadata leaves the existing Quadlet bytes unchanged. +- Accepted: live inspection confirmed Pi-hole/Syncthing bridge networking, + Tailscale/UniFi host networking, UniFi's declared process identity, expected + persistent data paths, and healthy/running services. Repeating host + provisioning preserved all four service container start times. An induced + UniFi volume-root ownership drift was repaired without changing child + ownership/mode. Caddy's staging flow also served the declared UI upstreams. + +## Implementation sequence + +1. Inventory actual resource usage before changing the model. Define explicit + image-default/named/numeric process identity, separate host volume ownership, + and optional namespace mapping. Reuse workstream 3's identity types. Remove + inert fields and implicit user creation; retain mapping support only with an + explicit validated caller and meaningful tests. +2. Make commonly used container settings typed, including identity, network + attachments and declared port publications. Keep an extension section for + unsupported directives with documented constraints. Reject conflicts with + typed values and invalid input before effects. Validate repeated directives + according to their cardinality; preserve order where it affects semantics. +3. Add explicit shared network/global Podman baseline convergence selected by + the host profile. Move DNS listener policy out of generic container apply. + Services reference an already-declared network rather than each owning its + full definition. Preserve bridge DNS, dual stack, host networking and network + change refusal until an explicit safe replacement workflow exists. +4. Add reusable storage dependency helpers for application units and shared + data validation. Preserve the installation/runtime split: Ignition prepares + the filesystem and graphroot; recipes validate and prepare application data. + Carry caller-selected root metadata as typed volume data; never recursively + change descendants or infer host ownership from container process identity. +5. Migrate every existing service, including Tailscale and the smoke fixture, + preserving images, ports, capabilities, SELinux labels, identities, paths, + secret directives, auto-update and startup dependencies. Explain intentional + definition differences in the design; do not make service-policy changes + merely to simplify a type or get a test passing. +6. Keep revision/activation behavior compatible with workstream 6. Document + what omitted services/options mean: an omitted recipe leaves existing state + unmanaged unless an explicit deactivation is requested. Do not remove data + during the refactor or silently interpret omission as destructive cleanup. + +## Validation and exit criteria + +- Rendering/validation tests cover image-default and named/numeric users, + explicit mappings, bridge and host networks, dual-stack publications, secrets, + typed/raw conflicts and semantically ordered repeated directives. +- Baseline tests prove global/network policy is selected once from profile + inputs and independent of application apply order. Network definition changes + still fail safely instead of replacing a network in use. +- Unit and real-VM tests establish mount dependencies, volume root ownership, + preserved descendant metadata, secret/config consumption and repeat apply. + Compare representative old/new Quadlets and document intended changes. +- A named VM verifies actual process identity, DNS publications, service-to- + service resolution, host-network services, and unchanged application state. + Include stop/start and reboot checks for definitions that changed. +- Pass roadmap checks, update affected service designs and acceptance evidence, + and retain separate process/mapping/ownership and global-policy AGENTS rules. diff --git a/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md b/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md new file mode 100644 index 00000000..4b308521 --- /dev/null +++ b/ublue/plan/REFACTOR-06-CONVERGENCE-RECORDING.md @@ -0,0 +1,83 @@ +# 6. Explicit convergence outcomes and failure-safe recording + +Status: implementation complete; fixture failure-recovery/reboot, host +credential rotation, and unchanged repeat-apply acceptance passed. See the +[prerequisite roadmap](SKILLET-REFACTOR.md). + +## Read and locate + +- `../skillet/crates/core/src/{recorder,resource_op,test_utils}.rs` and the + resource traits revised in workstream 3. +- `../skillet/crates/hardening/src/lib.rs`, Podman revision/activation logic, + btrbk unit convergence, and apply/record handling in the CLI libraries. +- `../design/{skillet-architecture,smoke-vms}.md` and existing failure-recovery + unit/VM fixture tests. + +Original defects: activation markers were implemented separately in hardening +and Podman; returned booleans described file changes despite restarts. +Recordings were saved only after successful apply and exposed payload hashes. + +## Progress, 2026-10-04 + +- Implemented one shared activation helper for hardening, Podman containers, + and the btrbk timer. It reports definition changes, starts/restarts, and + recovery of pending activation; marker state is committed only after a + successful service action. Persistent services are restarted after desired + changes, stopped unchanged services are started, and completed oneshots are + not rerun just because they are inactive. +- Implemented version 1 diagnostic recordings with overall outcome and + per-operation success/change/failure. Recording omits file contents, content + hashes, secret payload fingerprints, and raw operation errors. Writes use a + synced temporary file and atomic replacement. Failed applies are recorded; + if recording also fails, the returned error retains both causes. +- Focused tests cover changed/no-op/stopped states, reload/start/restart + interruption and retry, oneshots, secret redaction, failed apply recording, + and simultaneous apply/record failure. The fresh VM fixture passed its + failure-recovery and reboot cases. A further full host apply preserved the + service containers' start timestamps, confirming unchanged repeat apply. + +## Implementation sequence + +1. Specify result contracts before editing callers. Low-level file/secret + results describe their own convergence; aggregate apply outcomes distinguish + definition change, start/restart/recovery and no-op. Use a small documented + result type where a boolean cannot express the operation. Audit every caller + and test that currently interprets the boolean. +2. Extract a focused activation helper for desired revision, pending applied + state, required daemon reload, consumer action, and successful marker commit. + Keep resource-specific revision inputs and desired active-state policy in + the caller. Distinguish oneshot actions from persistent services; do not + start completed oneshots repeatedly merely because they are inactive. +3. Preserve interruption behavior: files may already match after a failed reload + or restart, but pending activation must retry. Write applied state only after + success. Account for network-global policy and application input/secret + revisions. Import existing markers or document a bounded migration; never + erase state merely to make a second apply appear clean. +4. Update recording to capture attempted operation plus sanitized result, + relevant non-secret parameters and overall apply outcome. Include account IDs + and consistent ownership types. Record failure without raw payloads, credential + bytes or unsafe subprocess output; fingerprints are not plaintext substitutes + to log indiscriminately. Do not record private configuration contents. +5. Persist the recording even when apply fails. Preserve the original apply + error if recording persistence also fails, with a separate useful diagnostic. + Use atomic writes or another explicitly recoverable format. Version format + changes and document compatibility for existing diagnostic consumers. +6. Update consumers, fakes, tests and README descriptions together. Recording + remains observational: it executes the real resources and is neither a dry + run nor acceptance proof. Update AGENTS result/recording prevention rules. + +## Validation and exit criteria + +- Tests cover changed definition, consumed-input and secret-only changes, + stopped service, genuine no-op, failed reload/start/restart and successful + retry. Assert markers remain pending on failure and commit after success. +- Oneshot and persistent consumers obey different activity policies. Repeat + apply leaves an unchanged running container's identity/start time intact. +- A failed apply produces a readable diagnostic recording containing the failed + operation and prior results. No-op and successful changes are distinguishable; + combined apply/record-write failure retains the original cause. +- Tests using distinctive dummy payloads verify secrets and private configuration + bytes never appear in recordings or returned diagnostic messages. +- Run the real VM fixture's configuration/secret/failure/reboot cases and an + affected host repeat apply. Pass roadmap checks, retain evidence, and document + implemented marker/record format behavior before completion. diff --git a/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md new file mode 100644 index 00000000..41f601ea --- /dev/null +++ b/ublue/plan/REFACTOR-07-TEST-INFRASTRUCTURE.md @@ -0,0 +1,135 @@ +# 7. Test layers, fixtures, build artifacts and CI + +Status: implementation and named fixture acceptance passed; remote CI and live +Syncthing acceptance after correcting the declared host/container identity +mapping remain open, 2026-10-05. Rust-owned smoke, storage/service fake contracts, +bootstrap CI execution, and capability-selected application acceptance are +implemented. The named fixture smoke and retained update passed. Scripted +interrupted-reboot recovery passed; see the +[acceptance log](../butane/ACCEPTANCE.md#interrupted-reboot-checkpoint-recovery-2026-10-05). +An optional repeated application check exposed a mismatch between the host +account owning `/var/lib/data/syncthing` and Syncthing's container identity. +The profile now declares the container UID/GID separately and maps them to the +host owner; live acceptance after this change is parked until Flatpak libvirt +access returns. See the +[follow-up plan](REFACTOR-FOLLOWUP.md). +Live legacy import and controlled interrupted-provisioning recovery passed; +see the [prerequisite roadmap](SKILLET-REFACTOR.md). + +## Read and locate + +- `../skillet/integration_tests/smoke-guest.sh`, the synthetic host + fixture, CLI container runner and `src/test_entrypoint.sh`. +- Revised resource fakes, VM manifest/transport, profiles, provisioning and + recording interfaces from workstreams 1–6. +- `../../.github/workflows/ci.yml`, `../butane/tests/bootstrap.sh`, both READMEs, + `../design/smoke-vms.md` and `../butane/ACCEPTANCE.md`. + +Original defects: fixture smoke, host acceptance and mocked container checks +were conflated; target/artifact selection assumed default paths/instances; fakes +disagreed with adapter behavior; extensionless helpers and Butane-only changes +missed relevant CI. Shell code was embedded in a production Rust fixture. + +## Follow-up progress, 2026-10-05 + +- Rust now owns the complete fixture smoke sequence under one VM lock and + checked transport. A named `clamps/smoke` run passed fixture convergence, + reboot detection, captured artifact verification, and readiness recovery. +- Update delivery uses ownership-checked transport. The named retained VM + accepted a current binary update and subsequent readiness check. +- Storage/service fakes distinguish Btrfs mounts/subvolumes and preserve + service reload state. Bootstrap regressions run in CI and passed locally. +- Capability-selected application acceptance and injected failure tests are + implemented. Live checks corrected ownership expectations: Syncthing data + uses its declared host account; its numeric container UID is separate. The + fully provisioned named-VM application, repeat-apply, and reboot checks pass. +- Workspace formatting, strict Clippy, workspace tests, the Fedora container + integration, bootstrap state tests, and workflow ShellCheck passed locally. + Remote CI is pending until push. +- A scripted interruption after persisting the pre-reboot checkpoint was + recovered by a separate readiness process, then ordinary fixture reboot + acceptance passed. The optional application variant exposed a Syncthing data + ownership mismatch after reboot; the VM was restored to Ready. + +## Earlier progress, 2026-10-04 + +- Moved the synthetic application out of the production CLI module into the + dedicated `skillet-smoke-fixture` package and binary. Its guest entrypoint is + a standalone `integration_tests/fixture-entrypoint.sh` file. The hidden + production CLI fixture command was removed; the guest SSH smoke installs the + fixture binary explicitly. +- Smoke derives Cargo target/profile from the invoked executable, builds the + fixture through Cargo JSON artifact reporting, and refuses an unreported or + missing executable. It verifies the fixture transfer and both installed + guest binaries against SHA-256 values recorded in the VM manifest. A unit + test covers custom target and release/debug selection. Existing VM identity, + instance, manifest SSH target, and port remain separately validated. +- CI now selects Rust validation when Skillet, Butane, its helpers, or the + workflow changes; it runs workspace formatting, all-target strict Clippy, + all-target tests, and the named Fedora container integration command. Shell + lint explicitly includes current extensionless scripts. Fresh/retained VM + fixture and reboot passed. The live legacy-manifest import and controlled + interrupted-provisioning recovery also passed; evidence is in the + 2026-10-05 acceptance entry. + +## Implementation sequence + +1. Document four acceptance scopes: pure/adapter-contract tests, binary apply + checks in a container with fake runtime commands, real VM convergence fixture, + and actual host application/live-provider acceptance. Recording is a diagnostic + aid, not a fifth acceptance layer. Preserve useful distinct layers; remove a + redundant check only after equivalent meaningful coverage is demonstrated. +2. Move the synthetic application into a separate test fixture binary/package + outside production host dispatch. Keep its guest program in a standalone + script file; no Rust shell string constant. Use the production resource engine + so configuration, secret, failure-retry and reboot tests remain representative. + Guest assertions may stay standalone Bash and get explicit validated inputs. +3. Select VM smoke targets using host plus instance and the validated manifest + from workstream 1. Resolve key, host-key file, port and installed artifacts + together. Preserve existing default command behavior through delegation; keep + any explicit remote-target mode clearly opted into a disposable target. + Fixed production-name exclusions are not sufficient ownership validation. +4. Consolidate artifact building/discovery for create, update, smoke and container + tests. Parse Cargo JSON artifact output and honor target/profile/target-directory + configuration. Do not select a stale release/debug artifact by directory scan. + Record hashes; make captured readiness artifacts versus current-code update + explicit and verify generic/host/fixture versions used by a scenario. +5. Add focused fake/adapter contract tests. Account IDs and directory metadata + must be observable; directory/subvolume checks and service/secret failures + cannot silently pass. Shell stubs must match production exit/error semantics. + Full host composition with injected inputs belongs in fast CI even though + a Fedora sandbox cannot provide the real service data mount or systemd. +6. Add deterministic host acceptance scenarios selected by the profile's actual + capabilities. Check running services, mounts/ownership, network mode and + declared listeners, unchanged repeat apply, and persistence after reboot. + Keep external ACME/enrollment scenarios opt-in and state what loopback probes + establish. Do not silently label fixture success as actual application success. +7. Update CI to cover shared Rust, Butane and helper inputs; explicitly lint + extensionless shell helpers and extracted fixtures. Add workspace formatting, + pedantic Clippy including test targets, unit/contract checks, bootstrap state + tests, and a clearly named container binary integration check. Routine CI + needs no vault, live providers, hypervisor, or production network. +8. Update command docs and acceptance evidence. Correct stale status text and + preserve deferred Pi-hole LAN, external-network UI, production ACME, backup + restore and hardware checks in their existing plans. Test cases unavailable + on a backend are unverified, not equivalent to a passing mock. + +## Validation and exit criteria + +- With a non-default target directory/profile and a named non-default instance, + commands select the exact newly built artifacts and recorded SSH target. + Missing/unsupported artifacts or ownership fail before guest mutation. +- Every important fake/adapter contract added in 3–6 has meaningful behavioral + coverage, including changed observations and failed effects. Private state is + unnecessary for routine CI; synthetic profiles are absent from production + dispatch and no shell programs are embedded in Rust. +- CI selection includes relevant Butane/helper changes, all executable shell + helpers are linted, and format/Clippy/unit/bootstrap/container commands pass + locally. Update AGENTS with exact final commands and general coverage rules. +- A named fresh VM passed create/ready/provision, real convergence fixture, + reboot, and external cleanup. A retained VM passed readiness, full + provisioning, repeat apply, fixture failure recovery, and reboot. Live + legacy-manifest import and controlled interrupted provisioning also passed. +- Finish only after earlier workstreams' exit criteria also pass. Update the + roadmap gate and migration entry point so the next agent can resume feature + work without losing deferred acceptance requirements. diff --git a/ublue/plan/REFACTOR-FOLLOWUP.md b/ublue/plan/REFACTOR-FOLLOWUP.md new file mode 100644 index 00000000..1045bab6 --- /dev/null +++ b/ublue/plan/REFACTOR-FOLLOWUP.md @@ -0,0 +1,192 @@ +# Refactor follow-up: close the completion audit gaps + +Status: implementation and local VM acceptance substantially complete, +2026-10-05. Code and local regression coverage for all six findings are in +place. Rust-owned fixture smoke, retained-VM update, and scripted interrupted +reboot recovery passed on the named clamps VM. Syncthing now declares its host +owner separately from its container UID/GID and maps them with the shared +Podman user-namespace API. Its live application acceptance is parked until +Flatpak libvirt access returns; then rerun named-VM apply, repeat-apply, and +reboot acceptance. Remote CI evidence remains pending. + +Latest local checks, 2026-10-06: musl formatting, strict workspace Clippy, +all-target workspace tests, Fedora base/repeat-apply integration, ShellCheck, +and bootstrap regressions passed under Rust 1.99. Checks ran outside the sandbox +restrictions that previously blocked provider HTTP fixtures. GitHub's last run +failed Rust 1.99 collection-assertion lints and an intentional desktop helper's +ShellCheck warning; fixes are verified locally and await a new remote run. + +## Progress + +| Step | Implementation | Evidence still needed | +| --- | --- | --- | +| 1. Rust-owned smoke lifecycle | Implemented; named `clamps/smoke` fixture/reboot and scripted interrupted-checkpoint recovery passed | None locally; remote CI pending | +| 2. Update delivery guard | Implemented; ownership-loss/retry tests and retained-VM update passed | None locally; remote CI pending | +| 3. Fake contracts | Implemented; workspace regression suite passes | None locally; remote CI pending | +| 4. Bootstrap CI | Implemented; CI runs state regressions and local command passes | Observe remote CI after push | +| 5. Host application acceptance | Implemented from canonical profile capabilities; Syncthing host/container identity mapping corrected with regression coverage | PARKED: named-VM Syncthing apply, repeat apply, and reboot acceptance until Flatpak libvirt access returns | +| 6. Documentation | Updated implementation, commands, design status, and fault-injection evidence | Final review and remote CI result | + +## Start here + +Read `../AGENTS.md`, `../skillet/AGENTS.md`, `SKILLET-REFACTOR.md`, +`REFACTOR-07-TEST-INFRASTRUCTURE.md`, and the architecture, smoke-VM, secrets, +and private-UI designs. Inspect current source and working-tree changes before +editing; file locations below identify responsibilities, not frozen interfaces. + +Work in the order below, with focused commits after validation. Keep public +command syntax and retained manifests compatible. Use existing native/Flatpak +backends and toolchains; install nothing on the workstation. Libraries use +`thiserror`; never embed shell programs in Rust. Routine tests need no vault, +provider access, or hypervisor. Live checks use an explicitly named disposable +VM and the existing vault-unlock workflow. If unlock would block, continue +independent work and record the live check as pending. + +## 1. Move smoke operations under the Rust lifecycle owner + +Inspect `skillet/crates/cli/src/main.rs` (`run_smoke`, `SmokeLifecycle`), +`crates/vm/src/{transport,readiness,backend,manifest}.rs`, and +`integration_tests/smoke-guest.sh`. The former `smoke-ssh.sh` lifecycle owner +has been removed. + +- Hold the per-run lock for the complete smoke workflow. Load SSH trust, + identity, phase, and deployed artifact hashes from the validated manifest. + Keep optional CLI overrides as equality checks against those values. +- Use `OwnershipCheckedTransport` for every upload and guest command. Validate + live UUID/disk ownership after artifact compilation and before guest contact. + Refuse later remote operations as soon as ownership validation fails. +- Move fixture/script delivery, installed-artifact verification, reboot, and + bounded return detection into shared Rust orchestration. Guest assertion + scripts may remain standalone; they receive explicit validated inputs and + must not own SSH, lifecycle retries, or reboot. +- Persist the non-ready checkpoint before reboot. Verify that a new boot + occurred and the selected profile's readiness checks pass before restoring + Ready. Preserve diagnostics and retry state after failed reboot/acceptance. + Reuse existing locked helpers without recursively acquiring the same lock. +- Retired `smoke-ssh.sh`; its caller and AGENTS runtime instructions now use + the authoritative Rust command. + +Exit: tests show ownership loss before/after a remote operation prevents later +operations; the run lock excludes competing mutations; reboot failure leaves +the run non-ready; successful smoke uses recorded host-key verification and +the selected instance. A named VM passes fixture checks and reboot through the +Rust owner. No remaining smoke wrapper owns SSH/reboot/wait sequencing. + +## 2. Guard binary update delivery + +Inspect `skillet/crates/cli/src/vm.rs` (`update`) and +`crates/vm/src/{delivery,transport}.rs`. + +- Retain the existing run lock and build-result artifact selection. Wrap update + delivery in the shared ownership-checked transport after the build. +- Revalidate ownership before saving deployed hashes. Preserve captured hashes + and the previous deployment record if transfer or ownership validation fails. +- Add a regression scenario where ownership disappears between delivery calls: + no subsequent upload/install occurs and success is not recorded. Also cover + a partial transfer followed by a successful retry. + +Exit: all update guest operations use the same checks as readiness/provisioning; +successful delivery verifies installed bytes/metadata before recording hashes. +A retained disposable VM can update and rerun smoke with the new hashes. + +## 3. Make storage and service fakes honor their contracts + +Inspect `skillet/crates/core/src/test_utils.rs`, storage/service traits and +real adapters, their contract tests, and host/storage/btrbk tests. + +- Model directories, Btrfs subvolumes, and mounted subvolumes distinctly. Require + existence and the requested backing mount/subvolume root; ordinary directories + must not satisfy subvolume requirements. Expose explicit fixture setup instead + of default success. Update host fixtures to declare their storage prerequisites. +- Preserve directory metadata and existing failure injection. Make unsupported + fake operations return an explicit error. +- Reload must preserve a running service's active state. Inspect the real + adapter's inactive/missing-service behavior and reproduce it in the fake; + reload is not an implicit start. +- Add meaningful contract checks for missing paths, wrong resource type, + mismatched mount/root, repeated convergence, failed effects, and reload state. + Ensure recorder-wrapped fakes preserve those results. Do not mutate the + workstation's mounts or services to exercise contracts. + +Exit: full injected host composition still passes with explicit valid fixtures; +invalid storage fails; btrbk cannot accept a plain directory as its snapshot +source; a successful reload does not falsely make a running service inactive. + +## 4. Execute bootstrap regressions in CI + +Inspect `../../.github/workflows/ci.yml`, `butane/tests/bootstrap.sh`, and the +workflow's existing input filters and shell checks. + +- Add an explicit step running `bash ublue/butane/tests/bootstrap.sh` from the + repository root. Keep it credential-free and independent of libvirt. +- Ensure changes to bootstrap scripts, included units/configuration, test + fixtures, and the workflow select the job. Retain lint coverage for executable + helpers regardless of extension. +- Run the same bootstrap command locally and update the exact required-check + commands in AGENTS if necessary. + +Exit: CI executes the state regressions, rather than merely linting them, and +shared bootstrap input changes trigger that coverage. Record local evidence; +remote CI remains unverified until a run is observed. + +## 5. Add actual host acceptance selected by capabilities + +Inspect `skillet/crates/host-profiles`, host composition, service declarations, +the new Rust smoke owner, and `integration_tests/smoke-guest.sh`. + +- Keep synthetic fixture acceptance and actual application acceptance explicit + in commands/output. Define reusable acceptance expectations from canonical + profile/service declarations; do not hard-code one host or duplicate its + recipe configuration in an unrelated test registry. +- Check declared systemd/container services, mounts and ownership, container + network mode, published listeners, and applicable application health. Use + focused guest commands or a standalone guest assertion helper, invoked only + through the guarded Rust transport. Redact credentials and private UI names. +- Compare managed bytes/metadata and relevant runtime identity around repeat + apply. After reboot, check the declared services and persistent application + data again. Exclude logs, caches, and counters from equality comparisons. +- Make credential-dependent application checks an explicit provisioned mode. + Missing prerequisites fail that mode clearly; fixture success must not imply + application success. External ACME/enrollment checks stay opt-in. +- Unit-test selection with differing/synthetic capability sets and injected + observations. Cover stopped services, wrong mounts/network/listeners, changed + managed state, and lost persistence without live providers in routine CI. + +Exit: a named provisioned clamps VM passes checks for its declared services, +repeat apply, and reboot persistence. A second profile/capability combination +proves selection is generic in routine tests. Report exactly which services +were checked and which opt-in checks were omitted. Keep production renewal, +outside-tailnet access, and other previously deferred checks deferred. + +## 6. Reconcile documentation and close the gate + +- Update the roadmap and affected slice progress to reflect implemented versus + pending work. Reconcile `design/skillet-architecture.md`, `design/smoke-vms.md`, + secrets/private-UI acceptance statements, and migration entry points. +- Correct recording format version documentation against emitted format 2, + manifest-bound smoke override instructions, vault requirements, and native + libvirt runtime instructions. Remove manual cleanup advice that bypasses + provider journals or the Rust lifecycle owner. +- Update the crate map and record general prevention rules in AGENTS: every + lifecycle entry point shares locking/ownership checks; fakes preserve resource + type/state; CI executes required regression suites; fixture acceptance cannot + substitute for declared application acceptance. Avoid incident-specific rules. +- Record exact commands, code/artifact versions, outcomes, and unavailable checks + in `butane/ACCEPTANCE.md`. Preserve earlier evidence. Remove completed slice + plans only after decisions/evidence and remaining tasks have durable homes; + update links when removing them. + +Exit: commands and design claims match the implementation. No required check is +described as passed without evidence. All five preceding exits pass before the +roadmap is marked complete; explicitly deferred checks remain visible. + +## Final validation and handoff + +Run the applicable local CI checks: workspace format, pedantic all-target +Clippy, all-target tests, the Fedora base container integration command in +AGENTS, bootstrap regressions, and workflow ShellCheck for changed helpers. +Run named-VM checks for smoke, retained update, actual host repeat apply, and +reboot. Preserve failed-run diagnostics and ownership journals for retry. + +Use focused commits, do not push without authorization, and report the completed +items, interface changes, validation evidence, and any remaining blockers. diff --git a/ublue/plan/SKILLET-REFACTOR.md b/ublue/plan/SKILLET-REFACTOR.md new file mode 100644 index 00000000..c7bb0282 --- /dev/null +++ b/ublue/plan/SKILLET-REFACTOR.md @@ -0,0 +1,85 @@ +# Skillet refactoring prerequisite + +Status: implementation complete; validation gate remains open, 2026-10-06. +The six follow-ups and scripted interrupted-reboot recovery are implemented. +The Syncthing identity fix is committed as `43438e3`, CLI diagnostics as +`126004f`. Its live apply/repeat/reboot check is PARKED until Flatpak libvirt +access returns; remote CI evidence remains pending. Track these in +[the six-point follow-up](REFACTOR-FOLLOWUP.md). Migration documentation, +parity auditing, and DDNS planning proceed by user instruction while the check +is parked; this does not constitute new runtime acceptance. +Previous routine validation and fresh/retained VM runs remain evidence. The live +legacy-manifest import and controlled interrupted-provisioning recovery are +recorded in the [acceptance log](../butane/ACCEPTANCE.md#legacy-manifest-import-and-interrupted-provisioning-recovery-2026-10-05). +Production delivery/renewal and outside-tailnet denial remain explicitly +deferred from this refactor gate. + +The workstream table below records earlier acceptance, not closure of the +follow-up acceptance gate. Lifecycle and fake-contract corrections are now +implemented; workstream 7 still requires live verification of the committed +Syncthing ownership correction and remote CI. No additional refactor slice is +currently planned. + +Read `../AGENTS.md`, `../skillet/AGENTS.md`, and +[the architecture decision](../design/skillet-architecture.md) first. Then read +the designs and source files listed in the selected workstream. Start from the +current working tree; do not assume the review's commits or line numbers are +still current. Inspect changes before editing and preserve unrelated work. + +## Execution order + +The numbers match the seven review findings. Complete one reviewable slice at +a time in this order. These are coordinated workstreams, not seven independent +assignments to execute concurrently against the same interfaces. + +| Order | Workstream | Depends on | Status | +| --- | --- | --- | --- | +| 1 | [VM lifecycle and Bash-to-Rust migration](REFACTOR-01-VM-LIFECYCLE.md) | Current lifecycle/secret designs | Fresh/retained cycles, live legacy import, and controlled recovery passed | +| 2 | [Canonical host profiles](REFACTOR-02-HOST-PROFILES.md) | VM identity types from 1 | Retained apply and synthetic-profile genericity checks passed | +| 3 | [Effect interfaces and ownership](REFACTOR-03-RESOURCE-BOUNDARIES.md) | Profile inputs from 2 | Live Btrfs, service ownership, and descendant-preservation checks passed | +| 4 | [CLI/library and delivery boundaries](REFACTOR-04-CLI-PROVISIONING.md) | Interfaces from 1–3 | Delivery, rotation, interrupted retry, provider cleanup, and VM cleanup passed | +| 5 | [Podman configuration and host policy](REFACTOR-05-PODMAN-MODEL.md) | Profiles/effect interfaces from 2–4 | Live service-network, identity, ownership, rotation, and repeat-apply checks passed | +| 6 | [Convergence outcomes and recording](REFACTOR-06-CONVERGENCE-RECORDING.md) | Resource/configuration contracts from 3–5 | Fixture recovery/reboot and host repeat-apply passed | +| 7 | [Test layers, fixtures, artifacts, and CI](REFACTOR-07-TEST-INFRASTRUCTURE.md) | Final interfaces from 1–6 | Implementation/fixture acceptance passed; Syncthing live retry parked; remote CI pending | + +Each earlier slice includes its own regression checks; testing does not wait +for workstream 7. Workstream 1 creates the minimal VM/transport contracts used +by workstream 4; workstream 4 consolidates delivery on those contracts rather +than inventing another VM transport or lifecycle owner. Preserve the existing +public command syntax unless a required change is documented with migration. + +## Scope and completion gate + +- Keep existing service crates and canonical public/private configuration + boundaries. Do not add new services, change storage layouts, broaden token + scope, install workstation packages, or migrate production data in this work. +- Preserve supported native and Flatpak VM backends, static guest artifacts, + retained VMs, SSH verification, atomic vault writes, and credential lifetimes. +- Routine CI uses dummy secrets and local fakes/HTTP fixtures. Live checks use + named disposable VMs and the existing vault unlock workflow, never chat + passwords or committed credentials. Preserve evidence and ownership on failure. +- For Rust slices run workspace format, pedantic Clippy, tests, and the current + CI integration command; for shell slices run ShellCheck on every affected + helper, including extensionless files. After workstream 7 use the updated CI + commands. Run affected VM checks as each plan requires. +- Record exact commands, code/artifact versions, outcomes, and unavailable + checks in `../butane/ACCEPTANCE.md`. Unavailable live/backend checks stay + unverified; do not mark a workstream complete while its required evidence is + missing. Distinguish unchanged, explicitly deferred production acceptance + from the checks required to validate this refactor. +- Make focused commits after validation and document incomplete validation + honestly. Do not push without authorization. Update this table and each + workstream's progress, design decisions, README usage, and general AGENTS + prevention rules in the corresponding implementation slice. +- Completion evidence includes fresh and retained clamps VM cycles, real + service/storage inspection, staging Caddy acceptance, fixture recovery and + reboot, provider cleanup, unchanged host repeat apply, legacy-manifest import, + and controlled interrupted-provisioning recovery. Production + delivery/renewal and outside-tailnet denial are explicitly deferred from this + refactor gate; routine CI remains credential-free. + +Resume [the migration plan](CLAMPS-MIGRATION.md) only after this gate passes. +The existing private-UI, storage/credential, UniFi, and TPM plans retain their +unfinished work; this prerequisite changes their order, not their acceptance +requirements. Remove completed refactoring plans only after preserving decisions, +evidence, and any remaining tasks as required by AGENTS. diff --git a/ublue/plan/TPM-ENCRYPTED-ROOT.md b/ublue/plan/TPM-ENCRYPTED-ROOT.md new file mode 100644 index 00000000..8622ae90 --- /dev/null +++ b/ublue/plan/TPM-ENCRYPTED-ROOT.md @@ -0,0 +1,221 @@ +# TPM-encrypted uCore root + +Prerequisite: complete [the Skillet refactoring roadmap](SKILLET-REFACTOR.md) +before proceeding with the remaining feature milestones in this plan. +Existing acceptance evidence and deferred tasks remain applicable. + +Status: native profile implemented 2026-10-10. Fresh signed bootstrap, +independent-key recovery/cold boot, runtime fixture, plain-profile readiness, +and owned disposal/recreation passed in native disposable VMs. +The accepted scope is encrypted-disk loss/removal with native Clevis SHA256 +PCR 7 plus Secure Boot. Custom UKIs/signed PCR policies and Tang are deferred. +Unencrypted root remains supported and is the default. Physical and Flatpak +acceptance are unverified. The refactor live retry remains parked. + +Implemented: typed optional manifest/install profile, conflict validation, +mapper-safe Btrfs mounts, common UEFI/TPM XML, capability preflight, owned +TPM/NVRAM disposal, private serial logging, profile-aware readiness and the +repeatable independent-key/header/cold-boot acceptance command. Routine CI +covers rendering, prerequisites, staging and policy rejection without a vault. +Production recovery-key vault persistence is implemented with atomic pending/verified +state, volume identity validation, and a separate VM archive command. Live +VM vault enrollment, unchanged retry, independent-passphrase verification and +unattended cold-boot persistence passed on 2026-10-10. Protected header export +is implemented as a verified KDBX attachment. Native simulated TPM-loss +recovery/re-enrollment, header restoration and completed-retry acceptance passed +on 2026-10-11. Physical USB media generation is implemented; installation on real hardware +remains pending. See [the hardware runbook](../butane/HARDWARE.md). + +## Scope and decisions + +Use LUKS2 beneath the shared Btrfs root backing `/sysroot`, `/var`, and the +`data` subvolume mounted at `/var/lib/data`. Keep uCore's composefs/deployment +layout, application paths, Podman graphroot, and btrbk leaf snapshots. +EFI and `/boot` remain outside encryption; boot integrity needs a separate +verified policy. Normal boots must unlock locally without an operator or +network. Every host gets an independent recovery key and LUKS header backup. + +Prefer TPM2 on local and remote physical servers. Assess each remote server's +actual TPM and firmware before choosing its install profile. Tang is deferred; +do not implement a Tang service, initramfs VPN, or public unlock endpoint. +An explicitly requested encrypted profile must fail if its prerequisites are +missing, never silently install unencrypted storage or an unrestricted unlock +slot. Existing unencrypted development VMs may retain their explicit profile. + +Read `AGENTS.md`, `skillet/AGENTS.md`, `design/storage.md`, `design/secrets.md`, +`design/smoke-vms.md`, `butane/clamps.bu`, `butane/includes/data-storage.bu`, +`butane/bin/{test-vm,test-vm-ready,butane}`, and the ucore-images recipes. +Make small validated changes in the order below. Never format a populated +physical disk as part of Skillet convergence; use only named disposable disks +until physical install and restore are explicitly arranged. + +## Audit findings and next slice (2026-10-10) + +- Read-only retained-VM inspection confirmed native Clevis/TPM/Btrfs unlock + components in the deployed initramfs. No image/package change is needed yet. + The exact initial cached FCOS image still needs boot-time verification. +- Native workstation q35 capabilities advertise EFI, secure loaders and TPM2 + emulation. swtpm 0.8, QEMU 10.1, libvirt 11.10 and OVMF with enrolled-key + templates are installed. No physical TPM is exposed; virtual tests remain + possible. Flatpak equivalence and physical hardware are unverified. +- Retained unencrypted XML selects q35 without UEFI/NVRAM or TPM. + The new explicit profile extends the shared manifest/XML/backend/lifecycle + owner while preserving retained BIOS manifests. +- `includes/data-storage.bu` now selects the verified filesystem UUID/label + rather than a raw root partition; UUID/topology and mount guards remain. +- Separate GRUB kernel/initramfs entries are deployed, not an observed signed + UKI/PCR policy pipeline. Native PCR-7-based unlock offers a narrower guarantee + than authenticated whole-machine boot. The operator accepted this narrower + scope; custom UKIs/signed policies are deferred. Do not choose empty PCRs or silently claim stronger protection. +- With the accepted scope, verify enrollment timing across FCOS, uCore and MOK, + using the explicit profile and named UEFI/TPM VM. Keep the + existing unencrypted retained smoke VM unchanged. Recovery material must be + established before TPM replacement/tampering tests. + +Evidence and policy limits: `../design/storage.md` and +`../butane/ACCEPTANCE.md`. Audit completion is not encrypted-boot acceptance. + +## 1. Establish hardware, image, and boot policy + +- Inventory exposed TPM2, supported PCR banks, UEFI/Secure Boot, installed + image versions, and initramfs Clevis/cryptsetup/TPM/Btrfs support. On older + systemd use `systemd-creds has-tpm2`; newer releases can use + `systemd-analyze has-tpm2`. An empty device list is not proof that the + motherboard lacks a disabled TPM. Do not install workstation packages. +- Determine the actual boot chain before selecting PCRs. Plain Clevis + `tpm2: true` supplies no explicit PCR policy. PCR 7 represents Secure Boot + state; it alone does not authenticate our kernel, initramfs and command line. + Include shim/MOK handling in the assessment. Avoid choosing exact kernel or + command-line measurements that strand unattended hosts on every update. +- Investigate signed PCR policies and their compatibility with the existing + image build, FCOS-to-uCore bootstrap, updates and rollback. If using systemd + enrollment rather than native Clevis, prove its initramfs unlock support. + Record the selected mechanism, measurements, protection limits and update + procedure in the storage design. Do not claim whole-machine theft protection + until the boot components and alternative boot paths are covered. +- Keep packages/static initramfs support in ucore-images; host enrollment and + private values stay out of that public image. Preserve serialized image CI. + +Exit: an explicit boot policy and recovery procedure are documented, and all +bootstrap/deployed images can unlock it. Unsupported policy integration is a +reported blocker, not permission to weaken the chosen policy implicitly. + +## 2. Add an explicit encrypted installation profile + +- Model the selected encryption profile separately from host identity, VM + instance and physical disk selection. Reuse it for every host that opts in; + do not hardcode clamps into shared storage or unlock logic. +- In Butane/Ignition create LUKS2 on the selected root partition and Btrfs + labelled `root` on `/dev/mapper/root`. Remove the existing raw-partition + formatting entry in that profile. Keep explicit partition sizing/resizing; + do not assume automatic growth after root reprovisioning. +- Enrollment must occur under the intended final boot policy. Account for + MOK enrollment and the FCOS-to-uCore bootstrap before taking measurements. + If a temporary install unlock path is necessary, document its lifetime and + removal and prove it is absent before acceptance. +- Generalize the data helper and mount currently referencing + `/dev/disk/by-partlabel/root`. Use a stable identifier of the unlocked Btrfs + filesystem and verify it matches `/var` and the expected backing topology. + Preserve wrong/missing-mount guards and SELinux labeling for Podman. + +Exit: generated Ignition and units target the mapper filesystem, carry no +private key, and preserve the existing data layout. Generation rejects missing +capabilities and conflicting raw/encrypted formatting entries. + +## 3. Add TPM-capable disposable VMs + +- Add an opt-in TPM2/encrypted-install profile to the existing Rust CLI and + VM helper. Document its actual command interface once implemented. +- Both native libvirt XML and Flatpak virt-install paths must use equivalent + virtual TPM2 and UEFI/Secure Boot configuration. Check backend capabilities + before creating a run; missing swtpm/firmware support produces a useful error. + Use libvirt-managed swtpm, not passthrough of the workstation's physical TPM. +- Give each named VM independent TPM state and firmware NVRAM. Persist them + across shutdown/start/rebase; own and remove them during disposal. Never + reuse another VM's state. Record profile and ownership in ignored metadata. +- Readiness must be bounded and capture serial/early-boot diagnostics for + unlock failures, which occur before SSH. Do not add `systemctl --wait`. + +Exit: a fresh encrypted test VM reaches readiness automatically. Disposal and +fresh recreation create a new disk, TPM identity and NVRAM. Existing VM profiles +remain usable, and both backend paths have evidence or an explicit unverified +status. No workstation installation is performed by the agent. + +## 4. Establish recovery before destructive tests + +Status: independent key enrollment and vault persistence/retry are implemented +and verified in a native VM, including cold boot and invalid-key rejection. +Protected header export is implemented in both recovery-key commands, with +legacy-entry upgrade, refresh and encrypted attachment readback verification. +The native TPM-loss driver uses a fresh base-only stopped disk clone and +unlogged console. Native live acceptance passed on 2026-10-11: restored header +fixture, fresh TPM rejection, vault-assisted boot, verified re-enrollment, two +unattended cold boots, unchanged completed retry, and original TPM preservation. + +- Add an independent high-entropy recovery key while the valid TPM path works. + Persist production keys through the atomic Rust KDBX writer at + `skillet/hosts//storage/root-recovery-key`. Reuse an existing value; + verify it unlocks this specific volume before treating it as recovery. +- Disposable tests use generated temporary recovery material, never production + entries. Keep it out of command arguments, logs and Ignition; any temporary + file must be ignored, mode 0600, and removed on disposal. Prefer stdin. +- Store a LUKS header backup off the encrypted disk with restricted access; + document its private backup location and disk identity. Treat it as sensitive + and refresh it after enrollment/rotation. Test recovery from another + workstation using the backed-up vault. A header backup is not a data backup. +- Check for unintended key-file/unrestricted unlock slots; do not erase the + sole working unlock path. Capture only non-secret enrollment metadata. + +Exit: recovery is verified before TPM replacement or boot-policy negative +tests. TPM/motherboard loss cannot remove the only usable recovery material. + +## 5. QEMU acceptance matrix + +Use a fresh named disposable VM and generated secrets. Routine CI needs no +production vault, Cloudflare or Tailscale API. Networkless boot tests start +after image pulls/bootstrap; evaluate storage and base readiness separately +from services requiring the network. Record results in `butane/ACCEPTANCE.md`. + +| Check | Required observation | +| --- | --- | +| Fresh install and signed bootstrap | Every bootstrap reboot unlocks without typing; final boot policy is enrolled | +| Storage topology | Raw root is LUKS2; opened mapper contains Btrfs; `/var` and `/var/lib/data` share the expected filesystem and data subvolume | +| Cold power-off/start and reboot | Same TPM/NVRAM persists; no operator input; data sentinel and test credentials survive | +| Boot with NIC disconnected | Root/data unlock and base storage readiness succeed without Tang or Tailscale | +| Signed update, rebase and rollback | Each approved deployment boots unattended and preserves sentinel, subvolumes and credentials | +| Repeat Skillet apply | Enrollment and mount bytes remain stable; no unnecessary application recreation | +| Missing/wrong data mount | Applications cannot write into a fallback directory; restoring the mount recovers without formatting | +| Disk with a different or absent TPM | Automatic unlock fails within the test deadline; no unintended key-file or weaker slot succeeds | +| Change a covered boot measurement | Unlock is denied; restoring approved state recovers; test the actual selected policy, not an unrelated PCR | +| Recovery after simulated TPM loss | Recovery key opens the same volume and preserves data; re-enrollment restores unattended boot | +| Snapshot/restore | Syncthing leaf snapshot and restore work; no whole-data snapshot or nested subvolume support is introduced | +| Disposal and recreation | Only owned disk/TPM/NVRAM/test secrets are removed; next VM receives fresh identities | + +Positive-path VM tests cover functionality, not resistance to a compromised +hypervisor or physical firmware attacks. Negative tests operate on disposable +copies and never clear the workstation's TPM or change its boot firmware. + +## 6. Physical acceptance and completion + +- On the selected stock clamps SSD, confirm TPM2 and supported PCR bank, final + UEFI/Secure Boot/MOK state, unattended cold boot, networkless unlock, signed + update/rebase/rollback, storage/credential persistence, and recovery from + external media or an available remote console. Preserve rupik and its SSD. +- Verify each later physical server independently, including remote console + availability before enabling encryption. Do not assume a vendor/model has + TPM support or that a QEMU pass proves its firmware behavior. +- Run applicable CI checks and affected real-runtime smoke tests after code + changes. Keep design/README setup and VM lifecycle ownership documentation + updated. Make focused commits only after validation; report unavailable + checks honestly. No push without instruction. + +Done when QEMU acceptance and stock-SSD physical acceptance both have evidence, +recovery material is independently available, no temporary weaker unlock path +remains, and the chosen policy supports our unattended update workflow. + +References: [CoreOS encrypted root](https://github.com/coreos/fedora-coreos-docs/blob/main/modules/ROOT/pages/storage.adoc#encrypted-storage-luks), +[Ignition LUKS](https://coreos.github.io/ignition/operator-notes/#luks), +[Clevis TPM policy](https://github.com/latchset/clevis/blob/master/src/pins/tpm2/clevis-encrypt-tpm2.1.adoc), +[systemd enrollment policies](https://github.com/systemd/systemd/blob/main/man/systemd-cryptenroll.xml), +[PCR registry](https://uapi-group.org/specifications/specs/linux_tpm_pcr_registry/), +[libvirt TPM devices](https://libvirt.org/formatdomain.html#tpm-device). diff --git a/ublue/skillet/.cargo/config.toml b/ublue/skillet/.cargo/config.toml new file mode 100644 index 00000000..01e5eccf --- /dev/null +++ b/ublue/skillet/.cargo/config.toml @@ -0,0 +1,12 @@ +# Static musl builds: every host binary is a fully static executable with no +# glibc or other system library dependencies, so it can be dropped onto any +# x86_64 Linux target (e.g. an immutable uCore host) and just run. +# +# One-time setup: rustup target add x86_64-unknown-linux-musl +[build] +target = "x86_64-unknown-linux-musl" + +[target.x86_64-unknown-linux-musl] +# crt-static is already the default for the musl target; spelled out here so +# the intent (fully static, no dynamic libc) is explicit and auditable. +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/ublue/skillet/.gitignore b/ublue/skillet/.gitignore new file mode 100644 index 00000000..110386d5 --- /dev/null +++ b/ublue/skillet/.gitignore @@ -0,0 +1,4 @@ +target/ + +# Private UniFi application backups +*.unf diff --git a/ublue/skillet/AGENTS.md b/ublue/skillet/AGENTS.md new file mode 100644 index 00000000..575a678a --- /dev/null +++ b/ublue/skillet/AGENTS.md @@ -0,0 +1,222 @@ +# 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. +- **Never embed shell scripts in Rust string constants or construct shell programs inside Rust.** Use Rust APIs and focused subprocess calls for work owned by Skillet. If a shell script is truly needed, keep it in a standalone script file with its own interface. + +### 2. Idempotency +- All resources (files, users, groups, etc.) must be **idempotent**. +- Before performing an action, check the current state (e.g., compare SHA256 hashes for files, check existence for users). +- Actions should only be taken if the system state does not match the desired state. +- Use the normal blocking `systemctl start` or `restart` command and inspect its + result. Do not pass `--wait`: it waits for a started unit to stop and can hang + on long-running services or `RemainAfterExit=yes` oneshot units. + +### 3. Testing Strategy +- **Unit Tests**: Place unit tests in a `tests` submodule within each module's directory (e.g., `src/files/tests.rs`). +- **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 --workspace --all-targets`. + - **Runtime Smoke**: Run `cargo run --release -p skillet -- test smoke --instance ` against an explicitly named disposable VM with real systemd and Podman for affected container resources. Record the guest state snapshots and failure diagnostics. +- Acceptance checks distinguish transient staging from durable state, and + successful oneshot completion from persistent service activity. Compare + identities only after the consumer reaches its stable state. +- Tests for host-dependent observations must inject or simulate those + observations. Do not require a particular workstation port, account, service, + or external resource to happen to be available for a unit test. + +## Local musl toolchain + +- On this workstation, the musl cross compiler is installed under + `/opt/x86_64-linux-musl-cross/bin`. Before compiling musl binaries, run: + `export PATH=/opt/x86_64-linux-musl-cross/bin:$PATH`. +- Ensure this PATH is inherited by Cargo and its child build commands. Check + the installed Rust target and compiler before reporting musl as unavailable; + a missing binary artifact alone does not indicate a missing toolchain. + +## Architecture and refactoring rules + +Follow [the refactoring roadmap](../plan/SKILLET-REFACTOR.md) before further +feature milestones. These rules describe the target architecture; the roadmap +tracks existing violations and their migration, not completed implementation. + +- Route effectful observations and mutations through the same explicit + boundary. Recipe and orchestration code must not bypass an injected resource + interface to inspect the live filesystem, account database, process + environment, or runtime. Concrete adapters own those operations; load + credentials and ambient configuration at the entry point and pass them in. +- Keep account lookup, subordinate-ID reads, and account changes injectable. + Select one explicit mapping policy and report missing or invalid host + identity data instead of silently substituting IDs. +- Define traits around cohesive capabilities needed by consumers. Add an + operation to the capability that owns it, not an unrelated omnibus trait. + Use ordinary data and functions for pure validation, rendering, and static + recipe composition; do not introduce a trait for every recipe. +- Keep one authoritative host capability declaration. Application composition, + UI exposure, credential requirements, and provisioning eligibility must agree + with it. Reject unknown profiles for profile-dependent operations. +- Keep synthetic smoke/test fixtures outside production host identity and profile + dispatch. Invoke them through an explicitly test-scoped command or target. +- Keep binary entry points thin. Host composition, guest runtime adapters, + workstation provisioning, and test fixtures have distinct responsibilities. + Extract modules first and crates where dependency or deployment boundaries + justify them; production dispatch must not include synthetic test profiles. +- Keep workstation vault, provider-client, and host-provisioning dependencies + behind workstation libraries. Guest apply and credential commands must not + require a workstation vault, interactive unlock flow, or provisioning-only + API client. +- Share delivery and lifecycle workflows across environments. Supply explicit + policy and target inputs rather than duplicating algorithms or branching on + particular deployment names. Keep secret values off command lines and out of + manifests, recordings, and errors. +- Credential-capable transport keeps stdin and captured output in memory. + Use the shared verified guest transport for production and VM delivery; pass + executable names and arguments as separate values rather than assembling + remote command strings in callers. + Drain stdout/stderr concurrently with stdin, bound the complete operation + including stream completion, and zeroize owned payload copies after transfer. +- Use a common ownership representation for named and numeric identities. + Model application identity, host filesystem ownership, and namespace mapping + separately. Existing numeric ownership must not require a named host account. +- Render machine configuration with the target format's escaping rules; + disable HTML escaping for non-HTML templates. Test literal directives + through both the renderer and the actual downstream consumer. +- Match bind-mount sources to the resource helper's convergence contract. + A helper that creates directories cannot manage a socket or regular-file + source; use an appropriate typed resource or mount its existing directory + without changing application-managed metadata. Verify this on the real runtime. +- Give common configuration fields one authoritative representation. Reject + conflicts between typed fields and extension directives; preserve ordering + where repeated directives are semantically ordered. Global runtime policy + belongs to the host baseline, not an arbitrary container resource. +- Diagnose container kernel-feature failures against the effective capability, + seccomp, mount and LSM policy before declaring the host kernel unsupported. + Match syscall allow rules to explicitly selected capabilities; test the + smallest policy change and retain restrictions unrelated to the feature. +- Validate trust-store paths against the deployed OS. A rendered strict TLS + directive does not prove the consumer can initialize its TLS engine. +- Restrict credential-file access to the identities that actually consume them. + Test access under the worker identity, rather than inferring it from a + successful privileged preparation command. +- Regenerate volatile credential files before their consumers start. Validate + persisted environment policy during both apply and boot preparation; retained + queues or application data must not silently cross deployment policies. +- Authentication-only startup modes may skip later preference flags. Converge + persisted preferences through supported consumer commands after readiness, + without reusing enrollment credentials or relying solely on initial startup. +- Discovery acceptance must cover concurrent boot startup as well as steady + state. Derive startup ordering from existing caller declarations and keep + ordering separate from dependencies that activate optional services; an + active observer alone does not prove it discovered the intended workloads. +- State what every convergence result means. Distinguish definition changes, + activation, recovery, and no-op outcomes. Persist applied state only after the + consuming operation succeeds and retry pending activation after interruption. +- Define whether omission means leaving a resource unmanaged or ensuring its + absence. Deactivation must be explicit and must preserve application data + unless its deletion is authorized. +- Diagnostic recording must survive apply failures and describe operation + outcomes without exposing payloads. Do not treat an attempted operation as + proof of a change, success, or idempotence. +- Fakes and command stubs must honor production contracts for state, metadata, + identities, and error/exit semantics. Unsupported behavior must fail explicitly + rather than silently succeeding. Use shared contract checks for important + adapters and fakes; a mocked runtime does not prove real runtime acceptance. +- Select build artifacts from Cargo's reported outputs and honor its target, + profile, and configured target directory. Do not discover the new build by + scanning potentially stale binaries at conventional paths. +- Compilation and artifact lookup belong to one operation. Consumers use + that operation's reported executables and reject incomplete or ambiguous + results; do not rebuild in one path and rediscover binaries in another. +- Reading retained state must not implicitly migrate it. Import legacy formats + explicitly, preserve original evidence, and use atomic creation that cannot + overwrite a concurrently created authoritative manifest. +- Serialize lifecycle mutations per run. A successful runtime query must + establish absence; transport failure is not absence. Revalidate ownership + around each bounded remote operation and after long external calls; do not + continue with later operations when an ownership check fails. Address + destructive runtime actions by the recorded immutable identity, not only a + reusable name. +- Every VM guest-operation entry point uses the same per-run lock and validates + recorded UUID/disk ownership before and after each remote operation. Guest + assertion scripts may inspect application state, but lifecycle orchestration, + SSH, reboot, retries, and readiness remain with the Rust owner. +- Persist external cleanup intent before provider mutations. Bind each + ownership journal to the complete typed host, environment, and instance + identity; reject mismatches on retry and preserve journals when cleanup is + incomplete. +- Verify deployed artifact bytes and required metadata before recording delivery + success. Keep original capture evidence distinct from subsequent deployments; + a partially successful transfer must remain safely retryable. +- Readiness verifies authoritative boot and application evidence, not only a + marker or successful connection. Supply profile expectations explicitly, keep + probes bounded and retain private failure diagnostics without replacing the + original error. Refuse further guest contact after ownership is lost. +- Compatibility entry points obtain identity from validated recorded fields; + do not reconstruct independent identity parsers from composite resource names. +- Remove recovery metadata only after owned artifact deletion succeeds, too. + A recursive directory deletion must not erase its own retry journal before + discovering that a later child cannot be removed. +- Keep subprocess adapters focused: construct executable arguments and + environments directly, observe results, and bound waits with diagnostics. + Preserve supported workstation backends and static guest builds without + installing tools as part of a refactor. +- Validate isolation policies before importing sensitive application state. + Prove management connectivity across activation and reboot, respect kernel + hook ordering, and prepare recovery while the guest is still empty. Cancel + any automatic policy rollback before restoring production state; a recovery + mechanism must not silently reconnect a restored test copy to live services. +- CI must cover executable helpers regardless of filename extension and must + run relevant checks when their shared inputs change. Keep routine CI free of + private credentials and external API access; record live acceptance separately. + +## Testing Philosophy + +Skillet uses a multi-layered testing approach to ensure reliability and idempotency: + +1. **Trait-based Abstraction**: Core resources (`FileResource`, `SystemResource`) are defined as traits. This allows for easy mocking using `MockFiles` and `MockSystem` in unit tests. +2. **Diagnostic Recorder**: `apply --record PATH` can capture attempted resource operations for investigation. Recording equality is not an acceptance check. +3. **Real Runtime Smoke**: `test smoke` exercises the namespaced synthetic fixture; `--with-applications` additionally checks the actual services selected from the canonical host profile. The Rust lifecycle owner guards SSH, upload, reboot, retry, and readiness. Application mode compares managed configuration and metadata, runtime identity, declared network/listeners, and a data marker across repeat apply and reboot. + +## Project Structure + +The project is organized as a Cargo workspace: + +```text +skillet/ +├── Cargo.toml # Workspace configuration +├── AGENTS.md # Project mandates +└── crates/ + ├── core/ # Effect interfaces and idempotent primitives + ├── host-profiles/ # Canonical host capabilities and composition + ├── vm/ # Manifest, backend, transport and lifecycle + ├── workstation/ # Vault, provider, delivery and enrollment + ├── podman/ # Typed Podman configuration/runtime model + ├── btrbk/ # Caller-selected Btrfs snapshot configuration + ├── caddy/ # Private UI routes and reverse proxy configuration + ├── pihole/ # Pi-hole service configuration + ├── syncthing/ # Syncthing service configuration + ├── unifi/ # UniFi service configuration + ├── hardening/ # Shared baseline configuration + ├── test-fixture/ # Synthetic smoke-only host binary + ├── cli-common/ # Shared guest apply/credential handling + ├── cli/ # Workstation and guest CLI entry point + └── hosts/ + ├── clamps/ # skillet-clamps host entry point + └── joey/ # skillet-joey host entry point +``` + +## Module Design +- **Service Modules**: Service crates compose shared primitives and own their application configuration. Supporting libraries own cohesive runtime, host-profile, or workstation responsibilities. +- **Binary per Host**: Host binaries select a shared canonical profile. Keep their behavior consistent with the generic entry point; do not duplicate recipe composition between binaries. +- **Core Primitives**: Found in `skillet_core`, providing the building blocks for all modules. diff --git a/ublue/skillet/Cargo.lock b/ublue/skillet/Cargo.lock new file mode 100644 index 00000000..63d8df29 --- /dev/null +++ b/ublue/skillet/Cargo.lock @@ -0,0 +1,3764 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +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 = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "askama" +version = "0.12.1" +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 2.0.117", +] + +[[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 2.0.117", +] + +[[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 2.0.117", +] + +[[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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +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 = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +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 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cloudflare" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1678b3295890df5895480a7c080430e73df2b7101f1763f62a3b32dd532f1d37" +dependencies = [ + "chrono", + "http", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with", + "thiserror 2.0.21", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[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 = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +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 = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "endi" +version = "1.1.1" +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 2.0.117", +] + +[[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 = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +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 = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +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 = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keepass" +version = "0.13.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8161ba5159f649a4899e46aa084130263a7af722116ead3faec24e5ee92a7b9" +dependencies = [ + "aes", + "base64 0.23.1", + "block-modes", + "byteorder", + "cbc", + "chacha20", + "chrono", + "cipher", + "flate2", + "getrandom 0.4.2", + "hex", + "hex-literal", + "hmac", + "hybrid-array", + "indexmap 2.13.0", + "js-sys", + "quick-xml", + "rust-argon2", + "salsa20", + "secrecy", + "serde", + "sha2 0.11.0", + "thiserror 2.0.21", + "twofish", + "uuid", + "zeroize", +] + +[[package]] +name = "keyutils" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c5d275b92e255aa97acd757f9bbc9ddbaa8d0938715c5cb6c28b3e00b773637" +dependencies = [ + "bitflags 1.3.2", + "errno", + "keyutils-raw", + "libc", + "log", + "uninit", +] + +[[package]] +name = "keyutils-raw" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db322e79368bacfd8d7ecba9d9038866d2ff5e78b52377376f38b7360878392b" +dependencies = [ + "errno", + "libc", + "log", + "uninit", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +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 = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.0" +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 = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "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-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +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 = "osc8" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2d2086a3a477709f0208181e65857a9c062064ab2b3bb9abaf64f17d148bf3" + +[[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 = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +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 2.0.117", +] + +[[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 = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +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 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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 = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "rust-argon2" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae76b7506744d254fd0eb2c0ff5c5d108201ccbb083111ac04a44eeda105680" +dependencies = [ + "base64 0.22.1", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +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 2.0.117", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap 2.13.0", + "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 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "skillet" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "hex", + "keepass", + "keyutils", + "osc8", + "pulldown-cmark", + "rpassword", + "serde", + "serde_json", + "sha2 0.10.9", + "skillet_caddy", + "skillet_cli_common", + "skillet_core", + "skillet_datadog", + "skillet_hosts", + "skillet_podman", + "skillet_vm", + "skillet_workstation", + "supports-hyperlinks", + "tempfile", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", + "zeroize", +] + +[[package]] +name = "skillet-clamps" +version = "0.1.0" +dependencies = [ + "anyhow", + "skillet_cli_common", + "skillet_core", + "skillet_hardening", + "skillet_pihole", + "skillet_podman", +] + +[[package]] +name = "skillet-joey" +version = "0.1.0" +dependencies = [ + "anyhow", + "skillet_cli_common", +] + +[[package]] +name = "skillet-smoke-fixture" +version = "0.1.0" +dependencies = [ + "anyhow", + "skillet_cli_common", + "skillet_core", + "skillet_podman", + "thiserror 1.0.69", +] + +[[package]] +name = "skillet_btrbk" +version = "0.1.0" +dependencies = [ + "hex", + "sha2 0.10.9", + "skillet_core", + "thiserror 1.0.69", +] + +[[package]] +name = "skillet_caddy" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "skillet_core", + "skillet_datadog", + "skillet_podman", + "tempfile", + "thiserror 1.0.69", + "tiny_http", +] + +[[package]] +name = "skillet_cli_common" +version = "0.1.0" +dependencies = [ + "clap", + "nix", + "serde", + "serde_yml", + "sha2 0.10.9", + "skillet_caddy", + "skillet_core", + "skillet_datadog", + "skillet_hosts", + "skillet_nut", + "skillet_smtp", + "tempfile", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", + "zeroize", +] + +[[package]] +name = "skillet_core" +version = "0.1.0" +dependencies = [ + "askama", + "hex", + "nix", + "serde", + "serde_yml", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", + "tracing", + "users", + "zbus", +] + +[[package]] +name = "skillet_datadog" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "skillet_core", + "skillet_podman", + "thiserror 1.0.69", +] + +[[package]] +name = "skillet_ddns" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "skillet_core", + "skillet_datadog", + "skillet_podman", + "thiserror 1.0.69", +] + +[[package]] +name = "skillet_hardening" +version = "0.1.0" +dependencies = [ + "hex", + "sha2 0.10.9", + "skillet_core", + "tempfile", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "skillet_hosts" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "skillet_btrbk", + "skillet_caddy", + "skillet_core", + "skillet_datadog", + "skillet_ddns", + "skillet_hardening", + "skillet_mikochi", + "skillet_nut", + "skillet_pihole", + "skillet_podman", + "skillet_smtp", + "skillet_syncthing", + "skillet_unifi", + "thiserror 1.0.69", +] + +[[package]] +name = "skillet_mikochi" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde_json", + "skillet_core", + "skillet_datadog", + "skillet_podman", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "skillet_nut" +version = "0.1.0" +dependencies = [ + "sha2 0.10.9", + "skillet_core", + "tempfile", + "thiserror 1.0.69", + "tracing", + "uuid", +] + +[[package]] +name = "skillet_pihole" +version = "0.1.0" +dependencies = [ + "askama", + "skillet_core", + "skillet_datadog", + "skillet_podman", + "thiserror 1.0.69", + "tracing", + "users", +] + +[[package]] +name = "skillet_podman" +version = "0.1.0" +dependencies = [ + "askama", + "hex", + "serde", + "sha2 0.10.9", + "skillet_core", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "skillet_smtp" +version = "0.1.0" +dependencies = [ + "askama", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "skillet_core", + "thiserror 1.0.69", +] + +[[package]] +name = "skillet_syncthing" +version = "0.1.0" +dependencies = [ + "skillet_core", + "skillet_datadog", + "skillet_podman", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "skillet_unifi" +version = "0.1.0" +dependencies = [ + "skillet_core", + "skillet_datadog", + "skillet_podman", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "skillet_vm" +version = "0.1.0" +dependencies = [ + "hex", + "nix", + "quick-xml", + "serde", + "serde_json", + "serde_yml", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", + "users", + "uuid", + "zeroize", +] + +[[package]] +name = "skillet_workstation" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "cloudflare", + "hex", + "keepass", + "keyutils", + "nix", + "reqwest", + "rpassword", + "serde", + "serde_json", + "serde_yml", + "sha2 0.10.9", + "skillet_caddy", + "skillet_datadog", + "skillet_ddns", + "skillet_hosts", + "skillet_smtp", + "skillet_vm", + "tempfile", + "thiserror 1.0.69", + "uuid", + "zeroize", +] + +[[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 = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[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 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +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 2.13.0", + "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 = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +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 2.0.117", +] + +[[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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twofish" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0218ef702a91e18ba84dd516edf8df33a9f9fb4431d6ada13e6f9502c3bcb6fc" +dependencies = [ + "cipher", +] + +[[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 = "uninit" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac20c07bcac4f505f1e4fbd2e55b54f6297a35a27538f107cb3d4254b4514146" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "users" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032" +dependencies = [ + "libc", + "log", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +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-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +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 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +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 2.13.0", + "prettyplease", + "syn 2.0.117", + "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 2.0.117", + "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 2.11.0", + "indexmap 2.13.0", + "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 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +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 0.8.5", + "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 2.0.117", + "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 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[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 2.0.117", + "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 2.0.117", +] diff --git a/ublue/skillet/Cargo.toml b/ublue/skillet/Cargo.toml new file mode 100644 index 00000000..f6dfa480 --- /dev/null +++ b/ublue/skillet/Cargo.toml @@ -0,0 +1,77 @@ +[workspace] +resolver = "2" +members = [ + "crates/core", + "crates/hardening", + "crates/pihole", + "crates/unifi", + "crates/syncthing", + "crates/mikochi", + "crates/nut", + "crates/btrbk", + "crates/caddy", + "crates/ddns", + "crates/datadog", + "crates/smtp", + "crates/podman", + "crates/cli", + "crates/hosts/joey", + "crates/hosts/clamps", + "crates/cli-common", + "crates/test-fixture", + "crates/vm", + "crates/host-profiles", + "crates/workstation", +] + +[workspace.dependencies] +osc8 = "0.1" +pulldown-cmark = { version = "0.13", default-features = false } +supports-hyperlinks = "3.2" +skillet_core = { path = "crates/core" } +skillet_hardening = { path = "crates/hardening" } +skillet_pihole = { path = "crates/pihole" } +skillet_unifi = { path = "crates/unifi" } +skillet_nut = { path = "crates/nut" } +skillet_mikochi = { path = "crates/mikochi" } +skillet_syncthing = { path = "crates/syncthing" } +skillet_btrbk = { path = "crates/btrbk" } +skillet_caddy = { path = "crates/caddy" } +skillet_ddns = { path = "crates/ddns" } +skillet_datadog = { path = "crates/datadog" } +skillet_smtp = { path = "crates/smtp" } +skillet_podman = { path = "crates/podman" } +skillet_cli_common = { path = "crates/cli-common" } +skillet_vm = { path = "crates/vm" } +skillet_hosts = { path = "crates/host-profiles" } +skillet_workstation = { path = "crates/workstation" } +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" +keepass = { version = "0.13.25", features = ["save_kdbx4"] } +rpassword = "7.5" +cloudflare = { version = "0.14.0", default-features = false, features = ["blocking", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +keyutils = "0.4" +uuid = { version = "1.26", features = ["serde", "v4"] } +quick-xml = { version = "0.41", features = ["serialize"] } + +[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..f4aaaeb5 --- /dev/null +++ b/ublue/skillet/README.md @@ -0,0 +1,813 @@ +# Skillet + +Monitoring composition and Datadog Agent acceptance are described +in the [monitoring design](../design/monitoring.md) and +[Datadog plan](../plan/CLAMPS-DATADOG.md). Container modules currently emit +credential-free Autodiscovery labels. The optional Agent uses a separate +credential-gated apply phase. + +## Datadog + +For an opted-in host, create this KeePassXC entry (Password field): + +- `skillet/datadog/api-key`: shared Datadog ingestion API key. + +Production and test use the same key; telemetry is tagged `env:prod` or +`env:test`. Production region comes from the host caller (Clamps: `region:ftwo`); +all tests use `region:lab`. Delivery replaces template region tags. +No application key is required for ingestion. Site is public configuration in +the `datadog` default template in +[configuration-templates.json](crates/workstation/src/configuration-templates.json), +defaulting to `datadoghq.com`. Change it there for another Datadog region; +host overrides and private tags use the shared configuration-template pattern. +Move any previous environment-specific key to the shared entry. Obsolete +Datadog key/site vault paths are reported as unused; the checker never deletes them. + +Deliver using `skillet secret deliver datadog` with the usual SSH +identity, known-hosts, and target options. For an owned disposable VM, use +`skillet test vm provision --with-datadog`. Unlock the vault +through the existing `secret unlock` workflow. Ordinary provisioning and smoke +do not enable Datadog. Historical telemetry remains after VM destruction. + +For one-time monitor administration, optionally add +`skillet/datadog/monitor-setup-key` (Password field) with `monitors_read` and +`monitors_write` scopes from Datadog Organization Settings → Application Keys. +This credential stays on the workstation. Exclude `env:test` in host/service +monitor data scopes; retain coverage for legacy Chef hosts tagged `env:home`. +Changing monitor metadata tags does not exclude matching telemetry. +The all-host Agent no-data and NTP monitors were updated on 2026-10-08; +include the same test exclusion when creating new monitors. +Existing running test hosts need Datadog credential redelivery for the region +change; destroying a VM does not erase historical telemetry. + +Skillet is a Rust tool for idempotent host configuration management. + +Design decisions: [secret storage and delivery](../design/secrets.md) and +[disposable VM lifecycle](../design/smoke-vms.md). + +## Building + +Skillet builds fully static x86_64 musl binaries by default +(see `.cargo/config.toml`). + +One-time setup: +```bash +rustup target add x86_64-unknown-linux-musl +``` + +### Development Build +To build the workspace for development, use the standard cargo command: +```bash +cargo build +``` + +### Production Build +For optimized production builds, use the `--release` flag: +```bash +cargo build --release +``` +The per-host binaries land at +`target/x86_64-unknown-linux-musl/release/skillet-` +(e.g. `skillet-clamps`). Verify with `file`, which should report +"statically linked". + +## Running + +The tool provides an `apply` command to execute configuration. `apply` defaults +to the full host and requires its application credentials. Use `--phase base` +to converge only the shared host baseline before credentials are available. + +- **Agent Mode**: Run generically on a host: + ```bash + ./target/x86_64-unknown-linux-musl/debug/skillet apply --host clamps --phase base + ``` +- **Host-Specific Configuration**: If a host-specific binary has been built (e.g., `skillet-joey`), you can use it to apply specific configurations. + +## Disposable VM smoke check + +`joey` replaces boxy and selects Pi-hole, Syncthing, Tailscale, snapshots, +DDNS, Datadog and shared SMTP. Use the [migration plan](../plan/JOEY-MIGRATION.md) +for remaining routing/production work and [host design](../design/fleet-hosts.md) +for selection decisions. The old misspelled binary/profile has been removed. + +The CI command `skillet test run joey --phase base --image fedora:latest` +runs the selected apply phase twice in a uniquely named, temporary Podman +container. It builds the selected host binary, checks both applies succeed, +and checks the second apply does not start or restart a service. `clamps` is +also supported. This sandbox uses mock `systemctl` and `podman` executables. +The phase defaults to `base`, since full apply may require a host's persistent +data mount and runtime credentials. +`--inspect` opens the container before cleanup. + +For real systemd/Podman and reboot coverage, create a disposable VM first: + +```bash +cargo run --release -p skillet -- test vm create clamps smoke +``` + +This provisions `clamps-test-smoke` on `giacomo@127.0.0.1:2201`, creates its SSH +key, and waits for the guest to pass `test-vm clamps ready smoke`. The same command +accepts another host when its `HOST.bu` and `skillet-HOST` are available. +Use `cargo run --release -p skillet -- test vm list` to see host availability +and recorded instances, or add a host to list only its instances. +Then run the clamps smoke scenario: + +```bash +cargo run --release -p skillet -- test smoke clamps +``` + +The smoke command reads the target, port, key and recorded host key from the +selected VM manifest. Optional `--target`, `--port`, and `--identity` values +must match that manifest; they cannot select another machine. It builds a +separate `skillet-smoke-fixture` executable from Cargo's reported artifact for +the running Skillet target and profile. Before running it, the guest verifies +the fixture transfer and confirms both installed Skillet binaries match the +ready VM manifest. Shared systemd +apply units use the generic binary and the stable host profile in +`/etc/skillet/host`. Repeated smoke runs +reset only the namespaced `/var/lib/skillet-smoke` fixture and its managed +files. The VM remains available for inspection and reruns. +Add `--with-applications` to require a provisioned VM and check the actual +services declared by that host profile. This checks service units, container +state/network/mounts, configured listeners, available service health probes, +unchanged runtime/configuration after repeat apply, and a data marker across +reboot. The default command checks the synthetic fixture only. + +To exercise recovery when Skillet stops after saving the pre-reboot checkpoint, +run the scripted interruption, readiness recovery, and normal reboot cycle: + +```bash +bash integration_tests/interrupted-reboot-recovery.sh clamps smoke +``` + +The script expects an existing, ready disposable VM. It deliberately expects +the first smoke command to exit at the injected checkpoint, verifies the +manifest is no longer `Ready`, runs readiness in a separate process, verifies +`Ready` is restored, then runs the regular smoke reboot and acceptance. Add +`--with-applications` for hosts with provisioned services. On clamps, the +current application check has a known Syncthing ownership divergence after +reboot; see the [acceptance log](../butane/ACCEPTANCE.md#interrupted-reboot-checkpoint-recovery-2026-10-05) +before enabling that option. + +After editing host code, use +`cargo run --release -p skillet -- test vm update clamps smoke` to rebuild and +install the current binary. `ready` reinstalls the artifact captured when the +VM was created. + +Rerun boot and base/user-environment acceptance with +`cargo run --release -p skillet -- test vm ready clamps smoke`. +Private diagnostics are retained in the run directory; see the +[VM commands](../butane/README.md) and [lifecycle design](../design/smoke-vms.md). + +When finished, remove the disposable VM, disk, SSH key, and run artifacts: + +```bash +cargo run --release -p skillet -- test vm destroy clamps smoke +``` + +The VM helpers require the host setup documented in the +[Butane README](../butane/README.md). Smoke requires the Skillet workspace and +Cargo. The VM fixture controls its own image. + +Build both static binaries from this directory if you want to install the host +binary separately: + +```bash +cargo build --release --target x86_64-unknown-linux-musl -p skillet -p skillet-clamps +sha256sum target/x86_64-unknown-linux-musl/release/skillet{,-clamps} +``` + +The generic artifact is `target/x86_64-unknown-linux-musl/release/skillet`; +the host artifact is `target/x86_64-unknown-linux-musl/release/skillet-clamps`. +The host artifact handles host-specific credential commands. + +The smoke scenario checks baseline/full separation, container startup and +idempotency, configuration and dummy secret rotation, failed startup recovery, +interrupted activation, and reboot persistence. It writes state snapshots and +failure output under `/var/lib/skillet-smoke/` in the guest. +The guest needs passwordless sudo for the SSH user and access to +pull `docker.io/library/alpine:3.20`. When `--identity` is supplied, SSH stores +that VM's host key beside the private key. + +## Secret setup and delivery + +Start with the repository-root [secrets checklist](../../SECRETS.md) for the +entries required by each module. + +The command group is `skillet secrets`; `skillet secret` remains an alias. + +```bash +cargo run --release -p skillet -- secrets unlock +cargo run --release -p skillet -- secrets check +cargo run --release -p skillet -- secrets check --host clamps --environment test +``` + +`check` defaults to all declared host profiles and production requirements. +It reads the vault, reports missing or invalid required entry fields with +the checklist's setup instructions, and exits unsuccessfully if any are missing +or invalid. Optional and automatically generated entries need not exist. +SMTP preparation uses one entry, `skillet/smtp`: set **Username** and +**Password**, plus custom fields `host` (Mailjet: `in-v3.mailjet.com`), `port` +(`587`), `tls` (`starttls`), and `sender` (a Mailjet-verified email address). +The audit requires it for all declared fleet hosts in production, checks field +presence/formats, and never prints values. +Test-policy checks omit Mailjet credentials because capture delivery never uses them. +The check does not authenticate to Mailjet. Native SMTP delivery is described below. + +Unused `skillet/` entries are reported against **all hosts and both prod/test**, +regardless of the check filter; unrelated personal entries are excluded. Unused +entries are advisory and are never deleted. No provider calls or credential +generation occur. These commands accept `--database` and `--key-file` overrides. + +Audit output uses plain labels and setup instructions. Supporting terminals get +clickable web links through OSC 8; pipes and unsupported terminals get readable +link destinations. Repository-relative documentation links remain file paths. +Set `FORCE_HYPERLINK=0` to disable links, or `FORCE_HYPERLINK=1` to override +terminal detection when stdout is a terminal. + +Manage durable secrets in your Syncthing-synced KeePassXC database. Use group +paths and entry titles from the [secret design](../design/secrets.md), storing +each value in its Password field. The workstation prompts for the database +password on its terminal and keeps the unlock in the Linux kernel keyring for +three hours. It does not write decrypted values to a file. Run +`cargo run --release -p skillet -- secret lock` to clear the cache early. +If the kernel denies key expiry, Skillet reports that it could not cache the +unlock and asks for the password on the next command. + +To unlock the database and verify the three-hour session cache without +provisioning a host or VM, run: + +```bash +cargo run --release -p skillet -- secret unlock +``` + +Use `--database PATH` for a non-default database or `--key-file PATH` when the +database uses a key file. The command verifies the database, then exits; it +does not change the vault. It reports an error if the password is valid but +the kernel session cache could not be established. +Skillet looks for `$XDG_DATA_HOME/skillet/secrets.kdbx`, falling back to +`$HOME/.local/share/skillet/secrets.kdbx`. Point that location at your +Syncthing-synced database, for example: + +```bash +mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/skillet" +ln -s /path/to/your/synced-vault.kdbx \ + "${XDG_DATA_HOME:-$HOME/.local/share}/skillet/secrets.kdbx" +``` + +The vault-dependent command reports the missing path before asking for its +password. `--database PATH` overrides the default when needed. Disposable VM +provisioning generates its own test value and does not require a vault. + +After creating and preparing a disposable clamps VM, deliver its generated +test password and run the full host apply: + +```bash +cargo run --release -p skillet -- test vm provision clamps smoke +``` + +The password stays encrypted on the VM. Repeat this command after a reboot to +reuse it; use `--rotate` to replace it explicitly. A fresh VM starts with a +new password. Provisioning also requires the current Butane +`skillet-full-apply.service` to load both the Pi-hole and Tailscale credentials. +Skillet checks this before unlocking the vault and tells you to recreate older +test VMs that lack the Tailscale credential load. +If an existing VM credential is corrupt, use `--rotate` to replace only that +disposable value. + +For a production clamps host, deliver over an SSH connection whose host key +you have already recorded: + +```bash +cargo run --release -p skillet -- secret deliver clamps pihole \ + --target giacomo@clamps --identity /path/to/ssh-key \ + --known-hosts /path/to/known_hosts +``` + +Add `--key-file /path/to/keyfile` if the database uses one. The value is +encrypted by the installed `skillet-clamps credential install` command on the +host and loaded by `skillet-full-apply.service`; later +`systemctl start skillet-full-apply.service` reuses it. If full apply +reports a missing or undecryptable credential, check the host's encrypted +credential file and redeliver the KeePassXC entry. If the entry is absent in +an existing vault and the host has no encrypted credential, Skillet creates +`skillet/hosts/clamps/pihole/web-password` and delivers it. Keep KeePassXC +closed during creation and resolve Syncthing conflict copies before retrying. +If the host already has an encrypted credential or Podman secret, restore the +original vault entry. +Redeliver the same entry after moving to a new workstation; use a deliberate +vault edit and redelivery for rotation. The production TPM binding decision +remains in the [secret design](../design/secrets.md). + +For Cloudflare token issuance, create an **account-owned API token** with +**Account > API Tokens > Write** (Read also works for permission discovery) +and **Zone > Zone > Read**, with Zone Resources restricted to the configured +test and/or production zones. Skillet reads the account ID from the selected +zone and uses the account-token endpoints to create, list, and revoke child +tokens. The issuer does not need DNS Write. Save it as +`skillet/cloudflare/token-creator`. Skillet discovers the Cloudflare +permission-group IDs at runtime, then creates zone-scoped child tokens with +Zone Read and DNS Write. If the issuer is already stored in the desktop +keyring, move the value into KeePassXC and verify the saved entry before +removing the old copy. + +### Caddy UI credentials + +Caddy proxies host-declared UIs on port 443 over Tailscale. Prepare these +KeePassXC entries in their Password fields: + +| Group path | Entry title | Value | +| --- | --- | --- | +| `skillet/environments//dns` | `ui-domain` | Optional relative prefix; defaults to `ui` when absent | +| `skillet/environments//dns` | `cloudflare-zone-id` | Required Zone ID for `prod` or `test` | +| `skillet/cloudflare` | `token-creator` | Workstation token issuer | + +Select `production` or `test` for the CLI environment; use `prod` or `test` as +the KeePassXC environment group name. Copy the Zone ID from the +selected zone's Cloudflare Overview page. Skillet fetches the zone's domain +and always appends it to the relative prefix. For zone `example.com`, an +absent entry produces `ui.example.com`; `ui.whatever` produces +`ui.whatever.example.com`. An empty entry is invalid. Replace any former +full-domain value with a relative prefix, or remove the entry to use `ui`. +Full-domain values are not detected or reused as absolute names: they too +are appended to the zone. Do not copy a full domain from the old +`skillet/dns/smoke-ui-zone` entry. The previous `cloudflare/zone` entry contained +a zone name; populate `dns/cloudflare-zone-id` with the actual ID. +Clean up any existing disposable UI deployment with the previous CLI and +configuration before migrating its prefix; cleanup verifies the recorded +resolved namespace and refuses a changed configuration. + +The CLI reads these environment paths directly. Persistent Caddy delivery +creates and saves a missing host ACME token at +`skillet/environments//hosts//cloudflare/acme-token`, then +reconciles the host's Tailscale A/AAAA records and UI CNAMEs. Smoke VMs only do +this when explicitly provisioned with `--with-ui`; they receive a 12-hour +zone-scoped token and `test vm destroy` removes its owned records and token. +Do not create a manual smoke VM ACME token. See the +[generic UI plan](../plan/GENERIC-PRIVATE-UIS.md) and +[Cloudflare lifecycle design](../design/cloudflare-ui-lifecycle.md). + +### Private file browsing + +Hosts declaring Syncthing also run Mikochi at `https://files..`. +For the default UI prefix, this is `files..ui.`. + +1. Create `skillet/tailscale/ui-users` in KeePassXC. Put complete Tailscale + `LoginName` values in **Password**, separated by commas or newlines; leave + **Username** empty. Find the identities in `tailscale status --json` on an + authenticated client. Include each allowed person explicitly. +2. In Tailscale Access controls, allow those people to reach the server tags on + `tcp:443`. For [grants](https://tailscale.com/docs/features/access-control/grants), + use their user group in `src`, `tag:skillet-server` and `tag:skillet-smoke` in + `dst`, and `["tcp:443"]` in `ip`. Tag ownership does not grant connectivity. +3. Unlock with `skillet secrets unlock`, then run + `skillet secrets check --host clamps --environment test`. +4. Provision the named test VM with + `skillet test vm provision clamps --with-ui`. +5. Open the files hostname from an allowed person's untagged tailnet client. + Mikochi has no additional login. Tagged server clients are denied. + +The default mounted file scope is `/var/lib/data/syncthing/data`, owned and +mapped like Syncthing. Configure synchronized folders beneath that directory. +New Syncthing instances use it as their home (the initial folder is `data/Sync`); +existing folder paths are preserved and must be moved/configured separately. +Mikochi cannot browse Syncthing's `config` directory or other service data. + +After updating a retained VM's Skillet binaries, redeliver Caddy credentials: +its previous service inventory lacks the new files site. Version 2 inputs +remain readable for unchanged profiles without identity-protected sites; +identity protection uses version 3. Removing a person from the vault takes +host effect after redelivery. The host policy is checked on every request. + +Mikochi has no published host port. Only Caddy joins its dedicated, isolated +backend network. Use a Netavark backend supporting `isolate=strict` and verify +bridge isolation; the smoke guest uses Podman 5.8.4 / Netavark 1.17.2. +The authorization endpoint uses `/run/skillet-tailnet-ui/auth.sock`; +the daemon socket remains outside Caddy at +`/run/skillet-tailscale/tailscaled.sock`. Do not forward the backend port or +mount either socket into unrelated containers. + +See [file browsing](../design/file-browsing.md) and +[tailnet UI authentication](../design/tailnet-ui-authentication.md). +Headscale identity lookup is not accepted yet; hosted Tailscale enrollment and +cleanup still use the hosted provider API. + +### Tailscale setup for Skillet + +Clamps runs `tailscale/tailscale` as a host-network container, matching Chef +on rupik, calculon, and boxy. Its state persists under +`/var/lib/data/tailscale`. Skillet reads the workstation OAuth client from +KeePassXC, mints a one-use enrollment key, and sends only that key to the host +inside a versioned encrypted systemd credential, together with resolved routing +policy. Only the decoded key becomes the Podman secret. Smoke VM destruction +removes the matching `tag:skillet-smoke` device before deleting the VM. This +requires `curl` on the workstation. See the +[private UI design](../design/private-ui-access.md). + +Clamps selects `advertise_exit_node: true` in its canonical host declaration. +Production credential delivery enables that preference; smoke provisioning always +disables it. Approve the provisioned production device as an exit node in the +Tailscale admin console (or your existing auto-approval policy), then select it +on clients. A restrictive tailnet policy must allow those users access to +`autogroup:internet`; see [exit-node setup](https://tailscale.com/docs/features/exit-nodes). +No extra vault secret is needed. For an existing raw-key credential, +redeliver with the production command below to activate the new policy. + +1. In the Tailscale admin console, open **Access controls** and ensure the + policy defines `tag:skillet-provisioner`, `tag:skillet-server`, and + `tag:skillet-smoke`. Set `tagOwners` so the provisioner tag owns the server + and smoke tags. Give smoke devices only smoke access. +2. In **Trust credentials**, create an OAuth client restricted to + `tag:skillet-provisioner`, with both **Keys > Auth Keys: Write** (`auth_keys`) + and **Devices > Core: Read/Write** (`devices:core`) scopes. The first mints + enrollment keys; the second lets Skillet find and remove its tagged smoke + device. Do not grant DNS-management scopes. Keys are one-use, preauthorized, + non-ephemeral, and expire after one hour if unused. +3. Copy the OAuth **Client ID** and **Client secret** from the creation page. + In KeePassXC create two entries, putting each value in its **Password** + field: + + | Group path | Entry title | Password value | + | --- | --- | --- | + | `skillet/tailscale` | `provisioner-client-id` | OAuth Client ID | + | `skillet/tailscale` | `provisioner-client-secret` | OAuth Client secret | + + The ID is not secret, but storing both this way lets Skillet use its + Password-field lookup for the complete credential. The client secret can + mint new auth keys, so treat it as the workstation-side master credential. + Never put it in a VM, Quadlet, environment file, or repository. Close + KeePassXC before Skillet edits the database. +4. Keep the test and production UI domain and Cloudflare Zone ID in their + respective KeePassXC environment entries. Skillet validates each domain + against the exact zone ID before it creates credentials or DNS records. No + delegated subzone or Tailscale split DNS configuration is required. +5. For a disposable VM, run `cargo run --release -p skillet -- test vm provision clamps smoke`. + Skillet prompts to unlock KeePassXC when its three-hour kernel cache is + empty, enrolls the VM, then provisions Pi-hole. Repeat the command to reuse + the Tailscale identity and Pi-hole credential. Add `--with-ui` to also mint + the disposable Cloudflare credential, publish its tailnet DNS records, and + activate Caddy with the test ACME staging issuer. Dispose of the VM with + `cargo run --release -p skillet -- test vm destroy clamps smoke`. If + Tailscale cleanup fails, destruction stops and the VM metadata remains for + retry. UI provisioning also probes each configured hostname from the VM's + loopback address and requires Caddy's explicit 403 denial response. This + checks the source-address rule; it does not replace a test from a separate + network outside the tailnet. Keep the OAuth client available for cleanup. +6. For production, deliver the Pi-hole credential first, then Tailscale: + + ```bash + cargo run --release -p skillet -- secret deliver clamps tailscale \ + --target giacomo@clamps --identity /path/to/ssh-key \ + --known-hosts /path/to/known_hosts + ``` + + This mints a production-tagged key; full apply then configures both + services. Preserve the encrypted KeePassXC entries so another workstation + can reprovision credentials. + +Cloudflare credentials and DNS records are removed before the smoke Tailscale +device and VM are destroyed. Keep the Cloudflare token creator on the +workstation; Tailscale enrollment needs no Cloudflare or Tailscale DNS +permissions. Staging ACME and HTTPS acceptance passed on 2026-10-02; production +renewal and access checks from outside the tailnet remain deferred. See the +[VM lifecycle](../design/smoke-vms.md). + +### Optional Cloudflare DDNS + +The selected host profile must declare DDNS. Clamps currently enables it. +In KeePassXC's Password fields, set: + +- `skillet/environments/prod/dns/cloudflare-zone-id`: the existing environment + zone ID. +- `skillet/environments/prod/hosts//cloudflare/ddns-dns-name`: one + approved relative record name, without the zone suffix. +- `skillet/cloudflare/token-creator`: the account token described above. + +The clear JSON policy is in +`crates/workstation/src/configuration-templates.json`. Its shared DDNS template +reads the record name through a typed `$secret` reference. A host-specific +template in that catalog replaces the complete shared template when needed. +Change template policy in source and rebuild Skillet; do not store the JSON +configuration in KeePassXC. + +Skillet creates `skillet/environments/prod/hosts//cloudflare/ddns-token` +automatically. Close KeePassXC before delivery. With the prior DNS writer stopped +and the selected record set approved, run: + +```bash +cargo run --release -p skillet -- secret deliver clamps ddns \ + --target giacomo@clamps --identity /path/to/ssh-key \ + --known-hosts /path/to/known_hosts +``` + +To adopt an existing public A record, set `takeover_existing` to `true` in the +source template after reviewing it. Conflicting records and other Skillet owners +remain protected. Repeat delivery reuses the child token; edit its vault entry +deliberately and redeliver for rotation. On the host, +`systemctl start skillet-ddns-apply.service` reuses the encrypted credential. +Use `test vm provision --with-ddns` for disposable DDNS. Its +record/token ownership is cleaned up by `test vm destroy`; do not use direct +`secret deliver` with test policy. See the +[DDNS design](../design/cloudflare-ddns.md) and +[remaining lifecycle plan](../plan/CLAMPS-CLOUDFLARE-DDNS.md). + +## Development checks + +- Workspace checks: `cargo fmt --all -- --check`, + `cargo clippy --workspace --all-targets -- -D warnings`, and + `cargo test --workspace --all-targets`. +- Container integration: `cargo run --bin skillet -- test run joey + --phase base --image fedora:latest` (also run for `clamps` after changing + shared container behavior). +- Bootstrap state regressions: `bash ublue/butane/tests/bootstrap.sh` from the + repository root. +- ShellCheck covers `*.sh` plus the extensionless scripts named in the CI + workflow, including VM and workstation helpers. +- `apply --record PATH` writes version 2 YAML with host, overall result, and + per-operation outcomes. It stores no content, hashes, secret values, or raw + effect errors, and is written after failed applies as well. Older unversioned + operation arrays remain diagnostic artifacts and are never loaded as state. + +- **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**: Run `cargo clippy --workspace --all-targets -- -D warnings`; the workspace enables pedantic lints with documented exceptions. + +## Native SMTP relay + +The shared fleet declaration includes native Postfix. The common image supplies +`postfix` and `cyrus-sasl-plain`, initially disabled. Delivery starts the separate +SMTP apply phase; credential-free base and normal application apply still work +without relay credentials. See [the design](../design/email-delivery.md). + +Create `skillet/smtp` as described above, including a verified `sender`. For a +production host, use the existing verified SSH target/identity/known-hosts options: + +```bash +cargo run --release -p skillet -- secrets deliver clamps smtp --target USER@ADDRESS --identity KEY --known-hosts KNOWN_HOSTS --environment production +``` + +This transmits a validated payload through SSH stdin, stores `smtp_config` +encrypted on the guest, and starts `skillet-smtp-apply.service`. The relay accepts +only loopback SMTP on port 25 and host `sendmail` submissions. UniFi's host-network +container can use `127.0.0.1:25` without SMTP authentication or TLS on that local +hop; Postfix authenticates to Mailjet over required, verified STARTTLS. Configure +UniFi's SMTP sender/test-email settings in its UI. Bridge application access is +not enabled yet. + +Postfix's provider map is root-owned, readable only by root and the Postfix +group (`0640`, parent `0750`) under `/run/postfix/skillet/`, and regenerated +from the encrypted credential before service startup. On SELinux hosts, preparation +applies the Postfix configuration label to the RAM map and directory. Production +TLS uses `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`. Queued mail persists in +`/var/spool/postfix`; Skillet initializes the queue directories and +`/var/lib/postfix` before activation. Local senders are rewritten to the verified sender; recipient +addresses are preserved. Configure Mailjet sender/domain verification and +SPF/DKIM/DMARC in the provider UI; validate headers on a received message. + +For an owned disposable VM, configure only the isolated relay without unlocking +KeePass or joining Tailscale: + +```bash +cargo run --release -p skillet -- test vm provision clamps smtp --smtp-only +``` + +The VM must already exist and be ready with the rebuilt image/current Skillet. +Ordinary application provisioning also configures this capture policy. Test mail +queues until a local sink is listening on `127.0.0.1:1025`; no provider credentials +are read or delivered. Switching an existing relay between production and capture +is refused to protect queued mail. Use a new disposable instance for another policy. + +For an explicitly authorized real-provider check, create a **fresh** instance +and opt in to vault credential delivery: + +```bash +cargo run --release -p skillet -- test vm create clamps mailjet --port 2202 +cargo run --release -p skillet -- test vm provision clamps mailjet --smtp-only --live-smtp +``` + +This configures the real relay but sends no message by itself. Submit the intended +message explicitly through the owned VM SSH command. Destroy the instance after +checking delivery; normal test provisioning continues to use capture mode. + +`crates/smtp/tests/native_acceptance.py BINARY HOST [--production-policy]` is a +native-consumer fixture for an isolated **Podman systemd container with +`--network none`** and Postfix/Python installed. It uses synthetic credentials, +checks repeat apply, queue restart/retry and map regeneration; production-policy +mode also checks that an upstream without STARTTLS receives no credentials/mail. +It refuses to run outside that test container. Compile the CLI via Cargo and use +its reported artifact; the fixture is separate from VM lifecycle management. + + +## Finalize production administrative access + +Finish service configuration and credential delivery first. On the host, run: + +```sh +sudo /var/usrlocal/bin/skillet access finalize --user giacomo +``` + +Keep the current administrative session open. Start a second interactive login +(with an SSH TTY or the console), use the known bootstrap password to change it, +and verify sudo asks for the new password. SSH authentication stays key-only. +The command requires an existing unlocked password; it does not create one. + +Finalization is explicit and persistent, with interrupted-operation recovery. +Repeating it never expires the changed password again. Base apply does not undo +it. Smoke VMs are not finalized automatically. Subsequent workstation credential +and VM automation assumes passwordless sudo: finish those operations before +finalization, then use interactive sudo or root for further administration. +Do not delete `/var/lib/skillet/access/` to rerun provisioning. + +Older install media containing `force-pw-change.service` must be regenerated. +For an already installed host using that service, stop/disable it and remove its +unit before adopting this explicit workflow; no live machine is migrated here. +See [the access lifecycle decision](../design/administrative-access.md). + +## Optional TPM-encrypted test root + +From `ublue/skillet`: + +```sh +cargo run --release -p skillet -- test vm create clamps encrypted --root-profile tpm --port 2202 +cargo run --release -p skillet -- test vm encryption-check clamps encrypted +cargo run --release -p skillet -- test vm destroy clamps encrypted +``` + +The equivalent wrapper is `ublue/butane/bin/test-vm clamps create encrypted +--root-profile tpm --port 2202`, followed by `encryption-check encrypted` and +`destroy encrypted`. Choose a free SSH port when retaining another VM. +`--root-profile unencrypted` is the default and supports hosts without TPM. +Profiles cannot be switched on an existing VM; use a separate instance. + +The TPM profile needs native libvirt 10.10+, swtpm 0.7+, KVM, and q35 UEFI +Secure Boot firmware with enrolled keys. Creation checks capabilities and fails +explicitly if unsupported. No packages are installed. Early boot diagnostics +are saved privately in the run's `serial.log` before SSH is available. + +`encryption-check` saves a disposable recovery key and LUKS header backup in +`butane/runs//recovery/` (directory 0700, files 0600), verifies +the independent key, cold-boots the owned VM and checks data persistence. +Reruns reuse the recovery key. Disposal removes these artifacts. No vault or +provider credentials are required. Keep production recovery material separately; +physical installation is still pending. + +See the [storage design](../design/storage.md) and +[encrypted-root plan](../plan/TPM-ENCRYPTED-ROOT.md) for policy and remaining +acceptance. PCR 7 does not measure every boot component; this profile has the +accepted encrypted-disk protection scope. + +## Root recovery keys + +Before finalizing production access (while `sudo -n` still works), enroll and +save an independent printable LUKS recovery passphrase: + +```bash +cargo run --release -p skillet -- secrets root-recovery-key clamps \ + --target giacomo@HOST --identity /absolute/path/to/key \ + --known-hosts /absolute/path/to/known_hosts +``` + +This opens the normal vault unlock prompt unless its password is cached. +The vault file and its parent directory must be writable: atomic saves create a +lock, encrypted replacement and backup in that directory. A group-writable file +in a read-only directory is insufficient. For synced stores, preserve service +ownership and grant the operator a narrow directory ACL when needed. + +Optional `--database` and `--key-file` select another vault. SSH host keys must +already be recorded. The Password at +`skillet/hosts/clamps/storage/root-recovery-key` is the passphrase to enter at +LUKS recovery. Skillet generates it, saves it before enrollment, and verifies +it against the running root volume. The verified LUKS header is stored as the encrypted `root-luks.header` +attachment on the same entry. Repeat runs verify the existing key and refresh +the header after enrollment changes; +a different volume is rejected. No key is printed or written to workstation +plaintext files. Back up the vault off the host before relying on it. + +For a TPM disposable VM, creation prints this follow-up: + +```bash +cargo run --release -p skillet -- test vm recovery-key clamps encrypted +# Equivalent wrapper: +../butane/bin/test-vm clamps recovery-key encrypted +``` + +Its archived entry is separate: +`skillet/environments/test/hosts/clamps/instances/encrypted/storage/root-recovery-key`. +Disposal retains this vault entry; remove it before reusing the instance name +for a different disk. This command enrolls an additional passphrase; it does +not export the TPM-bound volume key. The separate `encryption-check` uses its +own disposable key and enforces exactly two slots; use a fresh instance for +that acceptance scenario rather than combining both enrollment workflows. +Header backup/export and physical recovery acceptance remain pending. +See [storage decisions](../design/storage.md). + +### Native TPM-loss recovery acceptance + +Use fresh instance names and free ports. Start from a base-only VM with no +provider credentials; keep another smoke VM on its own port. From `skillet/`: + +```bash +cargo run --release -p skillet -- test vm create clamps header-recovery --root-profile tpm --port 2202 +cargo run --release -p skillet -- test vm recovery-key clamps header-recovery +cargo run --release -p skillet_workstation --example tpm_recovery -- ../butane clamps header-recovery tpm-loss 2203 clone +cargo run --release -p skillet_workstation --example tpm_recovery -- ../butane clamps header-recovery tpm-loss 2203 unlock +cargo run --release -p skillet_workstation --example tpm_recovery -- ../butane clamps header-recovery tpm-loss 2203 rebind +cargo run --release -p skillet_workstation --example tpm_recovery -- ../butane clamps header-recovery tpm-loss 2203 verify +cargo run --release -p skillet -- test vm destroy clamps tpm-loss +cargo run --release -p skillet -- test vm destroy clamps header-recovery +``` + +The driver uses the normal vault cache. `clone` tests header restoration on a +fresh tmpfs regular-file fixture and creates a stopped disk clone with a fresh +TPM. `unlock` proves automatic unlock fails, then sends the vault passphrase +through an unlogged console. `rebind` verifies the new TPM before removing the +old binding, saves a separate replacement header archive and starts a cold boot. +`verify` checks unattended boot, volume identity, data sentinel, keyslots and +the original source TPM unlock path. +No passwords, headers or console transcripts are printed. Source and clone +archives survive VM disposal; remove an old instance's entry before reusing +its name for a new disk. Interrupted tests retain owned artifacts. To return +the restarted source to Ready, use `test vm ready HOST SOURCE`. + +For physical recovery, retrieve the Password and attachment from the backed-up +vault on an independent machine. Enter the Password at the LUKS boot prompt. +Header restoration is a separate destructive rescue operation: verify the +volume/disk identity before restoring it, and never restore a header onto a +running root volume. Physical TPM replacement acceptance remains pending. + +## Physical installation media + +`skillet install prepare HOST --disk /dev/disk/by-id/WHOLE_DISK_ID --iso LIVE_ISO +--output NEW_PRIVATE_DIRECTORY --ssh-public-key PUBLIC_KEY --password-hash-file +PRIVATE_HASH_FILE [--root-profile unencrypted|tpm] [--mok-certificate UBLUE_DER]` +builds a private USB-ready ISO without writing a block device. Follow +[the hardware runbook](../butane/HARDWARE.md) for inputs, USB writing, live checks, +MOK enrollment and cutover. Installation requires confirmation on the server; +TPM prerequisites are checked there. The VM `/dev/vda` path remains supported. + +### Local UPS protection + +Clamps opts into the shared NUT module for the CyberPower CP1500 AVR USB UPS +(`0764:0501`). Rebase onto the rebuilt image containing `nut` and `nut-client`, +then run the usual full Skillet apply. Configuration is in `/etc/ups`, with +`MODE=standalone`; the server listens only on `127.0.0.1:3493` and `[::1]:3493`. + +The local monitor credential is generated automatically and retained under +`/var/lib/skillet/nut`; do not create a KeePass entry. Its configuration files +are root:nut readable. The packaged driver/server/monitor start when the +matching USB device is attached. Ordinary VMs without that device only converge +configuration. Other host callers can select `ServiceConfig::Nut` with their +own device name, USB IDs and description. + +Read status locally with `upsc cyberpower@localhost`. Inspect +`nut-driver@cyberpower.service`, `nut-server.service` and `nut-monitor.service` +with `systemctl status` or `journalctl -u`. After attaching hardware, rerun +Datadog apply to include its native units in monitoring. + +For a host declaring both NUT and Datadog, successful Datadog credential delivery +also enables `skillet-nut-metrics.timer`. It polls every 30 seconds through the +existing static guest binary. The collector is compiled into Skillet; the shared +Butane include installs that binary at `/var/usrlocal/bin/skillet`. It is not +baked into the uCore image. VM binary delivery updates the same path. Inspect +the timer and collector with +`systemctl status skillet-nut-metrics.timer` and +`journalctl -u skillet-nut-metrics.service`. +After deploying an updated guest binary to a host with existing credentials, +run `sudo systemctl restart skillet-datadog-apply.service` to converge the +Agent socket and timer. + +Read a sample without sending telemetry: + +```bash +skillet metrics nut --ups cyberpower --dry-run +``` + +To query a forwarded server, add `--server 127.0.0.1:3493` (the default). +For an explicit submission, run with sudo and omit `--dry-run`. +`--dogstatsd-socket PATH` overrides the default +`/run/skillet-dogstatsd/dsd.socket`. The Agent uses a root-only socket directory; +UDP collection is disabled. No new KeePass entry is required. + +Metrics use the `nut.` prefix: battery charge/runtime/voltage, available +input/output voltage/frequency, load/power/temperature and individual UPS status +flags. Missing optional readings are omitted. `nut.communication` and `nut.power` +are service checks; query failure exits nonzero and submits only communication +failure. Data retain the Agent's host/environment/location tags. + +Routine CI exercises protocol parsing, actual TCP/Unix-socket transport, the CLI +and timer composition using local fixtures. Run the isolated official-Agent +consumer check from the workspace after fetching the pinned image: + +```bash +cargo test -p skillet --test nut_metrics \ + real_agent_accepts_nut_metrics_over_uds_without_udp -- --ignored +``` + +It uses Podman, a dummy API key and no container network access. See the +[monitoring design](../design/monitoring.md) for the collector decision. + +Before relying on protection, verify battery status and arrange a supervised +power-loss/shutdown/restart test on the physical machine. Routine smoke tests +must not run `upsmon -c fsd` or command the real UPS to turn off. +See [the UPS design](../design/ups.md). diff --git a/ublue/skillet/crates/btrbk/Cargo.toml b/ublue/skillet/crates/btrbk/Cargo.toml new file mode 100644 index 00000000..db517d34 --- /dev/null +++ b/ublue/skillet/crates/btrbk/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "skillet_btrbk" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +thiserror.workspace = true +sha2.workspace = true +hex.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/btrbk/src/lib.rs b/ublue/skillet/crates/btrbk/src/lib.rs new file mode 100644 index 00000000..6428040d --- /dev/null +++ b/ublue/skillet/crates/btrbk/src/lib.rs @@ -0,0 +1,170 @@ +use sha2::{Digest, Sha256}; +use skillet_core::{ + activation::{self, ActivationRequest, ConsumerKind}, + files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}, + system::{ServiceResource, SystemError}, +}; +use std::path::{Component, Path, PathBuf}; +use thiserror::Error; + +const DATA_MOUNT: &str = "/var/lib/data"; +const CONFIG_PATH: &str = "/etc/btrbk/btrbk.conf"; +const SERVICE_PATH: &str = "/etc/systemd/system/skillet-btrbk.service"; +const TIMER_PATH: &str = "/etc/systemd/system/skillet-btrbk.timer"; + +#[derive(Debug, Error)] +pub enum BtrbkError { + #[error("File error: {0}")] + File(#[from] FileError), + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("Activation error: {0}")] + Activation(#[from] skillet_core::activation::ActivationError), + #[error("Invalid Btrfs subvolume path '{0}': use safe relative path components beneath /var/lib/data")] + InvalidSubvolume(String), +} + +/// Caller-selected paths relative to `/var/lib/data`. The parent data +/// subvolume is deliberately not a default source. +pub struct BtrbkConfig { + pub snapshot_subvolumes: Vec, +} + +pub fn apply(system: &S, files: &F, config: &BtrbkConfig) -> Result<(), BtrbkError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, +{ + if config.snapshot_subvolumes.is_empty() { + return Ok(()); + } + + files.require_btrfs_subvolume_mount(Path::new(DATA_MOUNT), Path::new("/var"), "/data")?; + + let mut sources = Vec::with_capacity(config.snapshot_subvolumes.len()); + for relative in &config.snapshot_subvolumes { + validate_relative_subvolume(relative)?; + let source = Path::new(DATA_MOUNT).join(relative); + files.require_btrfs_subvolume(&source)?; + let snapshot_dir = Path::new(DATA_MOUNT).join("snapshots").join(relative); + files.ensure_directory(&snapshot_dir, Some(0o755), &Ownership::default())?; + sources.push(( + relative, + snapshot_dir + .strip_prefix(DATA_MOUNT) + .map_err(|_| BtrbkError::InvalidSubvolume(relative.display().to_string()))? + .to_path_buf(), + )); + } + + files.ensure_directory( + Path::new("/etc/btrbk"), + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + let config_changed = files.ensure_file( + Path::new(CONFIG_PATH), + render_config(&sources).as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + let service_changed = files.ensure_file( + Path::new(SERVICE_PATH), + service_unit().as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + let timer_changed = files.ensure_file( + Path::new(TIMER_PATH), + timer_unit().as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + + let definition_changed = config_changed || service_changed || timer_changed; + let mut hasher = Sha256::new(); + hasher.update(render_config(&sources).as_bytes()); + hasher.update(service_unit().as_bytes()); + hasher.update(timer_unit().as_bytes()); + let revision = hex::encode(hasher.finalize()); + system.service_enable("skillet-btrbk.timer")?; + let state_dir = Path::new("/var/lib/skillet/btrbk"); + files.ensure_directory( + state_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + activation::activate( + system, + files, + &ActivationRequest { + service: "skillet-btrbk.timer".to_string(), + state_path: state_dir.join("timer.applied"), + revision: revision.into_bytes(), + definition_changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + Ok(()) +} + +fn validate_relative_subvolume(path: &Path) -> Result<(), BtrbkError> { + if path.as_os_str().is_empty() + || path.components().any(|component| match component { + Component::Normal(value) => value.to_str().is_none_or(|part| { + part.is_empty() + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + }), + _ => true, + }) + { + return Err(BtrbkError::InvalidSubvolume(path.display().to_string())); + } + Ok(()) +} + +fn render_config(sources: &[(&PathBuf, PathBuf)]) -> String { + let mut output = String::from( + "timestamp_format long\nsnapshot_preserve_min 6h\nsnapshot_preserve 24h 31d 6m\n\ + volume /var/lib/data\n", + ); + for (source, snapshot_dir) in sources { + output.push_str(" subvolume "); + output.push_str(&source.display().to_string()); + output.push_str("\n snapshot_dir "); + output.push_str(&snapshot_dir.display().to_string()); + output.push('\n'); + } + output +} + +fn service_unit() -> &'static str { + "[Unit]\n\ + Description=Create configured Btrfs snapshots with btrbk\n\ + Requires=var-lib-data.mount\n\ + After=var-lib-data.mount\n\ + BindsTo=var-lib-data.mount\n\ + AssertPathIsMountPoint=/var/lib/data\n\ + [Service]\n\ + Type=oneshot\n\ + ExecStartPre=/var/usrlocal/bin/skillet-data-prepare\n\ + ExecStart=/usr/bin/btrbk --config /etc/btrbk/btrbk.conf run\n" +} + +fn timer_unit() -> &'static str { + "[Unit]\n\ + Description=Hourly configured Btrfs snapshots\n\ + [Timer]\n\ + OnCalendar=hourly\n\ + Persistent=true\n\ + Unit=skillet-btrbk.service\n\ + [Install]\n\ + WantedBy=timers.target\n" +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/btrbk/src/tests.rs b/ublue/skillet/crates/btrbk/src/tests.rs new file mode 100644 index 00000000..afd889a6 --- /dev/null +++ b/ublue/skillet/crates/btrbk/src/tests.rs @@ -0,0 +1,195 @@ +use super::{apply, BtrbkConfig, BtrbkError, CONFIG_PATH, SERVICE_PATH, TIMER_PATH}; +use skillet_core::files::FileReadResource; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::path::PathBuf; + +#[test] +fn renders_only_caller_selected_subvolume() { + let files = MockFiles::new(); + setup_data_storage(&files, true); + let system = MockSystem::new(); + apply( + &system, + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ) + .unwrap(); + let config = read(&files, CONFIG_PATH); + assert!(config.contains("subvolume syncthing")); + assert!(config.contains("snapshot_dir snapshots/syncthing")); + assert!(!config.contains("subvolume containers")); + assert!(!config.contains("subvolume data")); + assert!(read(&files, SERVICE_PATH).contains("skillet-data-prepare")); + assert!(read(&files, TIMER_PATH).contains("OnCalendar=hourly")); +} + +#[test] +fn rejects_parent_absolute_and_traversal_paths() { + for invalid in [ + "/var/lib/data", + "..", + "nested/../escape", + ".", + "with space", + "bad\nvalue", + ] { + let files = MockFiles::new(); + setup_data_storage(&files, false); + let error = apply( + &MockSystem::new(), + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from(invalid)], + }, + ) + .expect_err("unsafe source should be rejected"); + assert!(matches!(error, BtrbkError::InvalidSubvolume(_))); + } +} + +#[test] +fn empty_config_is_an_opt_out() { + let files = MockFiles::new(); + let system = MockSystem::new(); + apply( + &system, + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![], + }, + ) + .unwrap(); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +#[test] +fn wrong_data_mount_fails_before_any_state_is_written() { + let files = MockFiles::new(); + setup_data_storage(&files, true); + files + .fail_btrfs_mount_check + .store(true, std::sync::atomic::Ordering::SeqCst); + let result = apply( + &MockSystem::new(), + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ); + assert!(result.is_err()); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +fn read(files: &MockFiles, path: &str) -> String { + String::from_utf8( + files + .read_file(std::path::Path::new(path)) + .unwrap() + .unwrap(), + ) + .unwrap() +} + +#[test] +fn repeated_apply_keeps_managed_state_and_timer_running() { + let files = MockFiles::new(); + setup_data_storage(&files, true); + let system = MockSystem::new(); + let config = BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }; + apply(&system, &files, &config).unwrap(); + apply(&system, &files, &config).unwrap(); + assert_eq!( + system + .restart_count + .load(std::sync::atomic::Ordering::SeqCst), + 1 + ); +} + +#[test] +fn ordinary_directory_cannot_be_used_as_snapshot_subvolume() { + let files = MockFiles::new(); + setup_data_storage(&files, false); + files + .directories + .lock() + .unwrap() + .insert("/var/lib/data/syncthing".into()); + let result = apply( + &MockSystem::new(), + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ); + assert!(matches!(result, Err(BtrbkError::File(_)))); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +#[test] +fn missing_data_mount_fails_before_state_is_written() { + let files = MockFiles::new(); + let result = apply( + &MockSystem::new(), + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ); + assert!(result.is_err()); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +#[test] +fn data_mount_on_a_different_device_from_var_is_rejected() { + let files = MockFiles::new(); + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/root", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/data", + "/data", + "btrfs", + ); + files.record_btrfs_subvolume(std::path::Path::new("/var/lib/data/syncthing")); + let result = apply( + &MockSystem::new(), + &files, + &BtrbkConfig { + snapshot_subvolumes: vec![PathBuf::from("syncthing")], + }, + ); + assert!(result.is_err()); + assert!(files + .read_file(std::path::Path::new(CONFIG_PATH)) + .unwrap() + .is_none()); +} + +fn setup_data_storage(files: &MockFiles, include_source: bool) { + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/test", + "/data", + "btrfs", + ); + if include_source { + files.record_btrfs_subvolume(std::path::Path::new("/var/lib/data/syncthing")); + } +} diff --git a/ublue/skillet/crates/caddy/Cargo.toml b/ublue/skillet/crates/caddy/Cargo.toml new file mode 100644 index 00000000..baf91e4c --- /dev/null +++ b/ublue/skillet/crates/caddy/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "skillet_caddy" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_datadog.workspace = true +skillet_core.workspace = true +skillet_podman.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +reqwest.workspace = true +sha2.workspace = true +tiny_http = "0.12" + +[dev-dependencies] +tempfile.workspace = true +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/caddy/src/auth.rs b/ublue/skillet/crates/caddy/src/auth.rs new file mode 100644 index 00000000..3f6e2af8 --- /dev/null +++ b/ublue/skillet/crates/caddy/src/auth.rs @@ -0,0 +1,264 @@ +//! Local, per-request tailnet identity authorization for Caddy `forward_auth`. +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use skillet_core::{ + activation::{activate, ActivationRequest, ConsumerKind}, + files::{FileMutationResource, FileReadResource, Ownership}, + system::ServiceResource, +}; +use std::{ + collections::BTreeSet, + io::Read as _, + net::{IpAddr, SocketAddr}, + path::Path, + time::Duration, +}; +use thiserror::Error; + +pub const USERS_ENTRY: &str = "skillet/tailscale/ui-users"; +pub const DAEMON_SOCKET: &str = "/run/skillet-tailscale/tailscaled.sock"; +pub const AUTH_SOCKET: &str = "/run/skillet-tailnet-ui/auth.sock"; +pub const POLICY_FILE: &str = "/etc/skillet/tailnet-ui.json"; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("invalid tailnet UI policy; use distinct complete LoginName values, separated by commas or newlines")] + InvalidPolicy, + #[error("tailnet identity lookup failed")] + Lookup, + #[error("tailnet UI runtime I/O: {0}")] + Io(#[from] std::io::Error), + #[error("could not create tailnet UI authorization socket")] + Listener, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Policy { + users: BTreeSet, +} + +impl Policy { + pub fn new(users: Vec) -> Result { + if users.iter().any(|name| !valid_identity(name)) { + return Err(AuthError::InvalidPolicy); + } + let count = users.len(); + let unique: BTreeSet<_> = users.into_iter().collect(); + if unique.len() != count { + return Err(AuthError::InvalidPolicy); + } + Ok(Self { users: unique }) + } + + pub fn parse_users(contents: &str) -> Result, AuthError> { + let users: Vec<_> = contents + .split([',', '\n']) + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + if users.is_empty() { + return Err(AuthError::InvalidPolicy); + } + Self::new(users.clone())?; + Ok(users) + } + + pub fn authorize(&self, identity: &Identity) -> bool { + identity.node.tags.is_empty() + && identity.node.user != 0 + && identity.node.user == identity.user_profile.id + && self.users.contains(&identity.user_profile.login_name) + } +} + +fn valid_identity(name: &str) -> bool { + !name.is_empty() + && name.len() <= 254 + && name + .bytes() + .all(|byte| byte.is_ascii_graphic() && byte != b',') +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct Identity { + node: Node, + user_profile: UserProfile, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct Node { + user: u64, + #[serde(default)] + tags: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct UserProfile { + #[serde(rename = "ID")] + id: u64, + login_name: String, +} + +pub struct LocalApi { + client: reqwest::blocking::Client, +} + +impl LocalApi { + pub fn new(socket: &Path) -> Result { + let client = reqwest::blocking::Client::builder() + .no_proxy() + .unix_socket(socket) + .timeout(Duration::from_secs(2)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| AuthError::Lookup)?; + Ok(Self { client }) + } + + pub fn whois(&self, peer: SocketAddr) -> Result { + let response = self + .client + .get("http://local-tailscaled.sock/localapi/v0/whois") + .query(&[("addr", peer.to_string())]) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .map_err(|_| AuthError::Lookup)?; + let mut contents = Vec::new(); + response + .take(65_537) + .read_to_end(&mut contents) + .map_err(|_| AuthError::Lookup)?; + if contents.len() > 65_536 { + return Err(AuthError::Lookup); + } + serde_json::from_slice(&contents).map_err(|_| AuthError::Lookup) + } +} + +fn tailnet_peer(peer: SocketAddr) -> bool { + match peer.ip() { + IpAddr::V4(ip) => ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1]), + IpAddr::V6(ip) => ip.segments()[..3] == [0xfd7a, 0x115c, 0xa1e0], + } +} + +/// This endpoint trusts only Caddy's private Unix socket connection. Caddy must +/// overwrite the peer header with the original TCP peer, never forwarded input. +pub fn authorize_peer(policy: &Policy, peer: &str, api: &LocalApi) -> Option { + let peer: SocketAddr = peer.parse().ok()?; + if !tailnet_peer(peer) { + return None; + } + let identity = api.whois(peer).ok()?; + policy + .authorize(&identity) + .then(|| identity.user_profile.login_name.clone()) +} + +/// Guest runtime adapter. Policy is reread for every request; failures deny access. +pub fn serve(socket: &Path, daemon: &Path, policy_file: &Path) -> Result<(), AuthError> { + use std::os::unix::{ + fs::{FileTypeExt as _, PermissionsExt as _}, + net::{UnixListener, UnixStream}, + }; + if let Ok(metadata) = std::fs::symlink_metadata(socket) { + if !metadata.file_type().is_socket() || UnixStream::connect(socket).is_ok() { + return Err(AuthError::Listener); + } + std::fs::remove_file(socket)?; + } + let listener = UnixListener::bind(socket)?; + std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?; + let server = + tiny_http::Server::from_listener(listener, None).map_err(|_| AuthError::Listener)?; + let api = LocalApi::new(daemon)?; + for request in server.incoming_requests() { + respond(request, &api, policy_file); + } + Ok(()) +} + +fn respond(request: tiny_http::Request, api: &LocalApi, policy_file: &Path) { + let mut peers = request + .headers() + .iter() + .filter(|header| header.field.equiv("X-Skillet-Peer")); + let peer = peers.next().map(|header| header.value.as_str()); + let user = if request.method() == &tiny_http::Method::Get + && request.url() == "/auth" + && peers.next().is_none() + { + std::fs::read(policy_file) + .ok() + .filter(|bytes| bytes.len() <= 65_536) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|policy| { + // Deserialization must not bypass validation. + let policy = Policy::new(policy.users.into_iter().collect()).ok()?; + authorize_peer(&policy, peer?, api) + }) + } else { + None + }; + let mut response = tiny_http::Response::from_string(if user.is_some() { + "" + } else { + "Access denied by Skillet identity policy" + }) + .with_status_code(if user.is_some() { 204 } else { 403 }); + if let Some(user) = user { + if let Ok(header) = tiny_http::Header::from_bytes("X-Skillet-User", user) { + response.add_header(header); + } else { + response = tiny_http::Response::from_string("Access denied").with_status_code(403); + } + } + // A client disappearing must not take down authorization for other requests. + let _ = request.respond(response); +} + +pub fn apply(system: &S, files: &F, policy: &Policy) -> Result<(), super::CaddyError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let root = Ownership::named(Some("root"), Some("root")); + files.ensure_directory(Path::new("/etc/skillet"), Some(0o755), &root)?; + let payload = serde_json::to_vec(policy)?; + let policy_changed = files.ensure_file(Path::new(POLICY_FILE), &payload, Some(0o600), &root)?; + let unit = "[Unit]\nDescription=Skillet tailnet UI authorization\nAfter=tailscale.service\n\n[Service]\nExecStart=/var/usrlocal/bin/skillet tailnet-ui serve\nRuntimeDirectory=skillet-tailnet-ui\nRuntimeDirectoryMode=0700\nRuntimeDirectoryPreserve=yes\nRestart=on-failure\nUMask=0077\nNoNewPrivileges=yes\nProtectSystem=strict\nProtectHome=yes\nPrivateTmp=yes\nReadWritePaths=/run/skillet-tailnet-ui\nRestrictAddressFamilies=AF_UNIX\n\n[Install]\nWantedBy=multi-user.target\n"; + let changed = files.ensure_file( + Path::new("/etc/systemd/system/skillet-tailnet-ui.service"), + unit.as_bytes(), + Some(0o644), + &root, + )?; + files.ensure_directory(Path::new("/var/lib/skillet/tailnet-ui"), Some(0o700), &root)?; + system.service_enable("skillet-tailnet-ui.service")?; + // The applied revision must not disclose allowed identities in a recording. + let mut revision = Sha256::new(); + revision.update(&payload); + revision.update(unit); + activate( + system, + files, + &ActivationRequest { + service: "skillet-tailnet-ui.service".into(), + state_path: "/var/lib/skillet/tailnet-ui/applied".into(), + revision: revision.finalize().to_vec(), + definition_changed: changed || policy_changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "auth/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/caddy/src/auth/tests.rs b/ublue/skillet/crates/caddy/src/auth/tests.rs new file mode 100644 index 00000000..ada7eedb --- /dev/null +++ b/ublue/skillet/crates/caddy/src/auth/tests.rs @@ -0,0 +1,208 @@ +use super::*; +use std::os::unix::net::UnixListener; + +fn identity(user: &str, tags: &[&str]) -> Identity { + Identity { + node: Node { + user: 1, + tags: tags.iter().map(|tag| (*tag).into()).collect(), + }, + user_profile: UserProfile { + id: 1, + login_name: user.into(), + }, + } +} + +#[test] +fn full_identities_not_prefixes_are_authorized_and_tags_are_denied() { + let policy = Policy::new(vec![ + "alice@example.invalid".into(), + "irene@example.invalid".into(), + ]) + .unwrap(); + assert!(policy.authorize(&identity("alice@example.invalid", &[]))); + assert!(policy.authorize(&identity("irene@example.invalid", &[]))); + for denied in ["alice@other.invalid", "alice", "unknown@example.invalid"] { + assert!(!policy.authorize(&identity(denied, &[]))); + } + assert!(!policy.authorize(&identity("alice@example.invalid", &["tag:server"]))); + let mut mismatch = identity("alice@example.invalid", &[]); + mismatch.node.user = 2; + assert!(!policy.authorize(&mismatch)); + assert!(!Policy::new(Vec::new()) + .unwrap() + .authorize(&identity("alice@example.invalid", &[]))); +} + +#[test] +fn malformed_policy_and_empty_required_entry_are_rejected_without_values() { + for invalid in ["", "\n", "alice smith", "alice\nalice", "bad\0identity"] { + let error = Policy::parse_users(invalid).unwrap_err(); + assert!(!error.to_string().contains(invalid) || invalid.is_empty()); + } + // Headscale's complete LoginName can differ from a hosted email identity. + assert_eq!( + Policy::parse_users("person\nother-person").unwrap(), + ["person", "other-person"] + ); +} + +#[test] +fn real_unix_localapi_checks_exact_peer_and_daemon_failure_denies() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("tailscaled.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = tiny_http::Server::from_listener(listener, None).unwrap(); + let thread = std::thread::spawn(move || { + let request = server + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + assert_eq!( + request.url(), + "/localapi/v0/whois?addr=100.100.100.1%3A1234" + ); + let body = + r#"{"Node":{"User":1},"UserProfile":{"ID":1,"LoginName":"alice@example.invalid"}}"#; + request + .respond(tiny_http::Response::from_string(body)) + .unwrap(); + }); + let api = LocalApi::new(&socket).unwrap(); + let policy = Policy::new(vec!["alice@example.invalid".into()]).unwrap(); + assert!(authorize_peer(&policy, "127.0.0.1:1234", &api).is_none()); + assert!(authorize_peer(&policy, "forged", &api).is_none()); + assert_eq!( + authorize_peer(&policy, "100.100.100.1:1234", &api).as_deref(), + Some("alice@example.invalid") + ); + thread.join().unwrap(); + assert!(authorize_peer(&policy, "100.100.100.1:1234", &api).is_none()); +} + +#[test] +fn ipv4_and_ipv6_ranges_are_exact() { + for addr in [ + "100.64.0.1:10", + "100.127.255.254:10", + "[fd7a:115c:a1e0::1]:10", + ] { + assert!(tailnet_peer(addr.parse().unwrap())); + } + for addr in [ + "100.63.0.1:10", + "100.128.0.1:10", + "[fd7a:115c:a1e1::1]:10", + "[::1]:10", + ] { + assert!(!tailnet_peer(addr.parse().unwrap())); + } +} + +#[test] +fn users_accept_commas_newlines_and_whitespace_but_never_duplicates() { + assert_eq!( + Policy::parse_users( + " alice@example.invalid, irene@example.invalid\nother@example.invalid " + ) + .unwrap(), + [ + "alice@example.invalid", + "irene@example.invalid", + "other@example.invalid" + ] + ); + assert!(Policy::parse_users("alice, alice").is_err()); +} + +#[test] +fn authorization_endpoint_ignores_forged_headers_and_reloads_revocations() { + let temp = tempfile::tempdir().unwrap(); + let daemon = temp.path().join("daemon.sock"); + let auth = temp.path().join("auth.sock"); + let policy = temp.path().join("policy.json"); + std::fs::write( + &policy, + serde_json::to_vec(&Policy::new(vec!["alice@example.invalid".into()]).unwrap()).unwrap(), + ) + .unwrap(); + let api_server = tiny_http::Server::http_unix(&daemon).unwrap(); + let api_thread = std::thread::spawn(move || { + for _ in 0..2 { + let request = api_server + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + request.respond(tiny_http::Response::from_string(r#"{"Node":{"User":1},"UserProfile":{"ID":1,"LoginName":"alice@example.invalid"}}"#)).unwrap(); + } + }); + let server = tiny_http::Server::http_unix(&auth).unwrap(); + let api = LocalApi::new(&daemon).unwrap(); + let policy_path = policy.clone(); + let auth_thread = std::thread::spawn(move || { + for _ in 0..5 { + let request = server + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + super::respond(request, &api, &policy_path); + } + }); + let client = reqwest::blocking::Client::builder() + .no_proxy() + .unix_socket(auth.as_path()) + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let url = "http://auth.local/auth"; + let allowed = client + .get(url) + .header("X-Skillet-Peer", "100.100.100.1:1234") + .header("X-Skillet-User", "attacker") + .send() + .unwrap(); + assert_eq!(allowed.status(), 204); + assert_eq!(allowed.headers()["X-Skillet-User"], "alice@example.invalid"); + assert_eq!( + client + .get(url) + .header("X-Skillet-User", "alice@example.invalid") + .send() + .unwrap() + .status(), + 403 + ); + assert_eq!( + client + .get(url) + .header("X-Skillet-Peer", "127.0.0.1:1234") + .header("X-Forwarded-For", "100.100.100.1") + .send() + .unwrap() + .status(), + 403 + ); + assert_eq!( + client + .get(url) + .header("X-Skillet-Peer", "100.100.100.1:1234") + .header("X-Skillet-Peer", "100.100.100.2:1234") + .send() + .unwrap() + .status(), + 403 + ); + std::fs::write(&policy, br#"{"users":[]}"#).unwrap(); + assert_eq!( + client + .get(url) + .header("X-Skillet-Peer", "100.100.100.1:1234") + .send() + .unwrap() + .status(), + 403 + ); + auth_thread.join().unwrap(); + api_thread.join().unwrap(); +} diff --git a/ublue/skillet/crates/caddy/src/lib.rs b/ublue/skillet/crates/caddy/src/lib.rs new file mode 100644 index 00000000..b1b77f3c --- /dev/null +++ b/ublue/skillet/crates/caddy/src/lib.rs @@ -0,0 +1,455 @@ +//! Caddy reverse proxy for administrative interfaces. + +use serde::{Deserialize, Serialize}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource, Ownership}; +use skillet_core::system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, +}; +use skillet_podman::{ + self, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, + PortPublication, ProcessIdentity, QuadletSecret, SecretTarget, Volume, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; + +pub mod auth; + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct UiService { + pub name: String, + pub upstream: String, + pub port: u16, + #[serde(default)] + pub aliases: Vec, + #[serde(default)] + pub tailnet_identity: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct UiEnvironment { + pub ui_domain: String, + pub acme_staging: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CaddySites { + pub version: u8, + pub host: String, + pub ui_domain: String, + pub acme_staging: bool, + pub machine_hostname: String, + pub services: Vec, + #[serde(default)] + pub tailnet_users: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CaddySite { + pub name: String, + pub hostname: String, + pub aliases: Vec, + pub upstream: String, + pub port: u16, + #[serde(default)] + pub tailnet_identity: bool, +} + +#[derive(Debug, Error)] +pub enum CaddyError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), + #[error("invalid Caddy configuration: {0}")] + InvalidConfiguration(String), + #[error("invalid Caddy sites credential: {0}")] + InvalidSites(#[from] serde_json::Error), + #[error(transparent)] + Auth(#[from] auth::AuthError), + #[error(transparent)] + Activation(#[from] skillet_core::activation::ActivationError), +} + +impl CaddySites { + pub fn from_host( + host: &str, + environment: &UiEnvironment, + services: &[UiService], + ) -> Result { + validate_label(host, "host")?; + validate_domain(&environment.ui_domain)?; + if services.is_empty() { + return Err(CaddyError::InvalidConfiguration( + "host declares no UI services".to_string(), + )); + } + let machine_hostname = format!("{host}.{}", environment.ui_domain); + validate_domain(&machine_hostname)?; + let mut sites = Vec::with_capacity(services.len()); + let mut all_hostnames = BTreeMap::from([(machine_hostname.clone(), "machine")]); + for service in services { + validate_label(&service.name, "service name")?; + validate_label(&service.upstream, "upstream name")?; + if service.port == 0 { + return Err(CaddyError::InvalidConfiguration(format!( + "service {} has an invalid upstream port", + service.name + ))); + } + if sites + .iter() + .any(|site: &CaddySite| site.name == service.name) + { + return Err(CaddyError::InvalidConfiguration(format!( + "duplicate UI service {}", + service.name + ))); + } + let hostname = format!("{}.{}.{}", service.name, host, environment.ui_domain); + validate_domain(&hostname)?; + if all_hostnames + .insert(hostname.clone(), "canonical UI") + .is_some() + { + return Err(CaddyError::InvalidConfiguration( + "duplicate UI hostname".to_string(), + )); + } + let mut aliases = Vec::with_capacity(service.aliases.len()); + for alias in &service.aliases { + let expanded = expand_alias(alias, host)?; + let alias_hostname = format!("{expanded}.{}", environment.ui_domain); + validate_domain(&alias_hostname)?; + if all_hostnames + .insert(alias_hostname.clone(), "UI alias") + .is_some() + { + return Err(CaddyError::InvalidConfiguration(format!( + "duplicate UI hostname for alias on service {}", + service.name + ))); + } + aliases.push(alias_hostname); + } + sites.push(CaddySite { + name: service.name.clone(), + hostname, + aliases, + upstream: service.upstream.clone(), + port: service.port, + tailnet_identity: service.tailnet_identity, + }); + } + Ok(Self { + version: 3, + host: host.to_string(), + ui_domain: environment.ui_domain.clone(), + acme_staging: environment.acme_staging, + machine_hostname, + services: sites, + tailnet_users: Vec::new(), + }) + } + + pub fn parse(contents: &str, host: &str, services: &[UiService]) -> Result { + let sites: Self = serde_json::from_str(contents)?; + if !matches!(sites.version, 2 | 3) { + return Err(CaddyError::InvalidConfiguration(format!( + "unsupported payload version {}", + sites.version + ))); + } + let mut expected = Self::from_host( + host, + &UiEnvironment { + ui_domain: sites.ui_domain.clone(), + acme_staging: sites.acme_staging, + }, + services, + )?; + auth::Policy::new(sites.tailnet_users.clone())?; + if sites.version == 2 && sites.services.iter().any(|site| site.tailnet_identity) { + return Err(CaddyError::InvalidConfiguration( + "identity protection requires payload version 3; redeliver Caddy credentials" + .into(), + )); + } + expected.version = sites.version; + expected.tailnet_users.clone_from(&sites.tailnet_users); + if sites != expected { + return Err(CaddyError::InvalidConfiguration( + "payload does not match this host's declared UI services".to_string(), + )); + } + Ok(expected) + } + + pub fn needs_identity_gate(&self) -> bool { + self.services.iter().any(|site| site.tailnet_identity) + } + + pub fn render(&self) -> String { + let acme = if self.acme_staging { + " acme_ca https://acme-staging-v02.api.letsencrypt.org/directory\n" + } else { + "" + }; + let mut config = + format!("{{\n admin off\n{acme} acme_dns cloudflare {{env.CF_API_TOKEN}}\n}}\n"); + for service in &self.services { + use std::fmt::Write as _; + for hostname in std::iter::once(&service.hostname).chain(&service.aliases) { + let _ = writeln!(config, "\n{hostname} {{\n tls {{\n resolvers 1.1.1.1 1.0.0.1\n }}\n route {{\n @outside_tailnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48\n respond @outside_tailnet \"Access denied by Skillet tailnet policy\" 403"); + if service.tailnet_identity { + let _ = writeln!(config, " @cross_origin expression `{{http.request.header.Origin}} != \"\" && {{http.request.header.Origin}} != \"https://{hostname}\"`\n respond @cross_origin 403\n @cross_site header Sec-Fetch-Site cross-site\n respond @cross_site 403"); + config.push_str(" request_header -X-Skillet-Peer\n request_header -X-Skillet-User\n request_header -X-Webauth-User\n request_header -Remote-User\n request_header -Tailscale-User\n forward_auth unix//run/skillet-tailnet-ui/auth.sock {\n uri /auth\n header_up X-Skillet-Peer {http.request.remote}\n copy_headers X-Skillet-User\n }\n"); + } + let _ = writeln!( + config, + " reverse_proxy {}:{}\n }}\n}}", + service.upstream, service.port + ); + } + } + config + } +} + +fn expand_alias(alias: &str, host: &str) -> Result { + if alias.is_empty() || alias.len() > 253 || alias.starts_with('.') || alias.ends_with('.') { + return Err(CaddyError::InvalidConfiguration( + "invalid relative UI alias".to_string(), + )); + } + let mut labels = Vec::new(); + for label in alias.split('.') { + if label == "{host}" { + labels.push(host); + } else { + if label.contains('{') || label.contains('}') { + return Err(CaddyError::InvalidConfiguration( + "the {host} alias placeholder must be a complete DNS label".to_string(), + )); + } + validate_label(label, "UI alias")?; + labels.push(label); + } + } + Ok(labels.join(".")) +} + +fn validate_label(label: &str, field: &str) -> Result<(), CaddyError> { + let valid = !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + if valid { + Ok(()) + } else { + Err(CaddyError::InvalidConfiguration(format!( + "invalid {field} DNS label" + ))) + } +} + +pub fn validate_domain(domain: &str) -> Result<(), CaddyError> { + if domain.len() > 253 || domain.split('.').count() < 2 { + return Err(CaddyError::InvalidConfiguration( + "UI domain must be a fully qualified DNS domain".to_string(), + )); + } + for label in domain.split('.') { + validate_label(label, "UI domain")?; + } + Ok(()) +} + +/// Resolve an optional relative UI namespace beneath the authoritative zone. +pub fn resolve_ui_domain(zone: &str, prefix: Option<&str>) -> Result { + validate_domain(zone)?; + let prefix = prefix.unwrap_or("ui").trim(); + for label in prefix.split('.') { + validate_label(label, "relative UI domain prefix")?; + } + let domain = format!("{prefix}.{zone}"); + validate_domain(&domain)?; + Ok(domain) +} + +pub fn validate_domain_in_zone(domain: &str, zone: &str) -> Result<(), CaddyError> { + validate_domain(domain)?; + validate_domain(zone)?; + if domain != zone && !domain.ends_with(&format!(".{zone}")) { + return Err(CaddyError::InvalidConfiguration( + "UI domain is outside the selected Cloudflare zone".to_string(), + )); + } + Ok(()) +} + +pub fn apply( + system: &S, + files: &F, + sites: &CaddySites, + network_name: &str, +) -> Result<(), CaddyError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let policy = auth::Policy::new(sites.tailnet_users.clone())?; + let caddyfile = sites.render(); + let mut networks = vec![NetworkAttachment::Bridge(network_name.to_string())]; + let mut auth_volumes = Vec::new(); + if sites.needs_identity_gate() { + auth::apply(system, files, &policy)?; + auth_volumes.push(Volume { + host_path: "/run/skillet-tailnet-ui".into(), + container_path: "/run/skillet-tailnet-ui".into(), + options: Some("ro,z".into()), + host_mode: None, + host_ownership: None, + }); + for site in sites.services.iter().filter(|site| site.tailnet_identity) { + let network = private_network(&sites.host, &site.name); + skillet_podman::ensure_network(system, files, &network)?; + networks.push(NetworkAttachment::Bridge(network.unit_name)); + } + } + let config_dir = Path::new("/etc/skillet/caddy"); + files.ensure_directory( + config_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + files.ensure_file( + &config_dir.join("Caddyfile"), + caddyfile.as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + + let mut container = BTreeMap::new(); + container.insert( + "Container".to_string(), + vec![ + "AutoUpdate=registry".to_string(), + "ContainerName=caddy".to_string(), + skillet_datadog::process("caddy", "caddy run"), + ], + ); + container.insert("Service".to_string(), vec!["Restart=always".to_string()]); + container.insert( + "Unit".to_string(), + vec![ + "Description=Caddy private UI reverse proxy".to_string(), + "After=network-online.target".to_string(), + ], + ); + + if sites.needs_identity_gate() { + container.entry("Unit".into()).or_default().extend([ + "Requires=skillet-tailnet-ui.service".into(), + "After=skillet-tailnet-ui.service".into(), + ]); + container.entry("Container".into()).or_default().extend([ + "Sysctl=net.ipv4.ip_forward=0".into(), + "Sysctl=net.ipv6.conf.all.forwarding=0".into(), + ]); + } + let mut config = PodmanConfig { + name: "caddy".to_string(), + image: "ghcr.io/caddybuilds/caddy-cloudflare:2".to_string(), + network_attachments: networks, + port_publications: ["::", "0.0.0.0"] + .into_iter() + .map(|address| PortPublication { + host_address: match address { + "::" => std::net::Ipv6Addr::UNSPECIFIED.into(), + _ => std::net::Ipv4Addr::UNSPECIFIED.into(), + }, + host_port: 443, + container_port: 443, + protocol: PortProtocol::Tcp, + }) + .collect(), + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, + volumes: state_volumes(), + secrets: vec![QuadletSecret { + secret_name: "cloudflare_acme_token".to_string(), + target: SecretTarget::Environment { + env_var_name: "CF_API_TOKEN".to_string(), + }, + }], + config_revisions: vec![caddyfile.into_bytes()], + extra_config: container, + }; + config.volumes.extend(auth_volumes); + // Preserve the caller's network configuration and let Podman create its + // persistent directories on the shared data filesystem. + for path in ["/var/lib/data/caddy/data", "/var/lib/data/caddy/config"] { + files.ensure_directory( + Path::new(path), + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + } + skillet_podman::container(system, files, config)?; + Ok(()) +} + +/// A private backend whose strict firewall isolation allows only joined containers. +pub fn private_network(host: &str, service: &str) -> skillet_podman::PodmanNetwork { + let name = format!("{host}-{service}"); + skillet_podman::PodmanNetwork { + unit_name: name.clone(), + options: vec![ + "Driver=bridge".into(), + // Netavark skips firewall/isolation rules for internal networks. + "Internal=false".into(), + "DisableDNS=false".into(), + "IPv6=true".into(), + "Options=isolate=strict".into(), + format!("NetworkName={name}"), + ], + } +} + +fn state_volumes() -> Vec { + vec![ + Volume { + host_path: "/etc/skillet/caddy".to_string(), + container_path: "/etc/caddy".to_string(), + options: Some("ro,Z".to_string()), + host_mode: None, + host_ownership: None, + }, + Volume { + host_path: "/var/lib/data/caddy/data".to_string(), + container_path: "/data".to_string(), + options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, + }, + Volume { + host_path: "/var/lib/data/caddy/config".to_string(), + container_path: "/config".to_string(), + options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, + }, + ] +} diff --git a/ublue/skillet/crates/caddy/src/tests.rs b/ublue/skillet/crates/caddy/src/tests.rs new file mode 100644 index 00000000..d0d04312 --- /dev/null +++ b/ublue/skillet/crates/caddy/src/tests.rs @@ -0,0 +1,406 @@ +use super::{CaddySites, UiEnvironment, UiService}; +use skillet_core::{ + system::PodmanSecretResource, + test_utils::{MockFiles, MockSystem}, +}; +use std::sync::atomic::Ordering; + +fn syncthing_only() -> Vec { + vec![UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + tailnet_identity: false, + aliases: Vec::new(), + }] +} + +#[test] +fn applying_unchanged_caddy_sites_preserves_the_container() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system + .ensure_podman_secret("cloudflare_acme_token", "dummy-token") + .unwrap(); + let sites = CaddySites::from_host( + "joey", + &UiEnvironment { + ui_domain: "test.example.invalid".to_string(), + acme_staging: true, + }, + &syncthing_only(), + ) + .unwrap(); + super::apply(&system, &files, &sites, "joey").unwrap(); + let initial_restart_count = system.restart_count.load(Ordering::SeqCst); + let initial_config = files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/caddy.container") + .cloned() + .unwrap(); + let initial_config_text = String::from_utf8_lossy(&initial_config); + assert!(initial_config_text.contains("Volume=/etc/skillet/caddy:/etc/caddy:ro,Z")); + assert!(files + .files + .lock() + .unwrap() + .contains_key("/etc/skillet/caddy/Caddyfile")); + + super::apply(&system, &files, &sites, "joey").unwrap(); + + assert_eq!( + system.restart_count.load(Ordering::SeqCst), + initial_restart_count + ); + assert_eq!( + files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/caddy.container"), + Some(&initial_config) + ); +} + +#[test] +fn derives_a_single_non_clamps_service_hostname_for_either_environment() { + let services = syncthing_only(); + for (domain, staging) in [ + ("test.example.invalid", true), + ("prod.example.invalid", false), + ] { + let sites = CaddySites::from_host( + "joey", + &UiEnvironment { + ui_domain: domain.to_string(), + acme_staging: staging, + }, + &services, + ) + .unwrap(); + assert_eq!(sites.services.len(), 1); + assert_eq!( + sites.services[0].hostname, + format!("syncthing.joey.{domain}") + ); + assert_eq!(sites.acme_staging, staging); + } +} + +#[test] +fn renders_each_declared_service_with_tailnet_filter_and_container_dns() { + let services = vec![ + UiService { + name: "pihole".to_string(), + upstream: "pihole".to_string(), + port: 8088, + tailnet_identity: false, + aliases: Vec::new(), + }, + syncthing_only().remove(0), + ]; + let sites = CaddySites::from_host( + "clamps", + &UiEnvironment { + ui_domain: "private.example.invalid".to_string(), + acme_staging: true, + }, + &services, + ) + .unwrap(); + let rendered = sites.render(); + assert!(rendered.contains("pihole.clamps.private.example.invalid")); + assert!(rendered.contains("syncthing.clamps.private.example.invalid")); + assert!(rendered.contains("reverse_proxy pihole:8088")); + assert!(rendered.contains("reverse_proxy syncthing:8384")); + assert!(rendered + .contains("respond @outside_tailnet \"Access denied by Skillet tailnet policy\" 403")); + assert_eq!( + rendered + .matches("respond @outside_tailnet \"Access denied by Skillet tailnet policy\" 403") + .count(), + 2 + ); + assert!(rendered.contains("https://acme-staging-v02.api.letsencrypt.org/directory")); +} + +#[test] +fn guest_payload_must_match_the_callers_declared_services() { + let services = syncthing_only(); + let expected = CaddySites::from_host( + "joey", + &UiEnvironment { + ui_domain: "test.example.invalid".to_string(), + acme_staging: true, + }, + &services, + ) + .unwrap(); + let payload = serde_json::to_string(&expected).unwrap(); + assert_eq!( + CaddySites::parse(&payload, "joey", &services).unwrap(), + expected + ); + assert!(CaddySites::parse(&payload, "clamps", &services).is_err()); + assert!(CaddySites::parse(&payload, "joey", &[]).is_err()); +} + +#[test] +fn rejects_domain_injection_duplicates_and_empty_service_sets() { + for domain in [ + "https://example.invalid", + "example.invalid/path", + "example.invalid:443", + "bad..invalid", + "bad\n.invalid", + ] { + assert!(super::validate_domain(domain).is_err()); + } + let duplicate = vec![syncthing_only()[0].clone(), syncthing_only()[0].clone()]; + assert!(CaddySites::from_host( + "host", + &UiEnvironment { + ui_domain: "example.invalid".to_string(), + acme_staging: false + }, + &duplicate + ) + .is_err()); + assert!(CaddySites::from_host( + "host", + &UiEnvironment { + ui_domain: "example.invalid".to_string(), + acme_staging: false + }, + &[] + ) + .is_err()); + assert!(super::validate_domain_in_zone("ui.other.invalid", "example.invalid").is_err()); + assert!(super::validate_domain_in_zone("ui.example.invalid", "example.invalid").is_ok()); +} + +#[test] +fn renders_declared_aliases_and_rejects_conflicting_names() { + let services = vec![UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + tailnet_identity: false, + aliases: vec!["sync".to_string(), "sync.{host}".to_string()], + }]; + let sites = CaddySites::from_host( + "clamps", + &UiEnvironment { + ui_domain: "test.example.invalid".to_string(), + acme_staging: true, + }, + &services, + ) + .unwrap(); + assert_eq!(sites.machine_hostname, "clamps.test.example.invalid"); + assert_eq!( + sites.services[0].aliases, + vec![ + "sync.test.example.invalid", + "sync.clamps.test.example.invalid", + ] + ); + let rendered = sites.render(); + assert!(rendered.contains("sync.test.example.invalid {")); + assert!(rendered.contains("sync.clamps.test.example.invalid {")); + assert_eq!(rendered.matches("reverse_proxy syncthing:8384").count(), 3); + assert_eq!(rendered.matches("resolvers 1.1.1.1 1.0.0.1").count(), 3); + + for alias in ["", ".sync", "sync.", "*.sync", "bad{host}", "clamps"] { + let invalid = vec![UiService { + tailnet_identity: false, + aliases: vec![alias.to_string()], + ..services[0].clone() + }]; + assert!(CaddySites::from_host( + "clamps", + &UiEnvironment { + ui_domain: "test.example.invalid".to_string(), + acme_staging: true, + }, + &invalid, + ) + .is_err()); + } +} +#[test] +fn relative_ui_domain_defaults_and_always_appends_zone() { + assert_eq!( + super::resolve_ui_domain("example.com", None).expect("default"), + "ui.example.com" + ); + assert_eq!( + super::resolve_ui_domain("example.com", Some("ui.whatever")).expect("relative prefix"), + "ui.whatever.example.com" + ); + assert_eq!( + super::resolve_ui_domain("example.com", Some(" ui ")).expect("trimmed prefix"), + "ui.example.com" + ); + assert_eq!( + super::resolve_ui_domain("example.com", Some("other.example.com")) + .expect("all values are relative"), + "other.example.com.example.com" + ); + assert_eq!( + super::resolve_ui_domain("example.test", None).expect("other environment"), + "ui.example.test" + ); +} + +#[test] +fn relative_ui_domain_rejects_invalid_prefixes_and_total_length() { + for prefix in [ + "", + " ", + ".ui", + "ui.", + "ui..private", + "https://ui", + "ui:443", + "ui/path", + "ui space", + "*", + "{host}", + "-ui", + "ui-", + ] { + assert!(super::resolve_ui_domain("example.com", Some(prefix)).is_err()); + } + assert!(super::resolve_ui_domain("invalid", None).is_err()); + let label = "a".repeat(63); + let too_long = format!("{label}.{label}.{label}.{label}"); + assert!(super::resolve_ui_domain("example.com", Some(&too_long)).is_err()); +} + +#[test] +fn file_ui_is_gated_before_proxy_and_cannot_disable_declared_identity() { + let services = vec![UiService { + name: "files".into(), + upstream: "mikochi".into(), + port: 8080, + aliases: vec!["browse.{host}".into()], + tailnet_identity: true, + }]; + let mut sites = CaddySites::from_host( + "joey", + &UiEnvironment { + ui_domain: "ui.example.invalid".into(), + acme_staging: true, + }, + &services, + ) + .unwrap(); + sites.tailnet_users = vec!["alice@example.invalid".into()]; + let config = sites.render(); + assert!(config.contains("files.joey.ui.example.invalid")); + assert_eq!( + config + .matches("forward_auth unix//run/skillet-tailnet-ui/auth.sock") + .count(), + 2 + ); + assert!(config.find("forward_auth").unwrap() < config.find("reverse_proxy mikochi").unwrap()); + assert!(config.contains("header_up X-Skillet-Peer {http.request.remote}")); + assert!(config.contains("request_header -X-Skillet-User")); + assert!(config.contains("respond @cross_origin 403")); + let encoded = serde_json::to_string(&sites).unwrap(); + assert_eq!( + CaddySites::parse(&encoded, "joey", &services).unwrap(), + sites + ); + sites.services[0].tailnet_identity = false; + assert!(CaddySites::parse(&serde_json::to_string(&sites).unwrap(), "joey", &services).is_err()); +} + +#[test] +fn identity_caddy_has_only_the_authorization_socket_and_a_private_backend() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system + .ensure_podman_secret("cloudflare_acme_token", "dummy-token") + .unwrap(); + let mut sites = CaddySites::from_host( + "joey", + &UiEnvironment { + ui_domain: "ui.example.invalid".into(), + acme_staging: true, + }, + &[UiService { + name: "files".into(), + upstream: "mikochi".into(), + port: 8080, + aliases: Vec::new(), + tailnet_identity: true, + }], + ) + .unwrap(); + sites.tailnet_users = vec!["alice@example.invalid".into()]; + super::apply(&system, &files, &sites, "joey").unwrap(); + let records = files.files.lock().unwrap(); + let quadlet = String::from_utf8_lossy(&records["/etc/containers/systemd/caddy.container"]); + assert!(quadlet.contains("Network=joey-files.network")); + assert!(quadlet.contains("Volume=/run/skillet-tailnet-ui:/run/skillet-tailnet-ui:ro,z")); + assert!(!quadlet.contains("skillet-tailscale")); + let network = String::from_utf8_lossy(&records["/etc/containers/systemd/joey-files.network"]); + assert!(network.contains("Internal=false")); + assert!(network.contains("Options=isolate=strict")); + let restarts = system.restart_count.load(Ordering::SeqCst); + drop(records); + super::apply(&system, &files, &sites, "joey").unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); +} + +#[test] +#[ignore = "requires Podman and the Caddy container image"] +fn generated_identity_caddyfile_validates_with_the_real_consumer() { + let temp = tempfile::tempdir().unwrap(); + let sites = CaddySites::from_host( + "clamps", + &UiEnvironment { + ui_domain: "ui.example.invalid".into(), + acme_staging: true, + }, + &[UiService { + name: "files".into(), + upstream: "mikochi".into(), + port: 8080, + aliases: Vec::new(), + tailnet_identity: true, + }], + ) + .unwrap(); + let path = temp.path().join("Caddyfile"); + std::fs::write(&path, sites.render()).unwrap(); + let volume = format!("{}:/etc/caddy/Caddyfile:ro,z", path.display()); + let output = std::process::Command::new("podman") + .args([ + "run", + "--rm", + "--network", + "none", + "--volume", + &volume, + "--env", + "CF_API_TOKEN=0000000000000000000000000000000000000000", + "ghcr.io/caddybuilds/caddy-cloudflare:2", + "caddy", + "validate", + "--config", + "/etc/caddy/Caddyfile", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/ublue/skillet/crates/cli-common/Cargo.toml b/ublue/skillet/crates/cli-common/Cargo.toml new file mode 100644 index 00000000..4adddb12 --- /dev/null +++ b/ublue/skillet/crates/cli-common/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "skillet_cli_common" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +nix.workspace = true +sha2.workspace = true +zeroize = "1.9" +skillet_smtp.workspace = true +skillet_nut.workspace = true +skillet_datadog.workspace = true +skillet_caddy.workspace = true +skillet_core.workspace = true +skillet_hosts.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_yml.workspace = true +tempfile.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/cli-common/src/access.rs b/ublue/skillet/crates/cli-common/src/access.rs new file mode 100644 index 00000000..f53071f9 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/access.rs @@ -0,0 +1,219 @@ +//! Explicit, one-time transition from bootstrap to interactive administration. +use crate::CliCommonError; +use nix::{ + fcntl::{Flock, FlockArg}, + unistd::Uid, +}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Write as _, + os::unix::fs::OpenOptionsExt as _, + path::{Path, PathBuf}, + process::Command, +}; + +#[derive(clap::Subcommand, Debug)] +pub enum AccessCommands { + /// Finish provisioning: require a sudo password and change password on next login + Finalize { + /// Existing administrator account to finalize + #[arg(long)] + user: String, + }, +} + +pub fn dispatch(command: AccessCommands) -> Result<(), CliCommonError> { + match command { + AccessCommands::Finalize { user } => { + validate_user(&user)?; + if !Uid::effective().is_root() { + return Err(config( + "access finalization requires root; run it with sudo", + )); + } + let root = Path::new("/var/lib/skillet/access"); + fs::create_dir_all(root)?; + let lock_file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(root.join("finalize.lock"))?; + let _lock = Flock::lock(lock_file, FlockArg::LockExclusiveNonblock) + .map_err(|_| config("another access finalization is running"))?; + let runtime = LinuxAccess { + root: root.to_path_buf(), + }; + finalize(&runtime, &user)?; + tracing::info!(user, "Access finalized; sudo requires the account password"); + Ok(()) + } + } +} + +fn config(message: &str) -> CliCommonError { + CliCommonError::Config(message.to_string()) +} + +fn validate_user(user: &str) -> Result<(), CliCommonError> { + if user == "root" + || user.len() > 32 + || user.is_empty() + || !user.bytes().enumerate().all(|(i, b)| { + b.is_ascii_lowercase() || b == b'_' || (i > 0 && (b.is_ascii_digit() || b == b'-')) + }) + { + return Err(config("finalization requires a non-root Unix account name")); + } + Ok(()) +} + +struct PasswordState { + fingerprint: String, + expired: bool, +} + +// A single adapter owns the account observation and the matching mutation. +trait AccessRuntime { + fn password(&self, user: &str) -> Result; + fn read_state(&self, user: &str) -> Result, CliCommonError>; + fn write_state(&self, user: &str, state: &str) -> Result<(), CliCommonError>; + fn require_sudo_password(&self, user: &str) -> Result<(), CliCommonError>; + fn expire_password(&self, user: &str) -> Result<(), CliCommonError>; +} + +fn finalize(runtime: &impl AccessRuntime, user: &str) -> Result<(), CliCommonError> { + validate_user(user)?; + let password = runtime.password(user)?; + let state = runtime.read_state(user)?; + // Re-converge durable sudo policy without repeating a one-time expiry. + if state.as_deref() == Some("complete\n") { + return runtime.require_sudo_password(user); + } + let original = if let Some(state) = &state { + state + .strip_prefix("pending:") + .and_then(|value| value.strip_suffix('\n')) + .filter(|value| value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())) + .ok_or_else(|| config("invalid access finalization journal"))? + .to_string() + } else { + runtime.write_state(user, &format!("pending:{}\n", password.fingerprint))?; + password.fingerprint.clone() + }; + runtime.require_sudo_password(user)?; + // If interrupted after expiry or after an operator changed the password, + // finish the journal without resetting the operator's new password. + if !password.expired && password.fingerprint == original { + runtime.expire_password(user)?; + } + runtime.write_state(user, "complete\n") +} + +fn sudo_policy(user: &str) -> String { + format!("# Managed by skillet access finalize\nDefaults:{user} !rootpw, !targetpw, !runaspw\n{user} ALL=(ALL:ALL) PASSWD: ALL\n") +} + +struct LinuxAccess { + root: PathBuf, +} + +fn atomic_write(path: &Path, content: &[u8], mode: u32) -> Result<(), CliCommonError> { + use std::os::unix::fs::PermissionsExt as _; + let parent = path.parent().ok_or_else(|| config("file has no parent"))?; + if fs::read(path).ok().as_deref() == Some(content) + && fs::metadata(path)?.permissions().mode() & 0o777 == mode + { + return Ok(()); + } + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(mode))?; + temporary.write_all(content)?; + temporary.as_file().sync_all()?; + temporary + .persist(path) + .map_err(|error| CliCommonError::Io(error.error))?; + fs::File::open(parent)?.sync_all()?; + Ok(()) +} + +fn execute(program: &str, args: &[&str]) -> Result<(), CliCommonError> { + let result = Command::new(program).args(args).output()?; + if !result.status.success() { + // Never include account database contents or command output in errors. + return Err(config(&format!( + "{program} failed during access finalization" + ))); + } + Ok(()) +} + +impl AccessRuntime for LinuxAccess { + fn password(&self, user: &str) -> Result { + let shadow = zeroize::Zeroizing::new(fs::read_to_string("/etc/shadow")?); + let entry = shadow + .lines() + .find(|line| line.split(':').next() == Some(user)) + .ok_or_else(|| config("administrator account is absent from /etc/shadow"))?; + let mut fields = entry.split(':'); + fields.next(); + let hash = fields + .next() + .ok_or_else(|| config("invalid password entry"))?; + if hash.is_empty() || hash.starts_with(['!', '*']) { + return Err(config( + "administrator needs an unlocked bootstrap password before finalization", + )); + } + let changed = fields + .next() + .ok_or_else(|| config("invalid password age entry"))?; + Ok(PasswordState { + fingerprint: format!("{:x}", Sha256::digest(hash.as_bytes())), + expired: changed == "0", + }) + } + fn read_state(&self, user: &str) -> Result, CliCommonError> { + match fs::read_to_string(self.root.join(user)) { + Ok(state) => Ok(Some(state)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } + } + fn write_state(&self, user: &str, state: &str) -> Result<(), CliCommonError> { + atomic_write(&self.root.join(user), state.as_bytes(), 0o600) + } + fn require_sudo_password(&self, user: &str) -> Result<(), CliCommonError> { + let directory = Path::new("/etc/sudoers.d"); + let name = format!("zz-skillet-access-{user}"); + for entry in fs::read_dir(directory)? { + let filename = entry?.file_name().to_string_lossy().into_owned(); + if filename > name && !filename.contains('.') && !filename.ends_with('~') { + return Err(config( + "a later sudoers rule exists; review sudo policy before finalizing", + )); + } + } + let mut candidate = tempfile::NamedTempFile::new_in(directory)?; + candidate.write_all(sudo_policy(user).as_bytes())?; + let candidate_path = candidate + .path() + .to_str() + .ok_or_else(|| config("invalid sudoers path"))?; + execute("/usr/sbin/visudo", &["-c", "-f", candidate_path])?; + execute("/usr/sbin/visudo", &["-c"])?; + atomic_write(&directory.join(name), sudo_policy(user).as_bytes(), 0o440)?; + execute("/usr/sbin/visudo", &["-c"]) + } + fn expire_password(&self, user: &str) -> Result<(), CliCommonError> { + execute("/usr/bin/chage", &["-d", "0", user]) + } +} + +#[cfg(test)] +#[path = "access/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/cli-common/src/access/tests.rs b/ublue/skillet/crates/cli-common/src/access/tests.rs new file mode 100644 index 00000000..e6fa4a75 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/access/tests.rs @@ -0,0 +1,115 @@ +use super::*; +use std::cell::{Cell, RefCell}; + +struct Fake { + state: RefCell>, + fingerprint: RefCell, + expired: Cell, + expirations: Cell, + policies: Cell, + fail_expiry: Cell, +} +impl Default for Fake { + fn default() -> Self { + Self { + state: RefCell::new(None), + fingerprint: RefCell::new("a".repeat(64)), + expired: Cell::new(false), + expirations: Cell::new(0), + policies: Cell::new(0), + fail_expiry: Cell::new(false), + } + } +} +impl AccessRuntime for Fake { + fn password(&self, _: &str) -> Result { + Ok(PasswordState { + fingerprint: self.fingerprint.borrow().clone(), + expired: self.expired.get(), + }) + } + fn read_state(&self, _: &str) -> Result, CliCommonError> { + Ok(self.state.borrow().clone()) + } + fn write_state(&self, _: &str, state: &str) -> Result<(), CliCommonError> { + *self.state.borrow_mut() = Some(state.to_string()); + Ok(()) + } + fn require_sudo_password(&self, _: &str) -> Result<(), CliCommonError> { + self.policies.set(self.policies.get() + 1); + Ok(()) + } + fn expire_password(&self, _: &str) -> Result<(), CliCommonError> { + assert!(self + .state + .borrow() + .as_deref() + .is_some_and(|s| s.starts_with("pending:"))); + assert!(self.policies.get() > 0); + if self.fail_expiry.get() { + return Err(config("expiry failed")); + } + self.expirations.set(self.expirations.get() + 1); + self.expired.set(true); + Ok(()) + } +} +#[test] +fn finalization_requires_password_and_expires_once() { + let runtime = Fake::default(); + finalize(&runtime, "admin").unwrap(); + runtime.expired.set(false); + *runtime.fingerprint.borrow_mut() = "b".repeat(64); + finalize(&runtime, "admin").unwrap(); + assert_eq!(runtime.expirations.get(), 1); + assert_eq!(runtime.policies.get(), 2); + assert_eq!(runtime.state.borrow().as_deref(), Some("complete\n")); +} +#[test] +fn interrupted_expiry_is_retryable() { + let runtime = Fake::default(); + runtime.fail_expiry.set(true); + assert!(finalize(&runtime, "admin").is_err()); + runtime.fail_expiry.set(false); + finalize(&runtime, "admin").unwrap(); + assert_eq!(runtime.expirations.get(), 1); +} +#[test] +fn recovery_preserves_password_changed_after_interruption() { + for changed in [false, true] { + let runtime = Fake::default(); + *runtime.state.borrow_mut() = Some(format!("pending:{}\n", "a".repeat(64))); + if changed { + *runtime.fingerprint.borrow_mut() = "b".repeat(64); + } else { + runtime.expired.set(true); + } + finalize(&runtime, "admin").unwrap(); + assert_eq!(runtime.expirations.get(), 0); + } +} +#[test] +fn account_names_cannot_inject_policy_or_options() { + for user in ["", "root", "-admin", "admin\nroot", "admin ALL", "../admin"] { + assert!(validate_user(user).is_err()); + } + assert!(validate_user("admin-2").is_ok()); + assert!(sudo_policy("admin").contains("PASSWD: ALL")); + assert!(!sudo_policy("admin").contains("NOPASSWD")); +} +#[test] +fn shared_host_cli_accepts_finalization() { + use clap::Parser as _; + assert!( + crate::HostArgs::try_parse_from(["host", "access", "finalize", "--user", "admin"]).is_ok() + ); +} + +#[test] +fn malformed_journal_does_not_change_policy_or_account() { + let runtime = Fake::default(); + *runtime.state.borrow_mut() = Some("invalid".to_string()); + assert!(finalize(&runtime, "admin").is_err()); + assert_eq!(runtime.policies.get(), 0); + assert_eq!(runtime.expirations.get(), 0); +} diff --git a/ublue/skillet/crates/cli-common/src/hosts.rs b/ublue/skillet/crates/cli-common/src/hosts.rs new file mode 100644 index 00000000..12dc8b3a --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/hosts.rs @@ -0,0 +1,39 @@ +//! CLI phase parsing and compatibility forwarding to canonical host profiles. + +use skillet_core::credentials::CredentialInputs; +use skillet_core::{files::FileResource, system::SystemResource}; +pub use skillet_hosts::{ + boot_policy_for_host, declared_profiles, profile_for_host, profile_for_name, + ui_config_for_host, ApplyError, CredentialConsumer, HostBootPolicy, HostId, HostProfile, + HostService, HostUiConfig, ServiceConfig, UiServiceDeclaration, CADDY_SITES_CREDENTIAL, + CLOUDFLARE_ACME_TOKEN_CREDENTIAL, PIHOLE_WEB_PASSWORD_CREDENTIAL, + TAILSCALE_AUTH_KEY_CREDENTIAL, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] +pub enum ApplyPhase { + Base, + Full, + Caddy, + Ddns, + Datadog, + Smtp, +} + +pub fn apply_host_phase( + hostname: &str, + phase: ApplyPhase, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialInputs, +) -> Result<(), ApplyError> { + let phase = match phase { + ApplyPhase::Base => skillet_hosts::HostApplyPhase::Base, + ApplyPhase::Full => skillet_hosts::HostApplyPhase::Full, + ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, + ApplyPhase::Ddns => skillet_hosts::HostApplyPhase::Ddns, + ApplyPhase::Datadog => skillet_hosts::HostApplyPhase::Datadog, + ApplyPhase::Smtp => skillet_hosts::HostApplyPhase::Smtp, + }; + skillet_hosts::apply_host_phase(hostname, phase, system, files, credentials) +} diff --git a/ublue/skillet/crates/cli-common/src/lib.rs b/ublue/skillet/crates/cli-common/src/lib.rs new file mode 100644 index 00000000..7048ed51 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/lib.rs @@ -0,0 +1,343 @@ +use clap::Parser; +use serde::Serialize; +use skillet_core::credentials::{CredentialInputs, CredentialManager}; +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; + +pub mod access; +pub mod hosts; +mod metrics; +pub use metrics::{run_metrics, MetricsCommands}; +mod smtp_runtime; +use hosts::ApplyPhase; + +#[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), + #[error("Credential installation error: {0}")] + CredentialInstall(#[from] skillet_core::credential_install::CredentialInstallError), + #[error("Systemd credential error: {0}")] + SystemdCredential(#[from] skillet_core::credentials::CredentialError), + #[error("Configuration apply failed: {apply}; diagnostic recording also failed: {recording}")] + ApplyAndRecord { apply: String, recording: String }, +} + +#[derive(Serialize)] +struct DiagnosticRecording<'a> { + format_version: u32, + host: &'a str, + outcome: &'static str, + operations: Vec, +} + +#[derive(Parser, Debug)] +#[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 { + /// Collect service metrics without workstation credentials + Metrics { + #[command(subcommand)] + command: MetricsCommands, + }, + /// Run the private tailnet identity authorization endpoint + TailnetUi { + #[command(subcommand)] + command: TailnetUiCommands, + }, + /// Finalize interactive administrative access after provisioning + Access { + #[command(subcommand)] + command: access::AccessCommands, + }, + /// Prepare native SMTP runtime files from an encrypted systemd credential + Smtp { + #[command(subcommand)] + command: SmtpCommands, + }, + /// Apply configuration + Apply { + #[arg(long, value_enum, default_value_t = ApplyPhase::Full)] + phase: ApplyPhase, + /// Optional: Output recorded actions to this file path + #[arg(long)] + record: Option, + }, + /// Inspect or install an encrypted host credential + Credential { + #[command(subcommand)] + command: CredentialCommands, + }, +} + +#[derive(clap::Subcommand, Debug)] +pub enum CredentialCommands { + /// Print `present` if a host credential or Podman secret exists, otherwise `absent` + State { name: String }, + /// Encrypt stdin for this host, install it, and start the consuming unit + Install { + name: String, + unit: String, + /// Save the encrypted credential without starting its consuming unit. + #[arg(long)] + no_start: bool, + }, +} + +pub fn run_host(hostname: &str, apply_fn: F) -> Result<(), CliCommonError> +where + F: Fn( + ApplyPhase, + &dyn SystemResource, + &dyn FileResource, + &CredentialInputs, + ) -> 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::Metrics { command } => run_metrics(command), + HostCommands::TailnetUi { command } => run_tailnet_ui(command), + HostCommands::Access { command } => access::dispatch(command), + HostCommands::Smtp { + command: SmtpCommands::Prepare, + } => prepare_smtp(), + HostCommands::Apply { record, phase } => { + handle_host_apply(hostname, phase, record, |system, files, credentials| { + apply_fn(phase, system, files, credentials) + }) + } + HostCommands::Credential { command } => { + match command { + CredentialCommands::State { name } => { + println!("{}", skillet_core::credential_install::state(&name)?); + } + CredentialCommands::Install { + name, + unit, + no_start, + } => skillet_core::credential_install::install(&name, &unit, !no_start)?, + } + Ok(()) + } + } +} + +pub fn handle_apply( + hostname: &str, + record_path: Option, + apply_fn: F, +) -> Result<(), CliCommonError> +where + F: Fn(&dyn SystemResource, &dyn FileResource) -> Result<(), String>, +{ + handle_apply_with_credentials( + hostname, + record_path, + &CredentialInputs::default(), + |system, files, _| apply_fn(system, files), + ) +} + +pub fn handle_host_apply( + hostname: &str, + phase: ApplyPhase, + record_path: Option, + apply_fn: F, +) -> Result<(), CliCommonError> +where + F: Fn(&dyn SystemResource, &dyn FileResource, &CredentialInputs) -> Result<(), String>, +{ + let phase = match phase { + ApplyPhase::Base => skillet_hosts::HostApplyPhase::Base, + ApplyPhase::Full => skillet_hosts::HostApplyPhase::Full, + ApplyPhase::Caddy => skillet_hosts::HostApplyPhase::Caddy, + ApplyPhase::Ddns => skillet_hosts::HostApplyPhase::Ddns, + ApplyPhase::Datadog => skillet_hosts::HostApplyPhase::Datadog, + ApplyPhase::Smtp => skillet_hosts::HostApplyPhase::Smtp, + }; + let required = skillet_hosts::credentials_for_phase(hostname, phase) + .map_err(|error| CliCommonError::Config(error.to_string()))?; + let mut credentials = CredentialInputs::default(); + if !required.is_empty() { + let manager = CredentialManager::new()?; + for name in required { + credentials.insert(name, manager.read_secret(name)?); + } + } + handle_apply_with_credentials(hostname, record_path, &credentials, apply_fn) +} + +fn handle_apply_with_credentials( + hostname: &str, + record_path: Option, + credentials: &CredentialInputs, + apply_fn: F, +) -> Result<(), CliCommonError> +where + F: Fn(&dyn SystemResource, &dyn FileResource, &CredentialInputs) -> Result<(), String>, +{ + info!("Starting Skillet configuration for {}...", hostname); + + let system = LinuxSystemResource::new(); + let files = LocalFileResource::new(); + + if let Some(path) = record_path { + handle_recorded_apply(hostname, &path, system, files, credentials, apply_fn)?; + } else { + apply_fn(&system, &files, credentials).map_err(CliCommonError::Config)?; + } + + info!("Configuration applied successfully."); + Ok(()) +} + +fn handle_recorded_apply( + hostname: &str, + path: &std::path::Path, + system: S, + files: F, + credentials: &CredentialInputs, + apply_fn: Apply, +) -> Result<(), CliCommonError> +where + S: SystemResource, + F: FileResource, + Apply: Fn(&dyn SystemResource, &dyn FileResource, &CredentialInputs) -> Result<(), String>, +{ + let recorder_system = Recorder::new(system); + let recorder_files = Recorder::with_ops(files, recorder_system.shared_ops()); + let apply_result = apply_fn(&recorder_system, &recorder_files, credentials); + let diagnostic = DiagnosticRecording { + format_version: 2, + host: hostname, + outcome: if apply_result.is_ok() { + "succeeded" + } else { + "failed" + }, + operations: recorder_system.get_ops(), + }; + let record_result = persist_recording(path, &diagnostic); + if let Err(apply) = apply_result { + if let Err(recording) = record_result { + return Err(CliCommonError::ApplyAndRecord { apply, recording }); + } + return Err(CliCommonError::Config(apply)); + } + record_result.map_err(CliCommonError::Config)?; + info!("Recording saved to {}", path.display()); + Ok(()) +} + +fn persist_recording( + path: &std::path::Path, + diagnostic: &DiagnosticRecording<'_>, +) -> Result<(), String> { + use std::io::Write as _; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")); + fs::create_dir_all(parent).map_err(|error| format!("create {}: {error}", parent.display()))?; + let yaml = serde_yml::to_string(diagnostic).map_err(|error| format!("serialize: {error}"))?; + let mut temp = tempfile::NamedTempFile::new_in(parent) + .map_err(|error| format!("create temporary recording: {error}"))?; + temp.write_all(yaml.as_bytes()) + .map_err(|error| format!("write temporary recording: {error}"))?; + temp.as_file() + .sync_all() + .map_err(|error| format!("sync temporary recording: {error}"))?; + temp.persist(path) + .map_err(|error| format!("persist {}: {}", path.display(), error.error))?; + std::fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("sync recording directory {}: {error}", parent.display()))?; + Ok(()) +} + +#[cfg(test)] +#[path = "recording_tests.rs"] +mod recording_tests; + +#[derive(clap::Subcommand, Debug)] +pub enum SmtpCommands { + Prepare, +} + +pub fn prepare_smtp() -> Result<(), CliCommonError> { + let manager = CredentialManager::new()?; + let payload = manager.read_secret(skillet_smtp::CREDENTIAL)?; + let input = + skillet_smtp::Input::parse(&payload).map_err(|e| CliCommonError::Config(e.to_string()))?; + let files = LocalFileResource::new(); + skillet_smtp::prepare(&files, &input).map_err(|e| CliCommonError::Config(e.to_string()))?; + smtp_runtime::label_credentials(&files, &|program, arguments| { + let output = std::process::Command::new(program) + .args(arguments) + .output()?; + if !output.status.success() { + return Err(CliCommonError::Config( + "SMTP credential labeling failed".into(), + )); + } + Ok(()) + }) +} + +#[derive(clap::Subcommand, Debug)] +pub enum TailnetUiCommands { + /// Serve Caddy authorization over a private Unix socket + Serve { + #[arg(long, default_value = skillet_caddy::auth::AUTH_SOCKET)] + socket: PathBuf, + #[arg(long, default_value = skillet_caddy::auth::DAEMON_SOCKET)] + daemon_socket: PathBuf, + #[arg(long, default_value = skillet_caddy::auth::POLICY_FILE)] + policy_file: PathBuf, + }, +} + +pub fn run_tailnet_ui(command: TailnetUiCommands) -> Result<(), CliCommonError> { + match command { + TailnetUiCommands::Serve { + socket, + daemon_socket, + policy_file, + } => skillet_caddy::auth::serve(&socket, &daemon_socket, &policy_file) + .map_err(|error| CliCommonError::Config(error.to_string())), + } +} diff --git a/ublue/skillet/crates/cli-common/src/metrics.rs b/ublue/skillet/crates/cli-common/src/metrics.rs new file mode 100644 index 00000000..e3783da2 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/metrics.rs @@ -0,0 +1,45 @@ +//! Guest metric collector dispatch. +use crate::CliCommonError; +use std::path::PathBuf; + +#[derive(clap::Subcommand, Debug)] +pub enum MetricsCommands { + /// Poll a NUT UPS read-only and submit metrics to local `DogStatsD` + Nut { + #[arg(long)] + ups: String, + #[arg(long, default_value = "127.0.0.1:3493")] + server: std::net::SocketAddr, + #[arg(long, default_value = skillet_datadog::DOGSTATSD_SOCKET)] + dogstatsd_socket: PathBuf, + /// Print datagrams without sending telemetry + #[arg(long)] + dry_run: bool, + }, +} + +pub fn run_metrics(command: MetricsCommands) -> Result<(), CliCommonError> { + match command { + MetricsCommands::Nut { + ups, + server, + dogstatsd_socket, + dry_run, + } => { + skillet_nut::metrics::validate_name(&ups) + .map_err(|error| CliCommonError::Config(error.to_string()))?; + let sample = skillet_nut::metrics::collect(server, &ups); + let packets = skillet_nut::metrics::render(&ups, sample.as_ref().ok()) + .map_err(|error| CliCommonError::Config(error.to_string()))?; + if dry_run { + println!("{}", packets.join("\n")); + } else { + skillet_nut::metrics::submit(&dogstatsd_socket, &packets) + .map_err(|error| CliCommonError::Config(error.to_string()))?; + } + sample + .map(|_| ()) + .map_err(|error| CliCommonError::Config(error.to_string())) + } + } +} diff --git a/ublue/skillet/crates/cli-common/src/recording_tests.rs b/ublue/skillet/crates/cli-common/src/recording_tests.rs new file mode 100644 index 00000000..b723dd6b --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/recording_tests.rs @@ -0,0 +1,78 @@ +use super::*; +use skillet_core::resource_op::{EffectResult, RecordedOperation, ResourceOp}; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +#[test] +fn diagnostic_recording_is_versioned_and_contains_only_sanitized_results() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("nested/apply.yml"); + let diagnostic = DiagnosticRecording { + format_version: 2, + host: "fixture-host", + outcome: "failed", + operations: vec![RecordedOperation { + operation: ResourceOp::EnsurePodmanSecret { + name: "fixture-secret".to_string(), + }, + result: EffectResult::Failed, + }], + }; + persist_recording(&path, &diagnostic).unwrap(); + let output = fs::read_to_string(path).unwrap(); + assert!(output.contains("format_version: 2")); + assert!(output.contains("outcome: failed")); + assert!(output.contains("status: failed")); + assert!(!output.contains("payload")); +} + +#[test] +fn failed_apply_is_recorded_without_secret_value() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("apply.yml"); + let secret = "distinctive-failed-apply-secret"; + let system = MockSystem::new(); + system.fail_restart_once.store(true, Ordering::SeqCst); + let result = handle_recorded_apply( + "fixture-host", + &path, + system, + MockFiles::new(), + &CredentialInputs::default(), + |system, _, _| { + system + .ensure_podman_secret("fixture-secret", secret) + .map_err(|error| error.to_string())?; + system + .service_restart("fixture.service") + .map_err(|error| error.to_string()) + }, + ); + assert!( + matches!(result, Err(CliCommonError::Config(message)) if message.contains("injected restart failure")) + ); + let output = fs::read_to_string(path).unwrap(); + assert!(output.contains("outcome: failed")); + assert!(output.contains("changed: true")); + assert!(output.contains("status: failed")); + assert!(!output.contains(secret)); +} + +#[test] +fn recording_failure_preserves_apply_cause() { + let directory = tempfile::tempdir().unwrap(); + let parent_file = directory.path().join("parent-file"); + fs::write(&parent_file, "block directory creation").unwrap(); + let path = parent_file.join("apply.yml"); + let result = handle_recorded_apply( + "fixture-host", + &path, + MockSystem::new(), + MockFiles::new(), + &CredentialInputs::default(), + |_, _, _| Err("original apply cause".to_string()), + ); + assert!( + matches!(result, Err(CliCommonError::ApplyAndRecord { apply, .. }) if apply == "original apply cause") + ); +} diff --git a/ublue/skillet/crates/cli-common/src/smtp_runtime.rs b/ublue/skillet/crates/cli-common/src/smtp_runtime.rs new file mode 100644 index 00000000..17a538c4 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/smtp_runtime.rs @@ -0,0 +1,32 @@ +//! Native `SELinux` adapter for volatile SMTP configuration files. +use crate::CliCommonError; +use skillet_core::files::FileReadResource; +use std::path::Path; + +pub(super) fn label_credentials( + files: &impl FileReadResource, + execute: &impl Fn(&str, &[&str]) -> Result<(), CliCommonError>, +) -> Result<(), CliCommonError> { + // SELinux-disabled containers have no mounted policy to label against. + // Permissive hosts still need correct labels before enforcement is enabled. + if files + .read_file(Path::new("/sys/fs/selinux/enforce")) + .map_err(|e| CliCommonError::Config(e.to_string()))? + .is_none() + { + return Ok(()); + } + execute("/usr/sbin/restorecon", &["-F", "/etc/postfix/main.cf"])?; + execute( + "/usr/bin/chcon", + &[ + "-R", + "--reference=/etc/postfix/main.cf", + "/run/postfix/skillet", + ], + ) +} + +#[cfg(test)] +#[path = "smtp_runtime/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/cli-common/src/smtp_runtime/tests.rs b/ublue/skillet/crates/cli-common/src/smtp_runtime/tests.rs new file mode 100644 index 00000000..32a30a98 --- /dev/null +++ b/ublue/skillet/crates/cli-common/src/smtp_runtime/tests.rs @@ -0,0 +1,61 @@ +use super::*; +use skillet_core::{ + files::{FileMutationResource, Ownership}, + test_utils::MockFiles, +}; +use std::cell::RefCell; + +#[test] +fn disabled_selinux_skips_label_commands() { + label_credentials(&MockFiles::new(), &|_, _| { + panic!("no SELinux commands expected") + }) + .unwrap(); +} + +#[test] +fn enabled_and_permissive_selinux_restore_reference_then_label_credentials() { + for enforcement in [b"0".as_slice(), b"1".as_slice()] { + let files = MockFiles::new(); + files + .ensure_file( + Path::new("/sys/fs/selinux/enforce"), + enforcement, + None, + &Ownership::default(), + ) + .unwrap(); + let calls = RefCell::new(Vec::new()); + label_credentials(&files, &|program, args| { + calls.borrow_mut().push(( + program.to_string(), + args.iter().map(ToString::to_string).collect::>(), + )); + Ok(()) + }) + .unwrap(); + assert_eq!( + *calls.borrow(), + vec![ + ( + "/usr/sbin/restorecon".into(), + vec!["-F".into(), "/etc/postfix/main.cf".into()] + ), + ( + "/usr/bin/chcon".into(), + vec![ + "-R".into(), + "--reference=/etc/postfix/main.cf".into(), + "/run/postfix/skillet".into() + ] + ), + ] + ); + assert!( + label_credentials(&files, &|_, _| Err(CliCommonError::Config( + "label failure".into() + ))) + .is_err() + ); + } +} diff --git a/ublue/skillet/crates/cli/Cargo.toml b/ublue/skillet/crates/cli/Cargo.toml new file mode 100644 index 00000000..14f68ad5 --- /dev/null +++ b/ublue/skillet/crates/cli/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "skillet" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +zeroize = "1.9" +osc8.workspace = true +pulldown-cmark.workspace = true +supports-hyperlinks.workspace = true +skillet_cli_common.workspace = true +skillet_hosts.workspace = true +skillet_caddy.workspace = true +skillet_podman.workspace = true +skillet_core.workspace = true +skillet_vm.workspace = true +skillet_workstation.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +anyhow = "1.0" +keepass.workspace = true +rpassword.workspace = true +keyutils.workspace = true +hex.workspace = true +sha2.workspace = true +tempfile.workspace = true +serde_json.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } +skillet_datadog.workspace = true diff --git a/ublue/skillet/crates/cli/src/application_acceptance_tests.rs b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs new file mode 100644 index 00000000..7b0188b2 --- /dev/null +++ b/ublue/skillet/crates/cli/src/application_acceptance_tests.rs @@ -0,0 +1,215 @@ +use super::{application_snapshot, application_snapshot_with_probe_timeout, require_data_mount}; +use skillet_hosts::{profile_for_name, HostAcceptancePlan}; +use skillet_vm::transport::{GuestCommand, GuestTransport}; +use std::{ + os::unix::process::ExitStatusExt as _, + path::Path, + process::{ExitStatus, Output}, +}; + +struct AcceptanceTransport { + active: bool, + network: String, + missing_listener: Option, + owner: String, + container_id: String, +} + +impl GuestTransport for AcceptanceTransport { + fn execute(&self, request: &GuestCommand<'_>, _: Option<&[u8]>) -> skillet_vm::Result { + let arguments = request.arguments; + let output = match (request.program, arguments) { + ("systemctl", ["is-active", _]) if self.active => b"active\n".to_vec(), + ("systemctl", ["is-active", _]) => b"inactive\n".to_vec(), + ("sudo", ["-n", "podman", "inspect", "--format", _, _]) => { + format!( + "true|bridge|{}|{{\"{}\":{{}}}}|/var/lib/data/syncthing;/var/lib/data/caddy/data;\n", + self.container_id, self.network + ) + .into_bytes() + } + ("id", ["-u", "giacomo"]) => b"1042\n".to_vec(), + ("getent", ["group", "giacomo"]) => b"giacomo:x:2047:\n".to_vec(), + ("stat", ["-c", "%u:%g", _]) => format!("{}\n", self.owner).into_bytes(), + ("sudo", ["-n", "ss", "-H", _, "sport", "=", port]) + if self.missing_listener != port.trim_start_matches(':').parse().ok() => + { + b"LISTEN 0 128 0.0.0.0:port *:*\n".to_vec() + } + ("sha256sum", [_]) => b"abc123 /etc/containers/systemd/test.container\n".to_vec(), + _ => Vec::new(), + }; + let success = !matches!((request.program, arguments), ("systemctl", ["is-active", _]) if !self.active); + Ok(Output { + status: ExitStatus::from_raw(if success { 0 } else { 1 << 8 }), + stdout: output, + stderr: Vec::new(), + }) + } + + fn upload(&self, _: &Path, _: &str) -> skillet_vm::Result<()> { + Ok(()) + } +} + +fn transport() -> AcceptanceTransport { + AcceptanceTransport { + active: true, + network: "joey".into(), + missing_listener: None, + owner: "1042:2047".into(), + container_id: "container-id".into(), + } +} + +// These observations exercise the Syncthing/Caddy capability subset only. +// Host selection itself is checked against the complete canonical declaration. +fn syncthing_ui_plan() -> HostAcceptancePlan { + let mut plan = profile_for_name("joey").unwrap().acceptance_plan(); + plan.services + .retain(|service| matches!(service.unit.as_str(), "syncthing.service" | "caddy.service")); + plan +} + +#[test] +fn application_snapshot_accepts_profile_declared_runtime() { + let plan = syncthing_ui_plan(); + let observed = application_snapshot(&transport(), &|| Ok(()), &plan).unwrap(); + assert!(observed + .iter() + .any(|entry| entry.starts_with("container:syncthing:"))); + assert!(observed + .iter() + .any(|entry| entry.starts_with("container:caddy:"))); +} + +#[test] +fn application_snapshot_rejects_stopped_units() { + let mut fake = transport(); + fake.active = false; + let plan = syncthing_ui_plan(); + assert!(application_snapshot_with_probe_timeout( + &fake, + &|| Ok(()), + &plan, + std::time::Duration::ZERO + ) + .unwrap_err() + .to_string() + .contains("not become active")); +} + +#[test] +fn application_snapshot_rejects_wrong_network_and_missing_listener() { + let plan = syncthing_ui_plan(); + let mut wrong_network = transport(); + wrong_network.network = "unrelated".into(); + assert!(application_snapshot(&wrong_network, &|| Ok(()), &plan) + .unwrap_err() + .to_string() + .contains("network attachment")); + + let mut no_listener = transport(); + no_listener.missing_listener = Some(22000); + assert!(application_snapshot_with_probe_timeout( + &no_listener, + &|| Ok(()), + &plan, + std::time::Duration::ZERO + ) + .unwrap_err() + .to_string() + .contains("listener")); +} + +#[test] +fn application_snapshot_rejects_wrong_data_ownership_and_changed_identity() { + let plan = syncthing_ui_plan(); + let mut wrong_owner = transport(); + wrong_owner.owner = "1000:1000".into(); + assert!(application_snapshot(&wrong_owner, &|| Ok(()), &plan) + .unwrap_err() + .to_string() + .contains("ownership")); + + let before = application_snapshot(&transport(), &|| Ok(()), &plan).unwrap(); + let mut changed = transport(); + changed.container_id = "recreated-container".into(); + let after = application_snapshot(&changed, &|| Ok(()), &plan).unwrap(); + assert_ne!(before, after); +} + +#[test] +fn data_mount_and_persistence_hash_checks_reject_lost_state() { + assert!(require_data_mount("btrfs /data").is_ok()); + assert!(require_data_mount("xfs /").is_err()); + + let transport = transport(); + assert!( + super::verify_guest_hash(&transport, "/var/lib/data/.marker", "expected-hash").is_err() + ); +} + +struct FilesTransport { + sibling_exit: i32, + probes: std::sync::Mutex>, +} + +impl GuestTransport for FilesTransport { + fn execute(&self, request: &GuestCommand<'_>, _: Option<&[u8]>) -> skillet_vm::Result { + let (stdout, code) = match (request.program, request.arguments) { + ("sudo", ["-n", "podman", "inspect", "--format", _, "mikochi"]) => ( + br#"{"fixture-files":{"IPAddress":"172.28.0.2","GlobalIPv6Address":"fd00:1234::2"}}"#.to_vec(), + 0, + ), + ("curl", arguments) => { + self.probes.lock().unwrap().push(arguments.last().unwrap().to_string()); + (br#"{"status":"ok"}"#.to_vec(), 0) + } + ("sudo", ["-n", "podman", "exec", "syncthing", "curl", arguments @ ..]) => { + self.probes.lock().unwrap().push(arguments.last().unwrap().to_string()); + (Vec::new(), self.sibling_exit) + } + _ => return Err(skillet_vm::Error::Invalid("unsupported acceptance fake request".into())), + }; + Ok(Output { + status: ExitStatus::from_raw(code << 8), + stdout, + stderr: Vec::new(), + }) + } + + fn upload(&self, _: &Path, _: &str) -> skillet_vm::Result<()> { + Err(skillet_vm::Error::Invalid( + "unsupported acceptance fake upload".into(), + )) + } +} + +#[test] +fn file_ui_health_proves_readiness_and_sibling_denial_in_both_ip_families() { + for sibling_exit in [7, 28] { + let guest = FilesTransport { + sibling_exit, + probes: std::sync::Mutex::default(), + }; + super::check_mikochi_health(&guest).unwrap(); + let probes = guest.probes.lock().unwrap(); + assert_eq!(probes.len(), 4); + assert_eq!(probes[0], probes[1]); + assert_eq!(probes[2], probes[3]); + assert!(probes[0].contains("172.28.0.2:8080")); + assert!(probes[2].contains("[fd00:1234::2]:8080")); + } +} + +#[test] +fn file_ui_health_rejects_accessible_backends_and_broken_probe_commands() { + for sibling_exit in [0, 1, 3, 127] { + let guest = FilesTransport { + sibling_exit, + probes: std::sync::Mutex::default(), + }; + assert!(super::check_mikochi_health(&guest).is_err()); + } +} diff --git a/ublue/skillet/crates/cli/src/installation.rs b/ublue/skillet/crates/cli/src/installation.rs new file mode 100644 index 00000000..81ca6508 --- /dev/null +++ b/ublue/skillet/crates/cli/src/installation.rs @@ -0,0 +1,59 @@ +//! Thin physical-media command adapter. +use super::RootProfileName; +use anyhow::Result; +use std::path::PathBuf; + +#[derive(clap::Subcommand, Debug)] +pub(super) enum Commands { + /// Build a USB-ready live ISO; installation requires confirmation on the server + Prepare(PrepareArgs), +} +#[derive(clap::Args, Debug)] +pub(super) struct PrepareArgs { + host: String, + /// Stable whole-disk path as seen on the destination server + #[arg(long)] + disk: String, + /// Verified Fedora `CoreOS` `x86_64` live ISO + #[arg(long)] + iso: PathBuf, + /// New private bundle directory, outside version control + #[arg(long)] + output: PathBuf, + #[arg(long, value_enum, default_value_t = RootProfileName::Unencrypted)] + root_profile: RootProfileName, + /// Operator's Ed25519 public key, replacing inherited authorized keys + #[arg(long)] + ssh_public_key: PathBuf, + /// Private file containing a fresh crypt-format bootstrap password hash + #[arg(long)] + password_hash_file: PathBuf, + /// Trusted ublue DER signing certificate; mandatory for TPM media + #[arg(long)] + mok_certificate: Option, +} +pub(super) fn dispatch(command: Commands) -> Result<()> { + let Commands::Prepare(args) = command; + let butane = super::butane_root()?; + let workspace = super::workspace_root()?; + let output = std::path::absolute(&args.output)?; + let iso = + skillet_workstation::installation::prepare(&skillet_workstation::installation::Request { + host: &args.host, + disk: &args.disk, + root_profile: args.root_profile.policy(), + iso: &args.iso, + output: &output, + public_key: &args.ssh_public_key, + password_hash: &args.password_hash_file, + mok_certificate: args.mok_certificate.as_deref(), + butane: &butane, + workspace: &workspace, + })?; + println!("Prepared {}\nTransfer the destination Ignition over SSH to the live server, then run: sudo /var/usrlocal/bin/skillet-install /path/to/destination.ign\nNo block device was written.", iso.display()); + Ok(()) +} + +#[cfg(test)] +#[path = "installation/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/cli/src/installation/tests.rs b/ublue/skillet/crates/cli/src/installation/tests.rs new file mode 100644 index 00000000..afefb56b --- /dev/null +++ b/ublue/skillet/crates/cli/src/installation/tests.rs @@ -0,0 +1,46 @@ +use super::super::{Args, Commands as RootCommands}; +use super::*; +use clap::Parser as _; + +#[test] +fn physical_command_requires_explicit_inputs_and_defaults_to_plain_root() { + assert!(Args::try_parse_from(["skillet", "install", "prepare", "clamps"]).is_err()); + let args = Args::try_parse_from([ + "skillet", + "install", + "prepare", + "clamps", + "--disk", + "/dev/disk/by-id/ata-example", + "--iso", + "/private/live.iso", + "--output", + "/private/bundle", + "--ssh-public-key", + "/private/key.pub", + "--password-hash-file", + "/private/password.hash", + ]) + .unwrap(); + let RootCommands::Install { + command: Commands::Prepare(request), + } = args.command + else { + panic!("wrong command dispatch"); + }; + assert_eq!(request.host, "clamps"); + assert!(matches!(request.root_profile, RootProfileName::Unencrypted)); +} + +#[test] +fn physical_and_vm_help_remain_available() { + for args in [ + vec!["skillet", "install", "prepare", "--help"], + vec!["skillet", "test", "vm", "create", "--help"], + ] { + assert_eq!( + Args::try_parse_from(args).unwrap_err().kind(), + clap::error::ErrorKind::DisplayHelp + ); + } +} diff --git a/ublue/skillet/crates/cli/src/main.rs b/ublue/skillet/crates/cli/src/main.rs new file mode 100644 index 00000000..3031a72d --- /dev/null +++ b/ublue/skillet/crates/cli/src/main.rs @@ -0,0 +1,1599 @@ +use anyhow::{anyhow, Context, Result}; +use clap::Parser; +use skillet_cli_common::hosts::ApplyPhase; +use std::{fs, io::Write as _, path::PathBuf, process::Command}; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +mod installation; +mod root_recovery; +mod secret_delivery; +mod secret_output; +mod vm; +mod vm_encryption; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[command(subcommand)] + command: Commands, + #[arg(short, long, global = true)] + verbose: bool, +} + +#[derive(clap::Subcommand, Debug)] +enum Commands { + /// Collect guest service metrics without unlocking the vault + Metrics { + #[command(subcommand)] + command: skillet_cli_common::MetricsCommands, + }, + /// Run the guest private UI authorization endpoint + TailnetUi { + #[command(subcommand)] + command: skillet_cli_common::TailnetUiCommands, + }, + /// Prepare physical installation media without writing a block device + Install { + #[command(subcommand)] + command: installation::Commands, + }, + /// Finalize interactive administrative access on this guest + Access { + #[command(subcommand)] + command: skillet_cli_common::access::AccessCommands, + }, + /// Prepare guest SMTP runtime credentials + Smtp { + #[command(subcommand)] + command: skillet_cli_common::SmtpCommands, + }, + /// Manage host secrets backed by `KeePassXC` + #[command(name = "secrets", visible_alias = "secret")] + Secret { + #[command(subcommand)] + command: SecretCommands, + }, + /// Apply host configuration + Apply { + #[arg(long, value_enum, default_value_t = ApplyPhase::Full)] + phase: ApplyPhase, + #[arg(long)] + host: Option, + #[arg(long)] + host_file: Option, + /// Optional diagnostic recording of resource operations + #[arg(long)] + record: Option, + }, + /// Run the real-runtime smoke scenario against a disposable VM + Test { + #[command(subcommand)] + command: TestCommands, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum SecretCommands { + /// Audit required and unused entries without changing the vault or providers + Check(SecretCheckArgs), + /// Deliver credentials for a configured host service + Deliver(SecretDeliverArgs), + /// Verify and cache the `KeePassXC` password for this session + Unlock(SecretUnlockArgs), + /// Remove the cached vault password from the kernel keyring + Lock(SecretLockArgs), + /// Enroll a production recovery passphrase and archive its LUKS header + RootRecoveryKey(RootRecoveryArgs), +} + +#[derive(clap::Args, Debug)] +struct RootRecoveryArgs { + hostname: String, + #[command(flatten)] + vault: SecretUnlockArgs, + #[arg(long)] + target: String, + #[arg(long, default_value_t = 22)] + port: u16, + #[arg(long)] + identity: PathBuf, + #[arg(long)] + known_hosts: PathBuf, +} +#[derive(clap::Args, Debug)] +struct VmRecoveryArgs { + #[command(flatten)] + target: VmTargetArgs, + #[command(flatten)] + vault: SecretUnlockArgs, +} +#[derive(clap::Args, Debug)] +struct SecretCheckArgs { + /// Check requirements for this host; defaults to all declared profiles + #[arg(long)] + host: Option, + #[arg(long, value_enum, default_value_t = UiEnvironmentName::Production)] + environment: UiEnvironmentName, + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, +} + +#[derive(clap::Args, Debug)] +struct SecretUnlockArgs { + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, +} + +#[derive(clap::Args, Debug)] +struct SecretLockArgs { + #[arg(long)] + database: Option, +} + +#[derive(clap::Args, Debug)] +struct SecretDeliverArgs { + hostname: String, + #[arg(value_parser = ["pihole", "tailscale", "caddy", "ddns", "datadog", "smtp"])] + service: String, + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, + /// `KeePassXC` environment to use for service configuration + #[arg(long, value_enum, default_value_t = UiEnvironmentName::Production)] + environment: UiEnvironmentName, + #[arg(long)] + target: String, + #[arg(long, default_value_t = 22)] + port: u16, + #[arg(long)] + identity: PathBuf, + #[arg(long)] + known_hosts: PathBuf, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)] +enum UiEnvironmentName { + Production, + Test, +} + +impl UiEnvironmentName { + fn policy(self) -> skillet_workstation::provisioning_policy::ProvisioningPolicy { + use skillet_workstation::provisioning_policy::{Environment, ProvisioningPolicy}; + match self { + Self::Production => ProvisioningPolicy::new(Environment::Production), + Self::Test => ProvisioningPolicy::new(Environment::Test), + } + } +} + +#[derive(clap::Subcommand, Debug)] +enum TestCommands { + /// Apply a selected configuration phase twice in a disposable Podman container + Run(ContainerArgs), + /// Exercise the real-systemd VM scenario using an explicit disposable SSH target + Smoke(SmokeArgs), + /// Create or destroy a disposable host VM + Vm { + #[command(subcommand)] + command: VmCommands, + }, +} + +#[derive(clap::Subcommand, Debug)] +enum VmCommands { + /// Verify encrypted root, save disposable recovery artifacts and cold-boot it + EncryptionCheck(VmTargetArgs), + /// Save this instance recovery passphrase and LUKS header in `KeePassXC` + RecoveryKey(VmRecoveryArgs), + /// Provision a disposable host VM and wait for it to become ready + Create(VmCreateArgs), + /// Destroy a disposable host VM and remove its temporary key and artifacts + Destroy(VmDestroyArgs), + /// List available host templates and their recorded disposable VMs + List(VmListArgs), + /// Inspect the UUID and disks recorded for an owned disposable VM + Status(VmTargetArgs), + /// Reboot a retained, owned disposable VM + Reboot(VmTargetArgs), + /// Open an interactive SSH shell to a retained, owned disposable VM + Ssh(VmSshArgs), + /// Read bootstrap and Skillet journals from a retained VM + Logs(VmTargetArgs), + /// Verify signed boot and apply the captured base/user environment + Ready(VmTargetArgs), + /// Compatibility adapter for the former `RUN_DIR` readiness helper + #[command(hide = true)] + ReadyDirectory(VmDirectoryArgs), + /// Provision Pi-hole and enroll the disposable VM in Tailscale + Provision(VmProvisionArgs), + /// Install the current host binary on a retained disposable VM + Update(VmDestroyArgs), +} + +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +enum RootProfileName { + Unencrypted, + Tpm, +} + +impl RootProfileName { + fn policy(self) -> skillet_vm::install::RootProfile { + match self { + Self::Unencrypted => skillet_vm::install::RootProfile::Unencrypted, + Self::Tpm => skillet_vm::install::RootProfile::Tpm, + } + } +} + +#[derive(clap::Args, Debug)] +struct VmCreateArgs { + /// Root storage policy; TPM requires UEFI Secure Boot and a virtual TPM + #[arg(long, value_enum, default_value_t = RootProfileName::Unencrypted)] + root_profile: RootProfileName, + hostname: String, + instance: String, + #[arg(long, default_value_t = 2201)] + port: u16, + /// Use a specific Fedora `CoreOS` QEMU image instead of selecting the cached image + #[arg(long)] + image: Option, +} + +#[derive(clap::Args, Debug)] +struct VmTargetArgs { + hostname: String, + instance: String, +} + +#[derive(clap::Args, Debug)] +struct VmSshArgs { + #[command(flatten)] + target: VmTargetArgs, + /// Forward a workstation localhost TCP port to guest localhost (repeatable) + #[arg(long, value_name = "LOCAL:GUEST")] + forward: Vec, +} + +#[derive(clap::Args, Debug)] +struct VmDirectoryArgs { + run_dir: PathBuf, +} + +#[derive(clap::Args, Debug)] +struct VmDestroyArgs { + hostname: String, + instance: String, + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, +} + +#[derive(clap::Args, Debug)] +// These CLI switches select independent optional provisioning operations. +#[allow(clippy::struct_excessive_bools)] +struct VmProvisionArgs { + /// Configure only SMTP (isolated capture unless --live-smtp is supplied) + #[arg(long)] + smtp_only: bool, + /// Explicitly deliver real provider credentials; requires a fresh SMTP-only VM + #[arg(long, requires = "smtp_only")] + live_smtp: bool, + hostname: String, + instance: String, + /// Also provision private UI DNS, Caddy, and disposable Cloudflare credentials + #[arg(long)] + with_ui: bool, + /// Provision public DDNS records from test-environment `KeePassXC` config + #[arg(long)] + with_ddns: bool, + /// Deliver the shared Datadog key and enable test-tagged VM telemetry + #[arg(long)] + with_datadog: bool, + #[arg(long)] + database: Option, + #[arg(long)] + key_file: Option, + /// Replace the disposable password and restart its consumer + #[arg(long)] + rotate: bool, +} + +#[derive(clap::Args, Debug)] +struct VmListArgs { + hostname: Option, +} + +#[derive(clap::Args, Debug)] +struct ContainerArgs { + hostname: String, + /// Configuration phase (defaults to base because full apply may require host mounts) + #[arg(long, value_enum, default_value = "base")] + phase: ApplyPhase, + #[arg(long, default_value = "fedora:latest")] + image: String, + #[arg(long)] + inspect: bool, +} + +#[derive(clap::Args, Debug)] +struct SmokeArgs { + /// Host profile from the recorded disposable VM + hostname: String, + /// Recorded disposable VM instance + #[arg(long, default_value = "smoke")] + instance: String, + /// Optional target override; must match the recorded disposable VM + #[arg(long)] + target: Option, + /// SSH port + #[arg(long)] + port: Option, + /// SSH private key (defaults to the key generated for `test vm create`) + #[arg(long)] + identity: Option, + /// Also verify and repeat-apply the selected host's provisioned applications + #[arg(long)] + with_applications: bool, + /// Stop after persisting the pre-reboot checkpoint to exercise recovery + #[arg(long, hide = true)] + interrupt_before_reboot: bool, +} + +fn main() -> Result<()> { + 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::Metrics { command } => skillet_cli_common::run_metrics(command)?, + Commands::TailnetUi { command } => skillet_cli_common::run_tailnet_ui(command)?, + Commands::Install { command } => installation::dispatch(command)?, + Commands::Access { command } => skillet_cli_common::access::dispatch(command)?, + Commands::Smtp { + command: skillet_cli_common::SmtpCommands::Prepare, + } => skillet_cli_common::prepare_smtp()?, + Commands::Secret { + command: SecretCommands::Check(args), + } => secret_delivery::check_vault(&args)?, + Commands::Secret { + command: SecretCommands::Deliver(args), + } => { + secret_delivery::deliver_from_vault(&args)?; + } + Commands::Secret { + command: SecretCommands::Unlock(args), + } => secret_delivery::unlock_vault(args.database.as_deref(), args.key_file.as_deref())?, + Commands::Secret { + command: SecretCommands::RootRecoveryKey(args), + } => root_recovery::production(&args)?, + Commands::Secret { + command: SecretCommands::Lock(args), + } => secret_delivery::lock_vault(args.database.as_deref())?, + Commands::Apply { + phase, + host, + host_file, + record, + } => { + let mut hostname = host.unwrap_or_else(|| "agent".to_string()); + if let Some(path) = host_file { + hostname = std::fs::read_to_string(path) + .context("Failed to read host file")? + .trim() + .to_string(); + } + skillet_cli_common::handle_host_apply( + &hostname, + phase, + record, + |system, files, credentials| { + skillet_cli_common::hosts::apply_host_phase( + &hostname, + phase, + system, + files, + credentials, + ) + .map_err(|error| error.to_string()) + }, + ) + .map_err(|error| anyhow!("Failed to apply configuration: {error}"))?; + } + Commands::Test { + command: TestCommands::Run(args), + } => run_container_test(&args)?, + Commands::Test { + command: TestCommands::Smoke(args), + } => run_smoke(&args)?, + Commands::Test { + command: TestCommands::Vm { command }, + } => run_vm_command(command)?, + } + Ok(()) +} + +fn run_vm_command(command: VmCommands) -> Result<()> { + match command { + VmCommands::EncryptionCheck(args) => vm_encryption::check(&args)?, + VmCommands::RecoveryKey(args) => root_recovery::vm(&args)?, + VmCommands::Create(args) => run_vm_create(&args)?, + VmCommands::Destroy(args) => run_vm_destroy(&args)?, + VmCommands::List(args) => run_vm_list(&args)?, + VmCommands::Status(args) => vm::status(&args)?, + VmCommands::Reboot(args) => vm::reboot(&args)?, + VmCommands::Ssh(args) => vm::ssh(&args)?, + VmCommands::Logs(args) => vm::logs(&args)?, + VmCommands::Ready(args) => vm::ready(&args)?, + VmCommands::ReadyDirectory(args) => vm::ready_directory(&args)?, + VmCommands::Provision(args) => secret_delivery::provision_vm(&args)?, + VmCommands::Update(args) => run_vm_update(&args)?, + } + Ok(()) +} + +fn run_smoke(args: &SmokeArgs) -> Result<()> { + let root = workspace_root()?; + let profile = skillet_hosts::profile_for_name(&args.hostname) + .ok_or_else(|| anyhow!("unknown host profile: {}", args.hostname))?; + if profile.signed_image.is_none() || !profile.requires_data_mount { + return Err(anyhow!( + "host {} does not declare the boot and storage capabilities required by VM smoke", + args.hostname + )); + } + let butane = butane_root()?; + let run_identity = skillet_vm::RunIdentity::new(&args.hostname, &args.instance)?; + let store = skillet_vm::ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _lock = store.lock(&run_identity)?; + let run = store.load(&run_identity)?; + if run.phase != skillet_vm::Phase::Ready { + return Err(anyhow!( + "run `{} {}` must be ready before smoke", + args.hostname, + args.instance + )); + } + let deployed_hashes = run.deployed.as_ref().ok_or_else(|| { + anyhow!("ready VM has no recorded deployed artifact hashes; rerun `test vm ready`") + })?; + let target = format!("{}@{}", run.ssh.user, run.ssh.address); + if args + .target + .as_ref() + .is_some_and(|provided| provided != &target) + { + return Err(anyhow!( + "--target must match the SSH target recorded in the VM manifest: {target}" + )); + } + if args.port.is_some_and(|port| port != run.ssh.port) { + return Err(anyhow!( + "--port must match the SSH port recorded in the VM manifest: {}", + run.ssh.port + )); + } + let identity = if let Some(path) = &args.identity { + if !path.is_file() { + return Err(anyhow!("SSH key not found at {}", path.display())); + } + if std::fs::canonicalize(path)? != std::fs::canonicalize(&run.ssh.identity)? { + return Err(anyhow!( + "--identity must match the SSH key recorded in the VM manifest" + )); + } + path.clone() + } else { + run.ssh.identity.clone() + }; + if !identity.is_file() { + return Err(anyhow!( + "SSH key not found at {}; create the VM with `skillet test vm create {} {}`", + identity.display(), + args.hostname, + args.instance + )); + } + let host_binary = format!("/var/usrlocal/bin/skillet-{}", run.identity.host()); + let credentials_required = profile.requires_full_apply_credentials(); + let binary = std::env::current_exe().context("locating the running Skillet binary failed")?; + let (fixture_artifact, fixture_hash, cargo_target, cargo_profile) = + build_smoke_fixture(&root, &binary)?; + info!( + target = cargo_target, + profile = cargo_profile, + generic_sha256 = deployed_hashes.generic, + host_sha256 = deployed_hashes.host, + fixture_sha256 = fixture_hash, + "Resolved smoke binaries" + ); + let guest_script = root.join("integration_tests/smoke-guest.sh"); + if !guest_script.is_file() { + return Err(anyhow!( + "smoke guest assertions not found at {}", + guest_script.display() + )); + } + SmokeLifecycle { + butane: &butane, + store: &store, + run: &run, + fixture: &fixture_artifact, + fixture_hash: &fixture_hash, + guest_script: &guest_script, + host_binary: &host_binary, + credentials_required, + with_applications: args.with_applications, + interrupt_before_reboot: args.interrupt_before_reboot, + } + .run() +} + +struct SmokeLifecycle<'a> { + butane: &'a std::path::Path, + store: &'a skillet_vm::ManifestStore, + run: &'a skillet_vm::VmRun, + fixture: &'a std::path::Path, + fixture_hash: &'a str, + guest_script: &'a std::path::Path, + host_binary: &'a str, + credentials_required: bool, + with_applications: bool, + interrupt_before_reboot: bool, +} + +impl SmokeLifecycle<'_> { + fn run(&self) -> Result<()> { + use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + transport::{HostKeyPolicy, OwnershipCheckedTransport}, + }; + let backend = VirshBackend::for_run(self.run, &self.butane.join("bin/virsh"))?; + let ownership_run = self.run.clone(); + let ownership = || -> skillet_vm::Result<()> { + backend + .inspect(&ownership_run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned VM domain is absent".into()))? + .validate_owned(&ownership_run) + }; + ownership()?; + + let base = + skillet_vm::transport::SshTransport::new(self.run.ssh.clone(), HostKeyPolicy::Verify)?; + let transport = OwnershipCheckedTransport::new(&base, &ownership); + self.install_and_verify(&transport)?; + let boot_before = guest_text(&transport, "cat", &["/proc/sys/kernel/random/boot_id"])?; + if self.with_applications { + self.write_persistence_marker(&transport)?; + } + self.run_guest_phase(&transport, "before-reboot")?; + + let run = self.store.mark_reboot_pending(&self.run.identity)?; + if self.interrupt_before_reboot { + return Err(anyhow!( + "injected interruption after persisting the pre-reboot checkpoint" + )); + } + ownership()?; + backend.reboot(&run)?; + ownership()?; + Self::wait_for_new_boot(&run, &ownership, &boot_before)?; + self.run_guest_phase(&transport, "after-reboot")?; + + if self.with_applications { + self.check_applications(&transport, &ownership)?; + smoke_guest_command( + &transport, + "sudo", + &["-n", "rm", "-f", "--", &self.persistence_marker_path()], + )?; + } + + vm::ready_locked(self.butane, self.store, &self.run.identity)?; + if self.store.load(&self.run.identity)?.phase != skillet_vm::Phase::Ready { + return Err(anyhow!( + "VM readiness did not restore the Ready phase after smoke" + )); + } + Ok(()) + } + + fn install_and_verify( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + ) -> Result<()> { + for (source, destination) in [ + (self.fixture, "/var/tmp/skillet-smoke-fixture"), + (self.guest_script, "/var/tmp/skillet-smoke-guest.sh"), + ] { + transport.upload(source, destination)?; + } + smoke_guest_command( + transport, + "sudo", + &[ + "-n", + "install", + "-m", + "0755", + "/var/tmp/skillet-smoke-fixture", + "/var/usrlocal/bin/skillet-smoke-fixture", + ], + )?; + verify_guest_hash( + transport, + "/var/usrlocal/bin/skillet-smoke-fixture", + self.fixture_hash, + )?; + let deployed = self + .run + .deployed + .as_ref() + .ok_or_else(|| anyhow!("VM has no deployed artifact hashes"))?; + verify_guest_hash(transport, "/var/usrlocal/bin/skillet", &deployed.generic)?; + verify_guest_hash( + transport, + &format!("/var/usrlocal/bin/skillet-{}", self.run.identity.host()), + &deployed.host, + ) + } + + fn run_guest_phase( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + phase: &str, + ) -> Result<()> { + let credentials = if self.credentials_required { + "yes" + } else { + "no" + }; + smoke_guest_command( + transport, + "sudo", + &[ + "-n", + "bash", + "/var/tmp/skillet-smoke-guest.sh", + phase, + self.host_binary, + credentials, + ], + ) + } + + fn persistence_marker_path(&self) -> String { + format!( + "/var/lib/data/.skillet-smoke-{}-{}.marker", + self.run.identity.host(), + self.run.identity.instance() + ) + } + + fn write_persistence_marker( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + ) -> Result<()> { + use skillet_vm::transport::GuestCommand; + const MARKER: &[u8] = b"skillet disposable application persistence check\n"; + smoke_guest_command( + transport, + "sudo", + &["-n", "rm", "-f", "--", &self.persistence_marker_path()], + )?; + let output = transport.execute( + &GuestCommand { + program: "sudo", + arguments: &["-n", "tee", &self.persistence_marker_path()], + }, + Some(MARKER), + )?; + if !output.status.success() { + return Err(anyhow!("writing disposable persistence marker failed")); + } + Ok(()) + } + + fn verify_persistence_marker( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + ) -> Result<()> { + use sha2::{Digest as _, Sha256}; + let expected = hex::encode(Sha256::digest( + b"skillet disposable application persistence check\n", + )); + verify_guest_hash(transport, &self.persistence_marker_path(), &expected) + } + + fn check_applications( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + ownership: &dyn Fn() -> skillet_vm::Result<()>, + ) -> Result<()> { + let profile = skillet_hosts::profile_for_name(self.run.identity.host()) + .ok_or_else(|| anyhow!("unknown host acceptance profile"))?; + let plan = profile.acceptance_plan(); + if plan.requires_data_mount { + let mount = guest_text( + transport, + "findmnt", + &["-n", "-o", "FSTYPE,FSROOT", "--mountpoint", "/var/lib/data"], + )?; + require_data_mount(&mount)?; + } + self.verify_persistence_marker(transport)?; + let first = application_snapshot(transport, ownership, &plan)?; + self.repeat_host_apply(transport, &profile)?; + let second = application_snapshot(transport, ownership, &plan)?; + if first != second { + let changed = first + .iter() + .zip(&second) + .filter(|(before, after)| before != after) + .map(|(before, _)| application_state_label(before)) + .collect::>(); + return Err(anyhow!( + "repeat host apply changed declared application runtime state: {}", + changed.join(", ") + )); + } + tracing::info!( + services = ?plan.services.iter().map(|service| service.unit.as_str()).collect::>(), + "Host application acceptance passed" + ); + println!( + "Application acceptance passed for: {}", + plan.services + .iter() + .map(|service| service.unit.as_str()) + .collect::>() + .join(", ") + ); + Ok(()) + } + + fn repeat_host_apply( + &self, + transport: &impl skillet_vm::transport::GuestTransport, + profile: &skillet_hosts::HostProfile, + ) -> Result<()> { + if profile.requires_full_apply_credentials() { + smoke_guest_command( + transport, + "sudo", + &["-n", "systemctl", "restart", "skillet-full-apply.service"], + )?; + } else { + smoke_guest_command( + transport, + "sudo", + &["-n", self.host_binary, "apply", "--phase", "full"], + )?; + } + if profile.services.iter().any(|service| service.ui.is_some()) { + smoke_guest_command( + transport, + "sudo", + &["-n", "systemctl", "restart", "skillet-caddy-apply.service"], + )?; + } + Ok(()) + } + + fn wait_for_new_boot( + run: &skillet_vm::VmRun, + ownership: &dyn Fn() -> skillet_vm::Result<()>, + boot_before: &str, + ) -> Result<()> { + use skillet_vm::transport::{HostKeyPolicy, OwnershipCheckedTransport}; + use std::time::{Duration, Instant}; + let mut probe = + skillet_vm::transport::SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + probe.timeout = Duration::from_secs(15); + let checked = OwnershipCheckedTransport::new(&probe, ownership); + let deadline = Instant::now() + Duration::from_mins(4); + loop { + ownership()?; + match guest_text(&checked, "cat", &["/proc/sys/kernel/random/boot_id"]) { + Ok(boot_after) if boot_after != boot_before => return Ok(()), + Ok(_) | Err(_) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_secs(5)); + } + Ok(_) => return Err(anyhow!("VM did not return from reboot within 240 seconds")), + Err(error) => { + ownership()?; + if Instant::now() >= deadline { + return Err(anyhow!( + "VM did not return from reboot within 240 seconds: {error}" + )); + } + std::thread::sleep(Duration::from_secs(5)); + } + } + } + } +} + +fn application_state_label(state: &str) -> &str { + let mut fields = state.split(':'); + match fields.next() { + Some("container" | "unit" | "config" | "bind" | "listener") => { + fields.next().unwrap_or("unknown") + } + Some(name) => name, + None => "unknown", + } +} + +fn application_snapshot( + transport: &impl skillet_vm::transport::GuestTransport, + ownership: &dyn Fn() -> skillet_vm::Result<()>, + plan: &skillet_hosts::HostAcceptancePlan, +) -> Result> { + application_snapshot_with_probe_timeout( + transport, + ownership, + plan, + std::time::Duration::from_mins(2), + ) +} + +fn application_snapshot_with_probe_timeout( + transport: &impl skillet_vm::transport::GuestTransport, + ownership: &dyn Fn() -> skillet_vm::Result<()>, + plan: &skillet_hosts::HostAcceptancePlan, + probe_timeout: std::time::Duration, +) -> Result> { + let mut snapshot = Vec::new(); + for service in &plan.services { + ownership()?; + let unit_state = check_unit_active(transport, &service.unit, probe_timeout)?; + if unit_state != "active" { + return Err(anyhow!( + "declared service unit {} is not active", + service.unit + )); + } + snapshot.push(format!("unit:{}:{unit_state}", service.unit)); + if let Some(container) = &service.container { + snapshot.push(check_container(transport, service, container)?); + } + for listener in &service.listeners { + check_listener(transport, *listener, probe_timeout)?; + snapshot.push(format!( + "listener:{:?}:{}", + listener.protocol, listener.port + )); + } + if let Some(health) = check_health(transport, service.health_probe)? { + snapshot.push(health); + } + } + Ok(snapshot) +} + +fn check_unit_active( + transport: &impl skillet_vm::transport::GuestTransport, + unit: &str, + timeout: std::time::Duration, +) -> Result { + use std::time::{Duration, Instant}; + let deadline = Instant::now() + timeout; + loop { + let output = transport.execute( + &skillet_vm::transport::GuestCommand { + program: "systemctl", + arguments: &["is-active", unit], + }, + None, + )?; + let last_state = String::from_utf8(output.stdout) + .context("systemd service state was not UTF-8")? + .trim() + .to_string(); + if output.status.success() && last_state == "active" { + return Ok(last_state); + } + if Instant::now() >= deadline { + return Err(anyhow!( + "declared service unit {unit} did not become active within 120 seconds (last state: {last_state})" + )); + } + std::thread::sleep(Duration::from_secs(2)); + } +} + +fn require_data_mount(observed: &str) -> Result<()> { + if observed != "btrfs /data" { + return Err(anyhow!( + "host data mount differs from the profile expectation" + )); + } + Ok(()) +} + +fn check_container( + transport: &impl skillet_vm::transport::GuestTransport, + service: &skillet_hosts::AcceptanceService, + container: &str, +) -> Result { + use std::fmt::Write as _; + let inspect = guest_text( + transport, + "sudo", + &[ + "-n", + "podman", + "inspect", + "--format", + "{{.State.Running}}|{{.HostConfig.NetworkMode}}|{{.Id}}|{{json .NetworkSettings.Networks}}|{{range .Mounts}}{{.Source}};{{end}}", + container, + ], + )?; + let fields = inspect.splitn(5, '|').collect::>(); + if fields.len() != 5 || fields[0] != "true" { + return Err(anyhow!("declared container {container} is not running")); + } + if let Some(expected) = &service.network_mode { + let actual_networks: serde_json::Value = serde_json::from_str(fields[3]) + .context("Podman returned invalid network inspection JSON")?; + let network_matches = if expected == "host" { + fields[1] == "host" + } else { + actual_networks + .as_object() + .is_some_and(|networks| networks.contains_key(expected)) + }; + if !network_matches { + return Err(anyhow!( + "container {container} has an unexpected network attachment" + )); + } + } + let mut state = format!( + "container:{container}:{}:{}:{}", + fields[1], fields[2], fields[3] + ); + for path in &service.bind_paths { + if !fields[4].split(';').any(|source| source == path) { + return Err(anyhow!( + "container {container} does not mount declared path {path}" + )); + } + if let Some(owner_expectation) = &service.owner { + use skillet_hosts::AcceptanceOwner; + let (format, expected) = match owner_expectation { + AcceptanceOwner::Named { user, group } => { + let uid = guest_text(transport, "id", &["-u", user])? + .parse::() + .context("resolving declared data owner UID")?; + let group_record = guest_text(transport, "getent", &["group", group])?; + let gid = group_record + .split(':') + .nth(2) + .ok_or_else(|| anyhow!("resolving declared data owner group GID"))? + .parse::() + .context("parsing declared data owner group GID")?; + ("%u:%g", format!("{uid}:{gid}")) + } + AcceptanceOwner::Numeric { uid, gid } => ("%u:%g", format!("{uid}:{gid}")), + }; + let owner = guest_text(transport, "stat", &["-c", format, path])?; + if owner != expected { + return Err(anyhow!( + "data path ownership differs from declaration: {path} (expected {expected}, found {owner})" + )); + } + write!(state, ";bind:{path}:{owner}")?; + } + } + let unit_name = service + .unit + .strip_suffix(".service") + .ok_or_else(|| anyhow!("container service unit has an invalid name"))?; + let config_path = format!("/etc/containers/systemd/{unit_name}.container"); + let config_hash = guest_text(transport, "sha256sum", &[&config_path])?; + write!(state, ";config:{config_hash}")?; + Ok(state) +} + +fn check_listener( + transport: &impl skillet_vm::transport::GuestTransport, + listener: skillet_hosts::AcceptanceListener, + timeout: std::time::Duration, +) -> Result<()> { + use skillet_hosts::ListenerProtocol; + use std::time::{Duration, Instant}; + let protocol = match listener.protocol { + ListenerProtocol::Tcp => "-lnt", + ListenerProtocol::Udp => "-lnu", + }; + let port = format!(":{}", listener.port); + let deadline = Instant::now() + timeout; + loop { + let listening = guest_text( + transport, + "sudo", + &["-n", "ss", "-H", protocol, "sport", "=", &port], + )?; + if !listening.is_empty() { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(anyhow!( + "declared listener on port {} did not appear within 120 seconds", + listener.port + )); + } + std::thread::sleep(Duration::from_secs(2)); + } +} + +fn check_health( + transport: &impl skillet_vm::transport::GuestTransport, + probe: Option, +) -> Result> { + use skillet_hosts::HealthProbe; + match probe { + Some(HealthProbe::Mikochi) => check_mikochi_health(transport), + Some(HealthProbe::Pihole) => { + smoke_guest_command( + transport, + "sudo", + &["-n", "podman", "exec", "pihole", "pihole", "status"], + )?; + Ok(Some("health:pihole:running".into())) + } + Some(HealthProbe::Tailscale) => { + let status = guest_text( + transport, + "sudo", + &[ + "-n", + "podman", + "exec", + "tailscale", + "tailscale", + "status", + "--json", + ], + )?; + let status: serde_json::Value = + serde_json::from_str(&status).context("Tailscale returned invalid status JSON")?; + if status + .get("BackendState") + .and_then(serde_json::Value::as_str) + != Some("Running") + { + return Err(anyhow!("Tailscale is not connected")); + } + Ok(Some("health:tailscale:running".into())) + } + None => Ok(None), + } +} + +fn check_mikochi_health( + transport: &impl skillet_vm::transport::GuestTransport, +) -> Result> { + let inspected = guest_text( + transport, + "sudo", + &[ + "-n", + "podman", + "inspect", + "--format", + "{{json .NetworkSettings.Networks}}", + "mikochi", + ], + )?; + let networks: serde_json::Map = serde_json::from_str(&inspected)?; + if networks.len() != 1 { + return Err(anyhow!( + "Mikochi must have exactly one private backend network" + )); + } + let network = networks + .values() + .next() + .ok_or_else(|| anyhow!("Mikochi backend absent"))?; + for field in ["IPAddress", "GlobalIPv6Address"] { + let address: std::net::IpAddr = network + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| anyhow!("Mikochi backend address absent"))? + .parse()?; + let url = format!("http://{}/ready", std::net::SocketAddr::new(address, 8080)); + let reply = guest_text( + transport, + "curl", + &[ + "--noproxy", + "*", + "--silent", + "--show-error", + "--fail", + "--max-time", + "10", + &url, + ], + )?; + let ready: serde_json::Value = serde_json::from_str(&reply)?; + if ready.get("status").and_then(serde_json::Value::as_str) != Some("ok") { + return Err(anyhow!("Mikochi is not ready")); + } + check_mikochi_backend_isolation(transport, &url)?; + } + Ok(Some("health:mikochi:ready:isolated-dual-stack".into())) +} + +fn check_mikochi_backend_isolation( + transport: &impl skillet_vm::transport::GuestTransport, + url: &str, +) -> Result<()> { + let output = transport.execute( + &skillet_vm::transport::GuestCommand { + program: "sudo", + arguments: &[ + "-n", + "podman", + "exec", + "syncthing", + "curl", + "--noproxy", + "*", + "--silent", + "--show-error", + "--connect-timeout", + "3", + "--max-time", + "5", + url, + ], + }, + None, + )?; + // A missing probe executable or malformed command is not isolation evidence. + if !matches!(output.status.code(), Some(7 | 28)) { + return Err(anyhow!( + "Mikochi backend isolation probe did not reject a sibling application connection" + )); + } + Ok(()) +} + +fn smoke_guest_command( + transport: &impl skillet_vm::transport::GuestTransport, + program: &str, + arguments: &[&str], +) -> Result<()> { + let output = transport.execute( + &skillet_vm::transport::GuestCommand { program, arguments }, + None, + )?; + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + if !output.status.success() { + return Err(anyhow!( + "guest smoke command {program} failed with status {:?}", + output.status.code() + )); + } + Ok(()) +} + +fn guest_text( + transport: &impl skillet_vm::transport::GuestTransport, + program: &str, + arguments: &[&str], +) -> Result { + let output = transport.execute( + &skillet_vm::transport::GuestCommand { program, arguments }, + None, + )?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let command = std::iter::once(program) + .chain(arguments.iter().copied()) + .collect::>() + .join(" "); + return Err(anyhow!( + "guest probe `{command}` failed with status {:?}{}", + output.status.code(), + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } + )); + } + String::from_utf8(output.stdout) + .context("guest probe output was not UTF-8") + .map(|value| value.trim().to_owned()) +} + +fn verify_guest_hash( + transport: &impl skillet_vm::transport::GuestTransport, + path: &str, + expected: &str, +) -> Result<()> { + let output = guest_text(transport, "sha256sum", &[path])?; + if output.split_whitespace().next() != Some(expected) { + return Err(anyhow!( + "guest artifact at {path} does not match the recorded SHA-256" + )); + } + Ok(()) +} + +fn artifact_target_profile(binary: &std::path::Path) -> Result<(&str, &str)> { + let profile_directory = binary + .parent() + .ok_or_else(|| anyhow!("running binary has no parent directory"))?; + let profile_name = profile_directory + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| anyhow!("cannot infer Cargo profile from running binary path"))?; + let profile = if profile_name == "debug" { + "dev" + } else { + profile_name + }; + let target = profile_directory + .parent() + .and_then(|path| path.file_name()) + .and_then(|value| value.to_str()) + .ok_or_else(|| anyhow!("cannot infer Cargo target from running binary path"))?; + Ok((target, profile)) +} + +fn build_smoke_fixture<'a>( + workspace: &std::path::Path, + running_binary: &'a std::path::Path, +) -> Result<(PathBuf, String, &'a str, &'a str)> { + let (target, profile) = artifact_target_profile(running_binary)?; + let binary = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace, + packages: &["skillet-smoke-fixture"], + target, + profile, + })? + .remove("skillet-smoke-fixture") + .ok_or_else(|| anyhow!("Cargo did not report the smoke fixture executable"))?; + let hash = skillet_vm::delivery::sha256(&binary)?; + Ok((binary, hash, target, profile)) +} + +fn run_vm_create(args: &VmCreateArgs) -> Result<()> { + vm::create(args) +} + +fn run_vm_destroy(args: &VmDestroyArgs) -> Result<()> { + vm::destroy(args) +} + +fn run_vm_update(args: &VmDestroyArgs) -> Result<()> { + vm::update(args) +} + +fn run_vm_list(args: &VmListArgs) -> Result<()> { + vm::list(args) +} + +fn butane_root() -> Result { + let root = workspace_root()?; + let butane = root + .parent() + .ok_or_else(|| anyhow!("Skillet workspace has no parent directory"))? + .join("butane"); + if !butane.join("bin/test-vm").is_file() || !butane.join("bin/test-vm-ready").is_file() { + return Err(anyhow!( + "test VM helpers not found under {}; check out the sibling butane directory", + butane.display() + )); + } + Ok(butane) +} + +fn workspace_root() -> Result { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + loop { + if path.join("integration_tests").is_dir() && path.join("Cargo.toml").is_file() { + return Ok(path); + } + if !path.pop() { + return Err(anyhow!("could not locate workspace root")); + } + } +} + +fn run_container_test(args: &ContainerArgs) -> Result<()> { + skillet_hosts::profile_for_name(&args.hostname) + .ok_or_else(|| anyhow!("unknown host profile: {}", args.hostname))?; + let root = workspace_root()?; + if !root + .join("crates/hosts") + .join(&args.hostname) + .join("Cargo.toml") + .is_file() + { + return Err(anyhow!( + "host profile {} has no standalone host apply binary", + args.hostname + )); + } + let package = format!("skillet-{}", args.hostname); + let artifacts = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace: &root, + packages: &[&package], + target: "x86_64-unknown-linux-musl", + profile: "dev", + })?; + let binary = artifacts + .get(&package) + .ok_or_else(|| anyhow!("Cargo did not report {package}"))?; + info!(artifact = %binary.display(), "Using Cargo-reported host binary"); + let id = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + ); + let name = format!("skillet-test-{}-{id}", args.hostname); + let creds = root + .join("target") + .join(format!("skillet-test-credentials-{id}")); + fs::create_dir_all(&creds)?; + fs::write( + creds.join("pihole_web_password"), + "skillet-test-dummy-secret", + )?; + fs::write( + creds.join("tailscale_auth_key"), + "skillet-test-dummy-auth-key", + )?; + let start = Command::new("podman") + .args([ + "run", + "-d", + "--rm", + "--security-opt", + "label=disable", + "--name", + &name, + "-v", + ]) + .arg(format!("{}:/usr/bin/skillet:ro", binary.display())) + .arg("-v") + .arg(format!("{}:/run/credentials:ro", creds.display())) + .args([ + "-e", + "CREDENTIALS_DIRECTORY=/run/credentials", + &args.image, + "sleep", + "infinity", + ]) + .status(); + let start = match start { + Ok(status) => status, + Err(error) => { + fs::remove_dir_all(&creds) + .context("cleaning test credentials after Podman error failed")?; + return Err(error).context("starting isolated Podman container failed"); + } + }; + if !start.success() { + fs::remove_dir_all(&creds) + .context("cleaning test credentials after Podman start failure failed")?; + return Err(anyhow!("Podman failed to start {name}")); + } + let result = run_twice_and_check(&name, args.phase, args.inspect); + let cleanup = Command::new("podman") + .args(["rm", "-f", &name]) + .status() + .context("removing disposable test container failed"); + let credential_cleanup = + fs::remove_dir_all(&creds).context("removing disposable test credentials failed"); + result?; + if !cleanup?.success() { + return Err(anyhow!("failed to remove own container {name}")); + } + credential_cleanup?; + Ok(()) +} + +fn run_twice_and_check(name: &str, phase: ApplyPhase, inspect: bool) -> Result<()> { + let entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/test_entrypoint.sh"); + let phase = match phase { + ApplyPhase::Base => "base", + ApplyPhase::Full => "full", + ApplyPhase::Caddy => "caddy", + ApplyPhase::Ddns => "ddns", + ApplyPhase::Datadog => "datadog", + ApplyPhase::Smtp => "smtp", + }; + for round in ["first", "second"] { + let status = Command::new("podman") + .args(["exec", "-i", name, "/bin/sh", "-s", "--", round, phase]) + .stdin(fs::File::open(&entry)?) + .status() + .context("running apply in container failed")?; + if !status.success() { + return Err(anyhow!("{round} apply failed in {name}")); + } + } + let first = podman_capture( + name, + "sed -n '1,/^SECOND$/p' /tmp/skillet-test.systemctl.log", + )?; + let second = podman_capture( + name, + "sed -n '/^SECOND$/,$p' /tmp/skillet-test.systemctl.log", + )?; + if first.trim().is_empty() { + return Err(anyhow!("first apply emitted no systemd operations")); + } + if second + .lines() + .any(|line| line.starts_with("start ") || line.starts_with("restart ")) + { + return Err(anyhow!("second apply restarted a service: {second}")); + } + if inspect { + let status = Command::new("podman") + .args(["exec", "-it", name, "/bin/bash"]) + .status() + .context("starting inspection shell failed")?; + if !status.success() { + return Err(anyhow!("inspection shell failed")); + } + } + info!("Both applies succeeded; repeat apply issued no service start/restart"); + Ok(()) +} + +fn podman_capture(name: &str, shell: &str) -> Result { + let out = Command::new("podman") + .args(["exec", name, "/bin/sh", "-c", shell]) + .output()?; + if !out.status.success() { + return Err(anyhow!( + "failed to inspect apply operation log: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + String::from_utf8(out.stdout).context("operation log was not UTF-8") +} + +#[cfg(test)] +mod tests { + use super::Args; + use clap::Parser; + + #[test] + fn access_finalization_parses_explicit_account() { + assert!(Args::try_parse_from(["skillet", "access", "finalize", "--user", "admin"]).is_ok()); + assert!(Args::try_parse_from(["skillet", "access", "finalize"]).is_err()); + } + + #[test] + fn test_run_accepts_explicit_base_phase() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "run", + "joey", + "--phase", + "base", + "--image", + "fedora:latest", + ]); + assert!(parsed.is_ok()); + } + + #[test] + fn test_smoke_parses_without_binary_arguments() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "smoke", + "clamps", + "--target", + "core@192.0.2.5", + "--with-applications", + "--interrupt-before-reboot", + ]); + assert!(parsed.is_ok()); + } + + #[test] + fn vm_create_parses_identity_and_optional_image() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "vm", + "create", + "clamps", + "smoke", + "--image", + "/tmp/fcos.qcow2", + ]); + assert!(parsed.is_ok()); + } + + #[test] + fn vm_create_accepts_explicit_tpm_profile() { + assert!(Args::try_parse_from([ + "skillet", + "test", + "vm", + "create", + "clamps", + "tpm", + "--root-profile", + "tpm" + ]) + .is_ok()); + assert!(Args::try_parse_from([ + "skillet", + "test", + "vm", + "create", + "clamps", + "tpm", + "--root-profile", + "unknown" + ]) + .is_err()); + } + + #[test] + fn secret_unlock_accepts_database_and_key_file_overrides() { + let parsed = Args::try_parse_from([ + "skillet", + "secret", + "unlock", + "--database", + "/tmp/secrets.kdbx", + "--key-file", + "/tmp/secrets.keyx", + ]); + assert!(parsed.is_ok()); + } + + #[test] + fn secret_unlock_uses_default_database_when_no_path_is_given() { + let parsed = Args::try_parse_from(["skillet", "secret", "unlock"]); + assert!(parsed.is_ok()); + } +} + +#[cfg(test)] +#[path = "smoke_artifact_tests.rs"] +mod smoke_artifact_tests; + +#[cfg(test)] +#[path = "application_acceptance_tests.rs"] +mod application_acceptance_tests; + +#[cfg(test)] +mod vm_ssh_tests; + +#[cfg(test)] +mod root_recovery_tests; diff --git a/ublue/skillet/crates/cli/src/root_recovery.rs b/ublue/skillet/crates/cli/src/root_recovery.rs new file mode 100644 index 00000000..7d02d44a --- /dev/null +++ b/ublue/skillet/crates/cli/src/root_recovery.rs @@ -0,0 +1,94 @@ +//! CLI boundaries for production and lifecycle-owned disposable recovery. +use super::{butane_root, workspace_root, RootRecoveryArgs, SecretUnlockArgs, VmRecoveryArgs}; +use anyhow::{anyhow, Result}; +use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + transport::{GuestTransport, HostKeyPolicy, OwnershipCheckedTransport, SshTransport}, + ManifestStore, Phase, RunIdentity, SshTarget, +}; +use skillet_workstation::{ + root_recovery::{self, VaultStore}, + vault::Vault, +}; +pub(super) fn production(args: &RootRecoveryArgs) -> Result<()> { + if skillet_hosts::profile_for_name(&args.hostname).is_none() { + return Err(anyhow!("unknown host profile")); + } + let (user, address) = args + .target + .split_once('@') + .ok_or_else(|| anyhow!("SSH target must be USER@HOST"))?; + let guest = SshTransport::new( + SshTarget { + user: user.into(), + address: address.into(), + port: args.port, + identity: args.identity.clone(), + known_hosts: args.known_hosts.clone(), + }, + HostKeyPolicy::Verify, + )?; + save( + &args.hostname, + &format!("skillet/hosts/{}/storage/root-recovery-key", args.hostname), + &args.vault, + &guest, + ) +} +pub(super) fn vm(args: &VmRecoveryArgs) -> Result<()> { + let butane = butane_root()?; + let id = RunIdentity::new(&args.target.hostname, &args.target.instance)?; + let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _lock = store.lock(&id)?; + let run = store.load(&id)?; + if run.phase != Phase::Ready || run.root_profile != skillet_vm::install::RootProfile::Tpm { + return Err(anyhow!("recovery requires a ready TPM-profile VM")); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let ownership = || { + backend + .inspect(&run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned VM is absent".into()))? + .validate_owned(&run) + }; + ownership()?; + let ssh = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + let guest = OwnershipCheckedTransport::new(&ssh, &ownership); + save( + id.host(), + &format!( + "skillet/environments/test/hosts/{}/instances/{}/storage/root-recovery-key", + id.host(), + args.target.instance + ), + &args.vault, + &guest, + ) +} +fn save( + host: &str, + path: &str, + args: &SecretUnlockArgs, + guest: &impl GuestTransport, +) -> Result<()> { + let database = match &args.database { + Some(path) => path.clone(), + None => Vault::default_path()?, + }; + let mut vault = Vault::open(&database, args.key_file.as_deref())?; + root_recovery::provision( + host, + path, + &workspace_root()?.join("scripts/root-recovery.sh"), + &mut VaultStore { + vault: &mut vault, + key_file: args.key_file.as_deref(), + }, + guest, + root_recovery::generate_key, + )?; + println!( + "Saved and verified recovery passphrase and encrypted LUKS header in KeePassXC: {path}" + ); + Ok(()) +} diff --git a/ublue/skillet/crates/cli/src/root_recovery_tests.rs b/ublue/skillet/crates/cli/src/root_recovery_tests.rs new file mode 100644 index 00000000..87355b95 --- /dev/null +++ b/ublue/skillet/crates/cli/src/root_recovery_tests.rs @@ -0,0 +1,63 @@ +use super::*; +#[test] +fn production_recovery_requires_explicit_verified_ssh_target() { + assert!(Args::try_parse_from(["skillet", "secrets", "root-recovery-key", "clamps"]).is_err()); + let parsed = Args::try_parse_from([ + "skillet", + "secrets", + "root-recovery-key", + "clamps", + "--target", + "admin@host", + "--identity", + "/tmp/key", + "--known-hosts", + "/tmp/known", + "--database", + "/tmp/vault", + ]) + .unwrap(); + let Commands::Secret { + command: SecretCommands::RootRecoveryKey(args), + } = parsed.command + else { + panic!("wrong command"); + }; + assert_eq!(args.port, 22); + assert_eq!(args.vault.database, Some(PathBuf::from("/tmp/vault"))); +} +#[test] +fn disposable_recovery_keeps_instance_distinct_and_loads_target_from_owner() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "vm", + "recovery-key", + "clamps", + "encrypted", + "--database", + "/tmp/vault", + ]) + .unwrap(); + let Commands::Test { + command: TestCommands::Vm { + command: VmCommands::RecoveryKey(args), + }, + } = parsed.command + else { + panic!("wrong command"); + }; + assert_eq!(args.target.hostname, "clamps"); + assert_eq!(args.target.instance, "encrypted"); + assert!(Args::try_parse_from([ + "skillet", + "test", + "vm", + "recovery-key", + "clamps", + "encrypted", + "--target", + "admin@arbitrary" + ]) + .is_err()); +} diff --git a/ublue/skillet/crates/cli/src/secret_delivery.rs b/ublue/skillet/crates/cli/src/secret_delivery.rs new file mode 100644 index 00000000..6e7c7e1a --- /dev/null +++ b/ublue/skillet/crates/cli/src/secret_delivery.rs @@ -0,0 +1,698 @@ +use super::{ + butane_root, SecretCheckArgs, SecretDeliverArgs, UiEnvironmentName, VmDestroyArgs, + VmProvisionArgs, +}; +use anyhow::{anyhow, Context, Result}; +use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + transport::{ + GuestCommand, GuestTransport, HostKeyPolicy, OwnershipCheckedTransport, SshTransport, + }, + Environment, ManifestStore, Phase, RunIdentity, VmRun, +}; +use skillet_workstation::provisioning_state; +use skillet_workstation::tailscale; +use skillet_workstation::vault::Vault; +use std::path::Path; + +pub(super) fn deliver_from_vault(args: &SecretDeliverArgs) -> Result<()> { + validate_delivery_service(&args.hostname, &args.service)?; + if matches!(args.service.as_str(), "ddns" | "datadog" | "smtp") + && args.environment.policy().environment() + != skillet_workstation::provisioning_policy::Environment::Production + { + let service = if args.service == "ddns" { + "DDNS" + } else if args.service == "smtp" { + "SMTP" + } else { + "Datadog" + }; + return Err(anyhow!( + "test {service} must be provisioned through an owned disposable VM" + )); + } + let database = match &args.database { + Some(path) => path.clone(), + None => default_database_path()?, + }; + if !args.identity.is_file() || !args.known_hosts.is_file() { + return Err(anyhow!( + "SSH identity and recorded known-hosts file must exist" + )); + } + let mut vault = Vault::open(&database, args.key_file.as_deref())?; + match args.service.as_str() { + "pihole" => { + let transport = credential_transport(args)?; + let mut store = skillet_workstation::vault::VaultSecretStore::new( + &mut vault, + args.key_file.as_deref(), + ); + skillet_workstation::credential_delivery::deliver_pihole_credential( + &args.hostname, + &mut store, + &transport, + )?; + Ok(()) + } + "tailscale" => { + let credentials = tailscale_credentials(&vault)?; + let policy = UiEnvironmentName::Production.policy(); + let transport = credential_transport(args)?; + skillet_workstation::credential_delivery::deliver_tailscale_credential( + &args.hostname, + policy, + &credentials, + &transport, + )?; + Ok(()) + } + "caddy" => deliver_caddy_from_vault(args, &mut vault), + "ddns" => deliver_ddns_from_vault(args, &mut vault), + "smtp" => { + let input = skillet_workstation::smtp_provisioning::input( + args.environment.policy(), + &|path, field| vault.get_field(path, field), + )?; + skillet_workstation::smtp_provisioning::deliver( + &args.hostname, + &input, + &credential_transport(args)?, + )?; + Ok(()) + } + "datadog" => { + let transport = credential_transport(args)?; + deliver_datadog_from_vault( + &args.hostname, + args.environment.policy(), + &vault, + &transport, + ) + } + _ => Err(anyhow!("unsupported secret service {}", args.service)), + } +} + +fn validate_delivery_service(hostname: &str, service: &str) -> Result { + let profile = skillet_hosts::profile_for_name(hostname) + .ok_or_else(|| anyhow!("unknown host profile: {hostname}"))?; + match service { + "pihole" | "tailscale" | "ddns" | "datadog" | "smtp" + if !profile.supports_service(service) => + { + Err(anyhow!( + "host {hostname} does not declare {service} credential delivery" + )) + } + "caddy" if profile.ui_services().is_empty() => { + Err(anyhow!("host {hostname} declares no UI services")) + } + "pihole" | "tailscale" | "caddy" | "ddns" | "datadog" | "smtp" => Ok(profile), + unsupported => Err(anyhow!("unsupported secret service {unsupported}")), + } +} + +fn deliver_datadog_from_vault( + host: &str, + policy: skillet_workstation::provisioning_policy::ProvisioningPolicy, + vault: &Vault, + transport: &impl GuestTransport, +) -> Result<()> { + let input = skillet_workstation::configuration_templates::render( + "datadog", + host, + policy.vault_name(), + vault, + )?; + skillet_workstation::credential_delivery::deliver_datadog_credential( + host, policy, &input, transport, + )?; + Ok(()) +} + +fn deliver_ddns_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Result<()> { + let policy = args.environment.policy(); + if policy.environment() != skillet_workstation::provisioning_policy::Environment::Production { + return Err(anyhow!( + "test DDNS must be provisioned through an owned disposable VM" + )); + } + let zone_path = policy.cloudflare_zone_entry(); + let zone_id = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let config = skillet_workstation::configuration_templates::render( + "ddns", + &args.hostname, + policy.vault_name(), + vault, + )?; + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare token creator is missing"))?; + let prefix = vault.get(&policy.ui_domain_entry())?; + let transport = credential_transport(args)?; + let provider = skillet_workstation::cloudflare::Cloudflare::new(); + let mut store = + skillet_workstation::vault::VaultSecretStore::new(vault, args.key_file.as_deref()); + skillet_workstation::ddns_provisioning::deliver_persistent_ddns( + &skillet_workstation::ddns_provisioning::DdnsDelivery { + host: &args.hostname, + policy, + zone_id: zone_id.trim(), + relative_ui_domain: prefix.as_deref(), + config: &config, + creator_token: &creator, + }, + &mut store, + &provider, + &transport, + )?; + Ok(()) +} + +fn deliver_caddy_from_vault(args: &SecretDeliverArgs, vault: &mut Vault) -> Result<()> { + let users = tailnet_ui_users(&args.hostname, vault)?; + let policy = args.environment.policy(); + let domain_path = policy.ui_domain_entry(); + let domain_prefix = vault.get(&domain_path)?; + let zone_path = policy.cloudflare_zone_entry(); + let zone_id = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let zone_id = zone_id.trim().to_string(); + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| { + anyhow!( + "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" + ) + })?; + let tailnet = tailscale_credentials(vault)?; + let transport = credential_transport(args)?; + let cloudflare = skillet_workstation::cloudflare::Cloudflare::new(); + let mut token_store = + skillet_workstation::vault::VaultSecretStore::new(vault, args.key_file.as_deref()); + skillet_workstation::ui_provisioning::deliver_persistent_ui( + &skillet_workstation::ui_provisioning::PersistentUiDelivery { + host: &args.hostname, + policy, + zone_id: &zone_id, + relative_ui_domain: domain_prefix.as_deref(), + tailnet_users: &users, + creator_token: &creator, + }, + &mut token_store, + &cloudflare, + &tailnet, + &transport, + )?; + Ok(()) +} + +pub(super) fn lock_vault(path: Option<&Path>) -> Result<()> { + let path = match path { + Some(path) => path.to_path_buf(), + None => default_database_path()?, + }; + // A symlink at the default XDG location points to the same cache entry as + // an explicit path to its target. + skillet_workstation::vault::lock(Some(&path))?; + println!("Vault unlock removed from the kernel keyring"); + Ok(()) +} + +pub(super) fn check_vault(args: &SecretCheckArgs) -> Result<()> { + let all_profiles = skillet_hosts::profile::declared_profiles(); + let profiles = if let Some(host) = &args.host { + vec![skillet_hosts::profile_for_name(host) + .ok_or_else(|| anyhow!("unknown host profile: {host}"))?] + } else { + all_profiles.clone() + }; + let path = args + .database + .clone() + .map_or_else(default_database_path, Ok)?; + let vault = Vault::open(&path, args.key_file.as_deref())?; + let report = skillet_workstation::secrets::check( + &profiles, + args.environment.policy(), + &|path, field| vault.get_field(path, field), + )?; + let unused = skillet_workstation::secrets::unused_paths(&all_profiles, &vault.entry_paths())?; + let hyperlinks = std::io::IsTerminal::is_terminal(&std::io::stdout()) + && supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout); + print!( + "{}", + crate::secret_output::render(&report, &unused, hyperlinks) + ); + if !report.missing.is_empty() { + return Err(anyhow!( + "required vault entries need attention; see instructions above" + )); + } + Ok(()) +} + +pub(super) fn unlock_vault(path: Option<&Path>, key_file: Option<&Path>) -> Result<()> { + let path = match path { + Some(path) => path.to_path_buf(), + None => default_database_path()?, + }; + let vault = Vault::open(&path, key_file).context("opening KeePassXC database for unlock")?; + if !vault.password_cached() { + return Err(anyhow!( + "KeePassXC password was verified, but the three-hour session cache could not be established; see the keyring warning above" + )); + } + drop(vault); + println!("KeePassXC vault unlocked; password cached for up to three hours"); + Ok(()) +} + +fn tailscale_credentials(vault: &Vault) -> Result { + let client_id = vault + .get("skillet/tailscale/provisioner-client-id")? + .ok_or_else(|| { + anyhow!("KeePassXC entry skillet/tailscale/provisioner-client-id is missing") + })?; + let client_secret = vault + .get("skillet/tailscale/provisioner-client-secret")? + .ok_or_else(|| { + anyhow!("KeePassXC entry skillet/tailscale/provisioner-client-secret is missing") + })?; + Ok(tailscale::OAuthCredentials::new(client_id, client_secret)?) +} + +fn credential_transport(args: &SecretDeliverArgs) -> Result { + let (user, address) = args + .target + .split_once('@') + .filter(|(user, address)| !user.is_empty() && !address.is_empty()) + .ok_or_else(|| anyhow!("SSH target must be USER@HOST"))?; + skillet_vm::transport::SshTransport::new( + skillet_vm::SshTarget { + user: user.to_string(), + address: address.to_string(), + port: args.port, + identity: args.identity.clone(), + known_hosts: args.known_hosts.clone(), + }, + skillet_vm::transport::HostKeyPolicy::Verify, + ) + .context("validating credential delivery SSH target") +} + +fn default_database_path() -> Result { + Ok(Vault::default_path()?) +} + +pub(super) fn provision_vm(args: &VmProvisionArgs) -> Result<()> { + let profile = skillet_hosts::profile_for_name(&args.hostname) + .ok_or_else(|| anyhow!("unknown host profile: {}", args.hostname))?; + if args.smtp_only && !profile.supports_service("smtp") { + return Err(anyhow!("host {} does not declare SMTP", args.hostname)); + } + if !args.smtp_only + && (!profile.supports_service("pihole") || !profile.supports_service("tailscale")) + { + return Err(anyhow!( + "host {} must declare both Pi-hole and Tailscale to use VM application provisioning", + args.hostname + )); + } + if args.with_ddns && !profile.supports_service("ddns") { + return Err(anyhow!("host {} does not declare DDNS", args.hostname)); + } + if args.with_datadog && !profile.supports_service("datadog") { + return Err(anyhow!("host {} does not declare Datadog", args.hostname)); + } + if args.smtp_only && (args.with_ui || args.with_ddns || args.with_datadog || args.rotate) { + return Err(anyhow!( + "--smtp-only cannot be combined with application provisioning switches" + )); + } + let butane = butane_root()?; + let run_identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _lock = store.lock(&run_identity)?; + let run = store.load(&run_identity)?; + if run.environment != Environment::Test || !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(anyhow!("VM must be a started test run before provisioning")); + } + let run_dir = store.run_dir(&run_identity); + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let base_transport = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + let verify_ownership = || verify_running_vm(&backend, &run); + verify_ownership()?; + let transport = OwnershipCheckedTransport::new(&base_transport, &verify_ownership); + if args.smtp_only { + return provision_vm_smtp(args, &transport); + } + validate_vm_tailscale_delivery(&transport)?; + let vault_path = match &args.database { + Some(path) => path.clone(), + None => default_database_path()?, + }; + let mut vault = Vault::open(&vault_path, args.key_file.as_deref())?; + let credentials = tailscale_credentials(&vault)?; + let expected_hostname = run.guest_hostname.as_str(); + let policy = UiEnvironmentName::Test.policy(); + // The host full-apply unit requires both encrypted credentials. Deliver + // Pi-hole first; Tailscale delivery then starts full apply with the full + // credential set, which creates the Tailscale container on a fresh VM. + skillet_workstation::credential_delivery::ensure_disposable_pihole_credential( + &args.hostname, + args.rotate, + &transport, + )?; + let record = skillet_workstation::tailscale_enrollment::enroll_disposable_vm( + &skillet_workstation::tailscale_enrollment::DisposableEnrollment { + host: &args.hostname, + instance: &args.instance, + vm_hostname: expected_hostname, + run_directory: &run_dir, + policy, + }, + &credentials, + &transport, + )?; + + if args.with_ui { + provision_vm_ui(args, &run_dir, &transport, &mut vault, &record)?; + } + if args.with_ddns { + provision_vm_ddns(args, &run_dir, &transport, &mut vault)?; + } + if args.with_datadog { + deliver_datadog_from_vault(&args.hostname, policy, &vault, &transport)?; + } + if profile.supports_service("smtp") { + let input = skillet_workstation::smtp_provisioning::input(policy, &|_, _| { + Err(skillet_workstation::vault::VaultError::Invalid( + "test policy must not read credentials".into(), + )) + })?; + skillet_workstation::smtp_provisioning::deliver(&args.hostname, &input, &transport)?; + } + provisioning_state::remove_tailscale_pending(&run_dir)?; + println!("Tailscale connected VM {expected_hostname}"); + Ok(()) +} + +pub(super) fn remove_vm_external_resources(args: &VmDestroyArgs, run: &VmRun) -> Result<()> { + if run.identity.host() != args.hostname || run.identity.instance() != args.instance { + return Err(anyhow!( + "destroy arguments do not match the recorded VM identity" + )); + } + let butane = butane_root()?; + let run_dir = butane.join("runs").join(run.identity.domain_name()); + let cloudflare_path = run_dir.join("cloudflare.json"); + let ddns_path = run_dir.join("ddns.json"); + let cloudflare_exists = provisioning_state::cloudflare_ownership_exists(&cloudflare_path)?; + let ddns_exists = provisioning_state::ddns_ownership_exists(&ddns_path)?; + let tailscale_pending = provisioning_state::tailscale_pending_exists(&run_dir)?; + let tailscale_record = provisioning_state::tailscale_record_exists(&run_dir)?; + if !tailscale_pending && !tailscale_record && !cloudflare_exists && !ddns_exists { + return Ok(()); + } + if cloudflare_exists { + cleanup_vm_cloudflare(args, &cloudflare_path)?; + } + if ddns_exists { + cleanup_vm_ddns(args, &ddns_path)?; + } + if !provisioning_state::tailscale_pending_exists(&run_dir)? + && !provisioning_state::tailscale_record_exists(&run_dir)? + { + return Ok(()); + } + let policy = UiEnvironmentName::Test.policy(); + let vault_path = match &args.database { + Some(path) => path.clone(), + None => default_database_path()?, + }; + let vault = Vault::open(&vault_path, args.key_file.as_deref())?; + let credentials = tailscale_credentials(&vault)?; + skillet_workstation::tailscale_enrollment::cleanup_disposable_vm( + &skillet_workstation::tailscale_enrollment::DisposableCleanup { + host: &args.hostname, + instance: &args.instance, + vm_hostname: &run.identity.domain_name(), + run_directory: &run_dir, + policy, + }, + &credentials, + )?; + println!( + "Cleaned Tailscale ownership for {}", + run.identity.domain_name() + ); + Ok(()) +} + +fn provision_vm_ui( + args: &VmProvisionArgs, + run_dir: &Path, + guest: &impl GuestTransport, + vault: &mut Vault, + device: &tailscale::DeviceRecord, +) -> Result<()> { + let users = tailnet_ui_users(&args.hostname, vault)?; + let policy = UiEnvironmentName::Test.policy(); + let domain_path = policy.ui_domain_entry(); + let zone_path = policy.cloudflare_zone_entry(); + let domain_prefix = vault.get(&domain_path)?; + let zone_id = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| { + anyhow!( + "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" + ) + })?; + let zone_id = zone_id.trim().to_string(); + let api = skillet_workstation::cloudflare::Cloudflare::new(); + let provisioned = skillet_workstation::ui_provisioning::provision_disposable_ui( + &skillet_workstation::ui_provisioning::DisposableUiRequest { + host: &args.hostname, + instance: &args.instance, + policy, + zone_id: &zone_id, + relative_ui_domain: domain_prefix.as_deref(), + tailnet_users: &users, + creator_token: &creator, + run_directory: run_dir, + addresses: &device.addresses, + }, + &api, + || vault.ensure_unchanged(), + )?; + skillet_workstation::ui_provisioning::deliver_disposable_ui( + &args.hostname, + &provisioned, + &creator, + &api, + guest, + std::time::Duration::from_mins(1), + std::time::Duration::from_secs(2), + )?; + Ok(()) +} + +fn provision_vm_ddns( + args: &VmProvisionArgs, + run_dir: &Path, + guest: &impl GuestTransport, + vault: &mut Vault, +) -> Result<()> { + let policy = UiEnvironmentName::Test.policy(); + let zone_path = policy.cloudflare_zone_entry(); + let zone_id = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let config = skillet_workstation::configuration_templates::render( + "ddns", + &args.hostname, + policy.vault_name(), + vault, + )?; + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare token creator is missing"))?; + let prefix = vault.get(&policy.ui_domain_entry())?; + let api = skillet_workstation::cloudflare::Cloudflare::new(); + skillet_workstation::ddns_provisioning::provision_disposable_ddns( + &skillet_workstation::ddns_provisioning::DisposableDdnsRequest { + host: &args.hostname, + instance: &args.instance, + policy, + zone_id: zone_id.trim(), + relative_ui_domain: prefix.as_deref(), + config: &config, + creator_token: &creator, + run_directory: run_dir, + }, + &api, + guest, + std::time::Duration::from_mins(5), + std::time::Duration::from_secs(5), + )?; + Ok(()) +} + +fn cleanup_vm_ddns(args: &VmDestroyArgs, metadata_path: &Path) -> Result<()> { + let policy = UiEnvironmentName::Test.policy(); + let vault_path = args + .database + .clone() + .map_or_else(default_database_path, Ok)?; + let vault = Vault::open(&vault_path, args.key_file.as_deref())?; + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare token creator is missing"))?; + let zone_path = policy.cloudflare_zone_entry(); + let zone_id = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let api = skillet_workstation::cloudflare::Cloudflare::new(); + skillet_workstation::ddns_provisioning::cleanup_disposable_ddns( + &skillet_workstation::ddns_provisioning::DisposableDdnsCleanup { + host: &args.hostname, + instance: &args.instance, + policy, + ownership_path: metadata_path, + configured_zone_id: zone_id.trim(), + creator_token: &creator, + }, + &api, + )?; + Ok(()) +} + +fn cleanup_vm_cloudflare(args: &VmDestroyArgs, metadata_path: &Path) -> Result<()> { + let policy = UiEnvironmentName::Test.policy(); + let vault_path = args + .database + .clone() + .map_or_else(default_database_path, Ok)?; + let vault = Vault::open(&vault_path, args.key_file.as_deref())?; + let creator = vault + .get("skillet/cloudflare/token-creator")? + .ok_or_else(|| { + anyhow!( + "KeePassXC Cloudflare token creator is missing: skillet/cloudflare/token-creator" + ) + })?; + let zone_path = policy.cloudflare_zone_entry(); + let configured_zone = vault + .get(&zone_path)? + .ok_or_else(|| anyhow!("KeePassXC Cloudflare zone entry is missing: {zone_path}"))?; + let api = skillet_workstation::cloudflare::Cloudflare::new(); + let configured_prefix = vault.get(&policy.ui_domain_entry())?; + skillet_workstation::ui_provisioning::cleanup_disposable_ui( + &skillet_workstation::ui_provisioning::DisposableUiCleanupRequest { + host: &args.hostname, + instance: &args.instance, + policy, + ownership_path: metadata_path.to_path_buf(), + configured_zone_id: &configured_zone, + relative_ui_domain: configured_prefix.as_deref(), + creator_token: &creator, + }, + &api, + )?; + Ok(()) +} + +fn verify_running_vm(backend: &impl VmBackend, run: &VmRun) -> skillet_vm::Result<()> { + let domain = backend + .inspect(run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned VM domain is absent".into()))?; + domain.validate_owned(run)?; + if domain.state != "running" { + return Err(skillet_vm::Error::Invalid(format!( + "VM is not running (libvirt state: {})", + domain.state + ))); + } + Ok(()) +} + +fn validate_vm_tailscale_delivery(guest: &impl GuestTransport) -> Result<()> { + let output = guest + .execute( + &GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "systemctl", "cat", "skillet-full-apply.service"], + }, + None, + ) + .context("checking the VM's full-apply credential configuration")?; + if !output.status.success() { + return Err(anyhow!( + "could not read skillet-full-apply.service from the VM" + )); + } + validate_tailscale_unit_config(&String::from_utf8_lossy(&output.stdout)) +} + +fn validate_tailscale_unit_config(contents: &str) -> Result<()> { + if !contents.lines().any(|line| { + line.trim() + == "LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred" + }) { + return Err(anyhow!( + "this VM was created from an older Butane config that does not pass the Tailscale credential to full apply; recreate the smoke VM with the current config, then retry provisioning" + )); + } + Ok(()) +} + +fn provision_vm_smtp(args: &VmProvisionArgs, transport: &impl GuestTransport) -> Result<()> { + let input = if args.live_smtp { + let vault_path = args + .database + .clone() + .map_or_else(default_database_path, Ok)?; + let vault = Vault::open(&vault_path, args.key_file.as_deref())?; + skillet_workstation::smtp_provisioning::input( + UiEnvironmentName::Production.policy(), + &|path, field| vault.get_field(path, field), + )? + } else { + skillet_workstation::smtp_provisioning::input(UiEnvironmentName::Test.policy(), &|_, _| { + Err(skillet_workstation::vault::VaultError::Invalid( + "test policy must not read credentials".into(), + )) + })? + }; + skillet_workstation::smtp_provisioning::deliver(&args.hostname, &input, transport)?; + if args.live_smtp { + println!("Configured live SMTP provider; submission remains explicit and loopback-only"); + } else { + println!("Configured isolated SMTP capture relay; start a loopback sink on port 1025"); + } + Ok(()) +} + +#[cfg(test)] +#[path = "secret_delivery/tests.rs"] +mod tests; + +fn tailnet_ui_users(host: &str, vault: &Vault) -> Result> { + let profile = + skillet_hosts::profile_for_name(host).ok_or_else(|| anyhow!("unknown host profile"))?; + if !profile.ui_services().iter().any(|ui| ui.tailnet_identity) { + return Ok(Vec::new()); + } + let users = vault.get(skillet_caddy::auth::USERS_ENTRY)?.ok_or_else(|| + anyhow!("KeePassXC entry is missing: {}; put full LoginName values in Password, separated by commas or newlines", skillet_caddy::auth::USERS_ENTRY))?; + Ok(skillet_caddy::auth::Policy::parse_users(&users)?) +} diff --git a/ublue/skillet/crates/cli/src/secret_delivery/tests.rs b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs new file mode 100644 index 00000000..3050b07f --- /dev/null +++ b/ublue/skillet/crates/cli/src/secret_delivery/tests.rs @@ -0,0 +1,205 @@ +use super::{validate_delivery_service, validate_tailscale_unit_config}; +use clap::Parser; + +#[test] +fn plural_secrets_commands_parse_and_unknown_check_host_fails_before_unlock() { + for command in ["unlock", "lock", "check"] { + assert!(crate::Args::try_parse_from(["skillet", "secrets", command]).is_ok()); + assert!(crate::Args::try_parse_from(["skillet", "secret", command]).is_ok()); + } + let parsed = crate::Args::try_parse_from([ + "skillet", + "secrets", + "check", + "--host", + "unknown", + "--environment", + "test", + "--database", + "/nonexistent/secrets.kdbx", + "--key-file", + "/nonexistent/key", + ]) + .unwrap(); + let crate::Commands::Secret { + command: crate::SecretCommands::Check(args), + } = parsed.command + else { + panic!("wrong command") + }; + assert!(super::check_vault(&args) + .unwrap_err() + .to_string() + .contains("unknown host profile")); +} + +#[test] +fn ddns_commands_parse_and_test_delivery_refuses_before_vault_access() { + let parsed = crate::Args::try_parse_from([ + "skillet", + "secret", + "deliver", + "clamps", + "ddns", + "--environment", + "test", + "--database", + "/nonexistent/skillet-test.kdbx", + "--target", + "user@localhost", + "--identity", + "/nonexistent/key", + "--known-hosts", + "/nonexistent/known_hosts", + ]) + .unwrap(); + let crate::Commands::Secret { + command: crate::SecretCommands::Deliver(args), + } = parsed.command + else { + panic!("wrong parsed command"); + }; + let error = super::deliver_from_vault(&args).unwrap_err(); + assert!(error + .to_string() + .contains("test DDNS must be provisioned through an owned disposable VM")); + assert!(crate::Args::try_parse_from([ + "skillet", "apply", "--host", "clamps", "--phase", "ddns" + ]) + .is_ok()); +} + +#[test] +fn rejects_vm_without_tailscale_systemd_credential() { + let old_unit = "LoadCredentialEncrypted=pihole_web_password:/etc/credstore.encrypted/skillet/pihole_web_password.cred"; + let error = validate_tailscale_unit_config(old_unit).unwrap_err(); + assert!(error.to_string().contains("recreate the smoke VM")); + + let current_unit = "LoadCredentialEncrypted=tailscale_auth_key:/etc/credstore.encrypted/skillet/tailscale_auth_key.cred"; + assert!(validate_tailscale_unit_config(current_unit).is_ok()); +} + +#[test] +fn delivery_eligibility_uses_declared_service_capabilities() { + assert!(validate_delivery_service("agent", "datadog").is_err()); + assert!(validate_delivery_service("clamps", "datadog").is_ok()); + assert!(validate_delivery_service("agent", "pihole").is_err()); + assert!(validate_delivery_service("agent", "tailscale").is_err()); + assert!(validate_delivery_service("joey", "caddy").is_ok()); + assert!(validate_delivery_service("agent", "ddns").is_err()); + assert!(validate_delivery_service("clamps", "ddns").is_ok()); + for service in ["pihole", "tailscale", "caddy", "ddns", "datadog", "smtp"] { + assert!(validate_delivery_service("joey", service).is_ok()); + } + assert!(validate_delivery_service("missing-host", "caddy").is_err()); + assert!(validate_delivery_service("clamps", "unknown").is_err()); +} + +#[test] +fn datadog_commands_parse_and_test_delivery_refuses_before_vault_access() { + assert!(crate::Args::try_parse_from([ + "skillet", "apply", "--host", "clamps", "--phase", "datadog" + ]) + .is_ok()); + assert!(crate::Args::try_parse_from([ + "skillet", + "test", + "vm", + "provision", + "clamps", + "monitoring", + "--with-datadog" + ]) + .is_ok()); + let parsed = crate::Args::try_parse_from([ + "skillet", + "secret", + "deliver", + "clamps", + "datadog", + "--environment", + "test", + "--database", + "/nonexistent/skillet-test.kdbx", + "--target", + "user@localhost", + "--identity", + "/nonexistent/key", + "--known-hosts", + "/nonexistent/known_hosts", + ]) + .unwrap(); + let crate::Commands::Secret { + command: crate::SecretCommands::Deliver(args), + } = parsed.command + else { + panic!("wrong parsed command"); + }; + assert!(super::deliver_from_vault(&args) + .unwrap_err() + .to_string() + .contains("test Datadog must be provisioned through an owned disposable VM")); +} + +#[test] +fn smtp_delivery_and_capture_only_cli_are_supported() { + use clap::Parser; + assert!(super::super::Args::try_parse_from([ + "skillet", + "secrets", + "deliver", + "clamps", + "smtp", + "--target", + "root@example.invalid", + "--identity", + "/tmp/key", + "--known-hosts", + "/tmp/hosts" + ]) + .is_ok()); + assert!(super::super::Args::try_parse_from([ + "skillet", + "test", + "vm", + "provision", + "joey", + "smtp", + "--smtp-only" + ]) + .is_ok()); + assert!(super::validate_delivery_service("agent", "smtp").is_err()); + assert!(super::validate_delivery_service("joey", "smtp").is_ok()); +} + +#[test] +fn live_smtp_requires_explicit_smtp_only_selection() { + use clap::Parser; + let base = ["skillet", "test", "vm", "provision", "joey", "mailjet"]; + assert!(super::super::Args::try_parse_from(base.into_iter().chain(["--live-smtp"])).is_err()); + let parsed = + super::super::Args::try_parse_from(base.into_iter().chain(["--smtp-only", "--live-smtp"])) + .unwrap(); + let crate::Commands::Test { + command: + crate::TestCommands::Vm { + command: crate::VmCommands::Provision(args), + }, + } = parsed.command + else { + panic!("wrong parsed command"); + }; + assert!(args.smtp_only && args.live_smtp); + let parsed = + super::super::Args::try_parse_from(base.into_iter().chain(["--smtp-only"])).unwrap(); + let crate::Commands::Test { + command: + crate::TestCommands::Vm { + command: crate::VmCommands::Provision(args), + }, + } = parsed.command + else { + panic!("wrong parsed command"); + }; + assert!(!args.live_smtp); +} diff --git a/ublue/skillet/crates/cli/src/secret_output.rs b/ublue/skillet/crates/cli/src/secret_output.rs new file mode 100644 index 00000000..b555b10c --- /dev/null +++ b/ublue/skillet/crates/cli/src/secret_output.rs @@ -0,0 +1,100 @@ +//! Terminal presentation of public secret setup instructions. +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use skillet_workstation::secrets::CheckReport; +use std::fmt::Write as _; + +// Parse shared documentation using CommonMark, preserving destinations in +// plain output and emitting OSC 8 only for supported web links. +fn terminal_guide(guide: &str, hyperlinks: bool) -> String { + let mut output = String::new(); + let mut link = None; + for event in Parser::new(guide) { + match event { + Event::Start(Tag::Link { dest_url, .. }) => { + let clickable = hyperlinks + && (dest_url.starts_with("https://") || dest_url.starts_with("http://")); + if clickable { + let _ = write!(output, "{}", osc8::Hyperlink::new(&dest_url)); + } + link = Some((dest_url, clickable)); + } + Event::End(TagEnd::Link) => { + if let Some((destination, clickable)) = link.take() { + if clickable { + let _ = write!(output, "{}", osc8::Hyperlink::END); + } else { + let _ = write!(output, ": {destination}"); + } + } + } + Event::Text(text) | Event::Code(text) => output.push_str(&text), + Event::SoftBreak | Event::HardBreak | Event::End(TagEnd::Paragraph) => output.push(' '), + _ => {} + } + } + output.trim().to_string() +} + +fn write_guide(output: &mut String, guide: &str, hyperlinks: bool) { + let guide = terminal_guide(guide, hyperlinks); + // Let the terminal wrap hyperlinks without counting invisible escapes or + // splitting link control sequences. Plain output is explicitly wrapped. + if hyperlinks { + let _ = writeln!(output, " {guide}"); + return; + } + let mut column = 6; + output.push_str(" "); + for word in guide.split_whitespace() { + let length = word.chars().count(); + if column > 6 { + if column + 1 + length > 88 { + output.push_str("\n "); + column = 6; + } else { + output.push(' '); + column += 1; + } + } + output.push_str(word); + column += length; + } + output.push('\n'); +} + +pub(super) fn render(report: &CheckReport, unused: &[String], hyperlinks: bool) -> String { + let mut output = String::new(); + if !report.missing.is_empty() { + output.push_str("Required entries needing attention\n"); + let mut previous_module = ""; + for entry in &report.missing { + if previous_module != entry.module { + let _ = write!(output, "\n{}\n", entry.module); + previous_module = &entry.module; + } + let state = if entry.invalid { + "INVALID (fields or ambiguous entry)" + } else { + "MISSING" + }; + let _ = writeln!(output, " {state}: {}", entry.path); + output.push_str(" Setup:\n"); + write_guide(&mut output, &entry.guide, hyperlinks); + output.push('\n'); + } + } + if !unused.is_empty() { + output.push_str("\nUnused Skillet entries\n Scope: all declared hosts, prod and test\n Review these entries; nothing has been deleted.\n"); + for path in unused { + let _ = writeln!(output, " {path}"); + } + } + let _ = writeln!( + output, + "\nSummary: {} required checked, {} missing/invalid, {} unused.", + report.checked, + report.missing.len(), + unused.len() + ); + output +} diff --git a/ublue/skillet/crates/cli/src/smoke_artifact_tests.rs b/ublue/skillet/crates/cli/src/smoke_artifact_tests.rs new file mode 100644 index 00000000..679b28cf --- /dev/null +++ b/ublue/skillet/crates/cli/src/smoke_artifact_tests.rs @@ -0,0 +1,17 @@ +use super::artifact_target_profile; + +#[test] +fn smoke_artifact_selection_preserves_target_and_profile() { + let (target, profile) = artifact_target_profile(std::path::Path::new( + "/tmp/cargo-target/aarch64-unknown-linux-musl/release/skillet", + )) + .unwrap(); + assert_eq!(target, "aarch64-unknown-linux-musl"); + assert_eq!(profile, "release"); + let (target, profile) = artifact_target_profile(std::path::Path::new( + "/tmp/cargo-target/x86_64-unknown-linux-musl/debug/skillet", + )) + .unwrap(); + assert_eq!(target, "x86_64-unknown-linux-musl"); + assert_eq!(profile, "dev"); +} diff --git a/ublue/skillet/crates/cli/src/test_entrypoint.sh b/ublue/skillet/crates/cli/src/test_entrypoint.sh new file mode 100644 index 00000000..e7905565 --- /dev/null +++ b/ublue/skillet/crates/cli/src/test_entrypoint.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu +round=$1 +phase=$2 +mock=/tmp/skillet-test-mocks +state=/tmp/skillet-test-state +mkdir -p "$mock" "$state" +if [ ! -x "$mock/systemctl" ]; then + cat >"$mock/systemctl" <<'MOCK' +#!/bin/sh +set -eu +printf '%s\n' "$*" >> /tmp/skillet-test.systemctl.log +case "$1" in + is-active) + if [ -f "/tmp/skillet-test-state/active-$3" ]; then exit 0; else exit 3; fi + ;; + start|restart) touch "/tmp/skillet-test-state/active-$2" ;; + enable|daemon-reload|stop|reload) exit 0 ;; + *) exit 2 ;; +esac +MOCK + cat >"$mock/podman" <<'MOCK' +#!/bin/sh +set -eu +printf '%s\n' "$*" >> /tmp/skillet-test.podman.log +case "$1 $2" in + "secret exists") name=$3; [ -f "/tmp/skillet-test-state/secret-$name" ] ;; + "secret inspect") + for name do :; done + case "$*" in *'{{.ID}}'*) printf 'skillet-test-%s-id\n' "$name" ;; *) cat "/tmp/skillet-test-state/secret-hash-$name" ;; esac + ;; + "secret create") + shift 2 + hash= + while [ "$#" -gt 0 ]; do + case "$1" in --replace) shift ;; --label) hash=${2#skillet.payload_hash=}; shift 2 ;; *) break ;; esac + done + name=$1 + cat > "/tmp/skillet-test-state/secret-payload-$name" + printf '%s\n' "$hash" > "/tmp/skillet-test-state/secret-hash-$name" + touch "/tmp/skillet-test-state/secret-$name" + ;; + *) exit 2 ;; +esac +MOCK + chmod +x "$mock/systemctl" "$mock/podman" +fi +export PATH="$mock:$PATH" +if [ "$round" = second ]; then printf '\nSECOND\n' >> /tmp/skillet-test.systemctl.log; fi +/usr/bin/skillet apply --phase "$phase" diff --git a/ublue/skillet/crates/cli/src/vm.rs b/ublue/skillet/crates/cli/src/vm.rs new file mode 100644 index 00000000..04994e05 --- /dev/null +++ b/ublue/skillet/crates/cli/src/vm.rs @@ -0,0 +1,691 @@ +//! CLI presentation and legacy provider wiring. Ownership lives in `skillet_vm`. +use super::{ + butane_root, secret_delivery, workspace_root, VmCreateArgs, VmDestroyArgs, VmDirectoryArgs, + VmListArgs, VmSshArgs, VmTargetArgs, +}; +use anyhow::{anyhow, Context, Result}; +use skillet_vm::{ + backend::{DomainSnapshot, FlatpakVirtInstall, VirshBackend, VmBackend}, + transport::{GuestCommand, GuestTransport, HostKeyPolicy, SshTransport}, + ManifestStore, Phase, RunIdentity, VmRun, +}; +use std::io::Write as _; +use std::process::Command; + +pub(super) fn status(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load(&identity)?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let domain = backend.inspect(&run)?.ok_or_else(|| { + anyhow!( + "owned domain is absent; recovery metadata remains at {}", + store.run_dir(&identity).display() + ) + })?; + domain.validate_owned(&run)?; + println!( + "Name: {}\nUUID: {}\nState: {}\nLifecycle: {:?}\nSSH: {}@{}:{}", + domain.name, + domain.uuid, + domain.state, + run.phase, + run.ssh.user, + run.ssh.address, + run.ssh.port + ); + for disk in domain.disks { + println!("Disk: {}", disk.display()); + } + Ok(()) +} + +pub(super) fn create(args: &VmCreateArgs) -> Result<()> { + let butane = butane_root()?; + let workspace = workspace_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + if !butane.join(format!("{}.bu", identity.host())).is_file() + || !workspace + .join("crates/hosts") + .join(identity.host()) + .join("Cargo.toml") + .is_file() + { + return Err(anyhow!( + "host {} needs both its Butane configuration and host crate", + identity.host() + )); + } + let source_commit = skillet_vm::provisioning::source_revision(&workspace)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + prepare_run_intent(args, &butane, &store, &identity, &source_commit)?; + let _create_lock = store.lock_create(&identity)?; + let run = store.load(&identity)?; + if matches!(run.phase, Phase::Preparing | Phase::Defined) && run.source_commit != source_commit + { + return Err(anyhow!( + "unfinished VM was prepared from a different source revision; inspect or dispose it before retrying" + )); + } + skillet_vm::runtime::validate_kvm_access()?; + if run.phase == Phase::Preparing { + skillet_vm::provisioning::validate_ssh_port(run.ssh.port)?; + } + + if run.phase == Phase::Preparing { + prepare_guest_artifacts( + &butane, + &workspace, + &store, + &identity, + args.image.as_deref(), + )?; + } else if args.image.is_some() { + return Err(anyhow!( + "--image can only be selected for a new or preparing VM" + )); + } + + create_recorded_domain(&butane, &store, &identity)?; + if let Err(error) = ready(&VmTargetArgs { + hostname: args.hostname.clone(), + instance: args.instance.clone(), + }) { + return Err(anyhow!( + "VM was started but readiness failed; inspect it with `test-vm {} logs {}` or destroy it with `skillet test vm destroy {} {}`: {error}", + args.hostname, args.instance, args.hostname, args.instance + )); + } + println!( + "Ready: {} at giacomo@127.0.0.1:{}; SSH key: {}", + identity.domain_name(), + args.port, + run.ssh.identity.display() + ); + if run.root_profile == skillet_vm::install::RootProfile::Tpm { + println!( + "Recovery key backup pending. Run: skillet test vm recovery-key {} {}", + identity.host(), + args.instance + ); + } + Ok(()) +} + +fn prepare_run_intent( + args: &VmCreateArgs, + butane: &std::path::Path, + store: &ManifestStore, + identity: &RunIdentity, + source_commit: &str, +) -> Result { + let run_dir = store.run_dir(identity); + if run_dir.exists() { + let run = store.load(identity)?; + if run.root_profile != args.root_profile.policy() { + return Err(anyhow!( + "recorded root profile differs; use another instance or dispose this run" + )); + } + if run.ssh.port != args.port { + return Err(anyhow!( + "recorded VM uses SSH port {}; requested port is {}", + run.ssh.port, + args.port + )); + } + if matches!(run.phase, Phase::Preparing | Phase::Defined) + && run.source_commit != source_commit + { + return Err(anyhow!( + "unfinished VM was prepared from a different source revision; inspect or dispose it before retrying" + )); + } + skillet_vm::runtime::ensure_connection(butane, current_uid(), &run.connection)?; + Ok(run) + } else { + skillet_vm::provisioning::validate_ssh_port(args.port)?; + let runtime = skillet_vm::runtime::prepare(butane, current_uid())?; + let backend = VirshBackend::new( + runtime.connection.clone(), + current_uid(), + &runtime.virsh_wrapper, + skillet_vm::backend::ProcessExecutor::default(), + )?; + if backend.contains_name(&identity.domain_name())? { + return Err(anyhow!( + "refusing unrecorded existing libvirt domain {}", + identity.domain_name() + )); + } + if args.root_profile.policy() == skillet_vm::install::RootProfile::Tpm { + backend.verify_tpm_support()?; + } + store + .prepare_intent_with_profile( + identity, + runtime.connection, + args.port, + source_commit, + args.root_profile.policy(), + ) + .map_err(Into::into) + } +} + +fn prepare_guest_artifacts( + butane: &std::path::Path, + workspace: &std::path::Path, + store: &ManifestStore, + identity: &RunIdentity, + requested_image: Option<&std::path::Path>, +) -> Result<()> { + let host_package = format!("skillet-{}", identity.host()); + let packages = ["skillet", host_package.as_str()]; + let artifacts = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace, + packages: &packages, + target: "x86_64-unknown-linux-musl", + profile: "release", + })?; + let host_binary = artifacts + .get(&host_package) + .ok_or_else(|| anyhow!("Cargo did not report {host_package}"))?; + let generic_binary = artifacts + .get("skillet") + .ok_or_else(|| anyhow!("Cargo did not report skillet"))?; + let image = + skillet_vm::provisioning::resolve_coreos_image(&butane.join("images"), requested_image)?; + skillet_vm::provisioning::prepare_local_artifacts(store, identity, &image)?; + let run = store.load(identity)?; + skillet_vm::staging::stage_butane_source( + store, + &run, + &butane.join(format!("{}.bu", identity.host())), + &butane.join("includes"), + host_binary, + generic_binary, + &image, + )?; + compile_butane(butane, &store.run_dir(identity), identity) +} + +fn compile_butane( + butane: &std::path::Path, + run_dir: &std::path::Path, + identity: &RunIdentity, +) -> Result<()> { + let compiler = butane.join("bin/butane"); + let source = run_dir + .join("source") + .join(format!("{}.bu", identity.host())); + let output_dir = run_dir.join("ignition"); + let mut command = Command::new(&compiler); + command + .args(["-s", "-o"]) + .arg(&output_dir) + .arg(source) + .arg(format!("{}.ign", identity.host())); + let output = skillet_vm::capture_command(command, std::time::Duration::from_mins(30)) + .with_context(|| { + format!( + "starting the Butane compiler at {} failed", + compiler.display() + ) + })?; + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + if !output.status.success() { + return Err(anyhow!( + "Butane compilation failed with status {:?}", + output.status.code() + )); + } + let run_ignition = run_dir + .join("ignition") + .join(format!("{}.ign", identity.host())); + if !run_ignition.is_file() { + return Err(anyhow!("Butane did not produce {}", run_ignition.display())); + } + Ok(()) +} + +fn create_recorded_domain( + butane: &std::path::Path, + store: &ManifestStore, + identity: &RunIdentity, +) -> Result { + let run = store.load(identity)?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let podman_version = skillet_vm::capture_version("podman")?; + let yq_version = skillet_vm::capture_version("yq")?; + match run.connection.backend { + skillet_vm::Backend::Native => { + let emulator = backend.x86_64_emulator(&run)?; + let versions = format!( + "Native VM launcher: virsh XML\n{}{}{}", + backend.version(&run)?, + podman_version, + yq_version + ); + skillet_vm::creation::create_libvirt(store, identity, &backend, &emulator, &versions) + .map_err(Into::into) + } + skillet_vm::Backend::Flatpak + if run.root_profile == skillet_vm::install::RootProfile::Tpm => + { + backend.verify_tpm_support()?; + skillet_vm::creation::create_libvirt( + store, + identity, + &backend, + std::path::Path::new("/usr/bin/true"), + &backend.version(&run)?, + ) + .map_err(Into::into) + } + skillet_vm::Backend::Flatpak => { + let creator = FlatpakVirtInstall::for_run(&run, &butane.join("bin/virt-install"))?; + let versions = format!( + "Flatpak VM launcher: virt-install\n{}{}{}{}", + creator.version()?, + backend.version(&run)?, + podman_version, + yq_version + ); + skillet_vm::creation::create_flatpak(store, identity, &backend, &versions, |run| { + creator.define(run) + }) + .map_err(Into::into) + } + } +} + +pub(super) fn reboot(args: &VmTargetArgs) -> Result<()> { + let (butane, store, identity) = context(args)?; + let _lock = store.lock(&identity)?; + let mut run = store.load(&identity)?; + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(anyhow!("VM must be started or ready before reboot")); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + require_owned_domain(&backend, &run)?; + run = store.mark_reboot_pending(&identity)?; + backend.reboot(&run)?; + require_owned_domain(&backend, &run)?; + println!("Reboot requested for {}", identity.domain_name()); + Ok(()) +} + +pub(super) fn ssh(args: &VmSshArgs) -> Result<()> { + let (butane, store, identity) = context(&args.target)?; + let _lock = store.lock(&identity)?; + let run = store.load(&identity)?; + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(anyhow!("VM must be started or ready before opening SSH")); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + require_running_domain(&backend, &run)?; + let transport = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Enroll)?; + let status = transport.interactive_with_forwards(&args.forward)?; + require_owned_domain(&backend, &run)?; + if !status.success() { + return Err(anyhow!( + "interactive SSH exited with status {:?}", + status.code() + )); + } + Ok(()) +} + +pub(super) fn logs(args: &VmTargetArgs) -> Result<()> { + let (butane, store, identity) = context(args)?; + let _lock = store.lock(&identity)?; + let run = store.load(&identity)?; + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(anyhow!("VM must be started or ready before reading logs")); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + require_running_domain(&backend, &run)?; + let transport = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Enroll)?; + for (description, command) in [ + ( + "deployment status", + GuestCommand { + program: "sudo", + arguments: &["-n", "rpm-ostree", "status"], + }, + ), + ( + "bootstrap and base-apply journal", + GuestCommand { + program: "sudo", + arguments: &[ + "-n", + "journalctl", + "-b", + "-u", + "ucore-bootstrap.service", + "-u", + "skillet-apply.service", + "--no-pager", + "-n", + "200", + ], + }, + ), + ] { + print_vm_diagnostic(&backend, &run, &transport, &command, description)?; + } + + if let Some(profile) = skillet_hosts::profile_for_name(identity.host()) { + let plan = profile.acceptance_plan(); + let credential_consumers = profile.credential_consumers(); + for service in plan + .services + .iter() + .filter(|service| service.container.is_some()) + .filter(|service| { + !credential_consumers + .iter() + .any(|consumer| consumer.unit == service.unit) + }) + { + let arguments = [ + "-n", + "journalctl", + "-b", + "-u", + service.unit.as_str(), + "--no-pager", + "-n", + "120", + ]; + let command = GuestCommand { + program: "sudo", + arguments: &arguments, + }; + print_vm_diagnostic( + &backend, + &run, + &transport, + &command, + &format!("{} journal", service.unit), + )?; + } + } + + let command = GuestCommand { + program: "sudo", + arguments: &[ + "-n", + "podman", + "ps", + "-a", + "--format", + "{{.Names}}|{{.Status}}|{{.Image}}", + ], + }; + print_vm_diagnostic(&backend, &run, &transport, &command, "Podman containers")?; + Ok(()) +} + +fn print_vm_diagnostic( + backend: &VirshBackend, + run: &VmRun, + transport: &impl GuestTransport, + command: &GuestCommand<'_>, + description: &str, +) -> Result<()> { + require_running_domain(backend, run)?; + let output = transport.execute(command, None)?; + require_owned_domain(backend, run)?; + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + if !output.status.success() { + return Err(anyhow!( + "guest diagnostic {description} failed with status {:?}", + output.status.code() + )); + } + Ok(()) +} + +fn context(args: &VmTargetArgs) -> Result<(std::path::PathBuf, ManifestStore, RunIdentity)> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + Ok((butane, store, identity)) +} + +fn require_owned_domain(backend: &impl VmBackend, run: &VmRun) -> Result { + let domain = backend + .inspect(run)? + .ok_or_else(|| anyhow!("owned VM domain is absent"))?; + domain.validate_owned(run)?; + Ok(domain) +} + +fn require_running_domain(backend: &impl VmBackend, run: &VmRun) -> Result { + let domain = require_owned_domain(backend, run)?; + if domain.state != "running" { + return Err(anyhow!( + "VM is not running (libvirt state: {})", + domain.state + )); + } + Ok(domain) +} + +pub(super) fn destroy(args: &VmDestroyArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load(&identity)?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + skillet_vm::lifecycle::destroy(&store, &identity, &backend, |run| { + secret_delivery::remove_vm_external_resources(args, run) + .map_err(|error| skillet_vm::Error::ExternalCleanup(error.to_string())) + })?; + println!("Disposed {}", identity.domain_name()); + Ok(()) +} + +fn current_uid() -> u32 { + skillet_vm::current_uid() +} + +pub(super) fn ready_directory(args: &VmDirectoryArgs) -> Result<()> { + let butane = butane_root()?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let run = store.load_directory(&args.run_dir)?; + ready(&VmTargetArgs { + hostname: run.identity.host().into(), + instance: run.identity.instance().into(), + }) +} + +pub(super) fn ready(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let _lock = store.lock(&identity)?; + ready_locked(&butane, &store, &identity) +} + +/// Run readiness while the caller holds the run lock. Smoke uses this after +/// reboot so it can keep the same lock across the complete lifecycle. +pub(super) fn ready_locked( + butane: &std::path::Path, + store: &ManifestStore, + identity: &RunIdentity, +) -> Result<()> { + let run = store.load(identity)?; + let boot = skillet_hosts::boot_policy_for_host(identity.host()) + .ok_or_else(|| anyhow!("unknown readiness profile: {}", identity.host()))?; + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("owned domain is absent"))? + .validate_owned(&run)?; + let mut run = store.import(identity)?; + let ownership_run = run.clone(); + let mut probe = skillet_vm::transport::SshTransport::new( + run.ssh.clone(), + skillet_vm::transport::HostKeyPolicy::Enroll, + )?; + probe.timeout = std::time::Duration::from_secs(15); + let operations = skillet_vm::transport::SshTransport::new( + run.ssh.clone(), + skillet_vm::transport::HostKeyPolicy::Enroll, + )?; + let policy = skillet_vm::readiness::ReadinessPolicy { + signed_image: boot.signed_image, + resolver_target: "/run/NetworkManager/resolv.conf".into(), + masked_units: boot.masked_units.into_iter().map(String::from).collect(), + phase_timeout: std::time::Duration::from_mins(45), + }; + skillet_vm::readiness::ready( + &mut run, + store, + &store.run_dir(identity), + &policy, + &skillet_vm::readiness::ReadinessIo { + probe: &probe, + operations: &operations, + ownership: &|| { + backend + .inspect(&ownership_run)? + .ok_or_else(|| { + skillet_vm::Error::Invalid( + "owned domain disappeared during readiness".into(), + ) + })? + .validate_owned(&ownership_run) + }, + }, + &skillet_vm::readiness::MonotonicClock::default(), + &mut |message| tracing::info!("{message}"), + )?; + println!( + "Ready: {} on {}; SSH: {}@{}:{}", + identity.domain_name(), + run.connection.uri, + run.ssh.user, + run.ssh.address, + run.ssh.port + ); + Ok(()) +} + +pub(super) fn update(args: &VmDestroyArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let _lock = store.lock(&identity)?; + let mut run = store.load(&identity)?; + if !matches!( + run.phase, + skillet_vm::Phase::Started | skillet_vm::Phase::Ready + ) { + return Err(anyhow!( + "finish VM creation or disposal before updating its binaries" + )); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("owned domain is absent"))? + .validate_owned(&run)?; + let root = workspace_root()?; + let package = format!("skillet-{}", identity.host()); + let artifacts = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace: &root, + packages: &["skillet", &package], + target: "x86_64-unknown-linux-musl", + profile: "release", + })?; + let host = artifacts + .get(&package) + .ok_or_else(|| anyhow!("Cargo did not report {package}"))?; + let generic = artifacts + .get("skillet") + .ok_or_else(|| anyhow!("Cargo did not report skillet"))?; + let transport = skillet_vm::transport::SshTransport::new( + run.ssh.clone(), + skillet_vm::transport::HostKeyPolicy::Verify, + )?; + run = store.import(&identity)?; + let ownership_run = run.clone(); + let ownership = || -> skillet_vm::Result<()> { + backend + .inspect(&ownership_run)? + .ok_or_else(|| { + skillet_vm::Error::Invalid("owned domain disappeared during update".into()) + })? + .validate_owned(&ownership_run) + }; + ownership()?; + let checked_transport = + skillet_vm::transport::OwnershipCheckedTransport::new(&transport, &ownership); + let hashes = skillet_vm::delivery::deliver(&run, &checked_transport, host, generic)?; + ownership()?; + run.deployed = Some(hashes); + store.save(&run)?; + println!( + "Updated skillet and {package} in {}", + identity.domain_name() + ); + Ok(()) +} + +pub(super) fn list(args: &VmListArgs) -> Result<()> { + let butane = butane_root()?; + let store = ManifestStore::new(&butane.join("runs"), current_uid())?; + let mut templates = skillet_vm::catalog::templates(&butane, &workspace_root()?)?; + if let Some(host) = &args.hostname { + RunIdentity::new(host, "catalog")?; + let available = templates.get(host).copied(); + if available.is_none() && skillet_vm::catalog::recorded_runs(&store, host)?.is_empty() { + return Err(anyhow!("unknown host: {host}")); + } + templates = std::collections::BTreeMap::from([(host.clone(), available.unwrap_or(false))]); + } + println!( + "{:<12} {:<12} {:<16} {:<12} PORT", + "HOST", "TEMPLATE", "INSTANCE", "STATE" + ); + for (host, available) in templates { + let availability = if available { + "available" + } else { + "unavailable" + }; + let runs = skillet_vm::catalog::recorded_runs(&store, &host)?; + if runs.is_empty() { + println!("{host:<12} {availability:<12} {:<16} {:<12} -", "-", "none"); + } + for identity in runs { + let (state, port) = match store.load(&identity) { + Err(_) => ("invalid".into(), "-".into()), + Ok(run) => { + let result = VirshBackend::for_run(&run, &butane.join("bin/virsh")) + .and_then(|backend| backend.inspect(&run)); + let state = match result { + Ok(None) => "missing".into(), + Ok(Some(domain)) if domain.validate_owned(&run).is_ok() => domain.state, + Ok(Some(_)) => "mismatch".into(), + Err(_) => "unavailable".into(), + }; + (state, run.ssh.port.to_string()) + } + }; + println!( + "{host:<12} {availability:<12} {:<16} {state:<12} {port}", + identity.instance() + ); + } + } + Ok(()) +} diff --git a/ublue/skillet/crates/cli/src/vm_encryption.rs b/ublue/skillet/crates/cli/src/vm_encryption.rs new file mode 100644 index 00000000..ac9b9cf1 --- /dev/null +++ b/ublue/skillet/crates/cli/src/vm_encryption.rs @@ -0,0 +1,142 @@ +//! Named disposable encryption acceptance, guarded by the VM lifecycle owner. +use super::{butane_root, vm, workspace_root, VmTargetArgs}; +use anyhow::{anyhow, Result}; +use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + transport::{ + GuestCommand, GuestTransport, HostKeyPolicy, OwnershipCheckedTransport, SshTransport, + }, + ManifestStore, Phase, RunIdentity, +}; +use std::{ + fs, + io::{Read as _, Write as _}, + os::unix::fs::PermissionsExt as _, +}; +use zeroize::Zeroizing; + +pub(super) fn check(args: &VmTargetArgs) -> Result<()> { + let butane = butane_root()?; + let identity = RunIdentity::new(&args.hostname, &args.instance)?; + let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _lock = store.lock(&identity)?; + let mut run = store.load(&identity)?; + if run.root_profile != skillet_vm::install::RootProfile::Tpm || run.phase != Phase::Ready { + return Err(anyhow!( + "encryption acceptance requires a ready TPM-profile VM" + )); + } + let backend = VirshBackend::for_run(&run, &butane.join("bin/virsh"))?; + let ownership = || { + backend + .inspect(&run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned encryption VM is absent".into()))? + .validate_owned(&run) + }; + ownership()?; + let ssh = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + let transport = OwnershipCheckedTransport::new(&ssh, &ownership); + let script = "/var/tmp/skillet-tpm-root-test.sh"; + transport.upload( + &workspace_root()?.join("integration_tests/tpm-root.sh"), + script, + )?; + let private = store.run_dir(&identity).join("recovery"); + store.validate(&run, &identity)?; + fs::create_dir_all(&private)?; + fs::set_permissions(&private, fs::Permissions::from_mode(0o700))?; + let key_path = private.join("root-recovery.key"); + if !key_path.exists() { + let mut key = Zeroizing::new(vec![0u8; 64]); + fs::File::open("/dev/urandom")?.read_exact(&mut key)?; + save_private(&key_path, &key)?; + } + let key = Zeroizing::new(fs::read(&key_path)?); + if key.len() != 64 { + return Err(anyhow!("disposable recovery key is invalid")); + } + execute(&transport, &["bash", script, "prepare"], Some(&key))?; + let header = Zeroizing::new(execute( + &transport, + &["cat", "/run/skillet-encryption-test/luks.header"], + None, + )?); + save_private(&private.join("root-luks.header"), &header)?; + execute(&transport, &["bash", script, "verify"], Some(&key))?; + let metadata = execute( + &transport, + &[ + "cryptsetup", + "luksDump", + "--dump-json-metadata", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + let metadata: serde_json::Value = serde_json::from_slice(&metadata)?; + if metadata["keyslots"].as_object().map(serde_json::Map::len) != Some(2) { + return Err(anyhow!( + "expected only TPM and independent recovery keyslots" + )); + } + ownership()?; + run.phase = Phase::Started; + store.save(&run)?; + backend.stop(&run)?; + backend + .inspect(&run)? + .ok_or_else(|| anyhow!("encryption VM disappeared"))? + .validate_owned(&run)?; + backend.start(&run)?; + vm::ready_locked(&butane, &store, &identity)?; + let fresh = store.load(&identity)?; + let ownership = || { + backend + .inspect(&fresh)? + .ok_or_else(|| skillet_vm::Error::Invalid("encryption VM disappeared".into()))? + .validate_owned(&fresh) + }; + let transport = OwnershipCheckedTransport::new(&ssh, &ownership); + execute(&transport, &["bash", script, "verify"], Some(&key))?; + println!("Encrypted root acceptance passed: Secure Boot, LUKS2, data persistence, independent recovery key and unattended cold boot."); + println!( + "Disposable recovery artifacts: {} (removed with this VM)", + private.display() + ); + Ok(()) +} + +fn execute( + transport: &impl GuestTransport, + args: &[&str], + input: Option<&[u8]>, +) -> Result> { + let arguments: Vec<_> = std::iter::once("-n").chain(args.iter().copied()).collect(); + let out = transport.execute( + &GuestCommand { + program: "sudo", + arguments: &arguments, + }, + input, + )?; + if !out.status.success() { + return Err(anyhow!( + "encrypted-root guest assertion failed; no credential output is printed" + )); + } + Ok(out.stdout) +} + +fn save_private(path: &std::path::Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("recovery artifact has no parent"))?; + let mut temp = tempfile::NamedTempFile::new_in(parent)?; + temp.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + temp.write_all(bytes)?; + temp.as_file().sync_all()?; + temp.persist(path)?; + fs::File::open(parent)?.sync_all()?; + Ok(()) +} diff --git a/ublue/skillet/crates/cli/src/vm_ssh_tests.rs b/ublue/skillet/crates/cli/src/vm_ssh_tests.rs new file mode 100644 index 00000000..cdbb6126 --- /dev/null +++ b/ublue/skillet/crates/cli/src/vm_ssh_tests.rs @@ -0,0 +1,51 @@ +use super::*; + +#[test] +fn vm_ssh_accepts_repeated_loopback_forwards() { + let parsed = Args::try_parse_from([ + "skillet", + "test", + "vm", + "ssh", + "clamps", + "restore", + "--forward", + "18443:8443", + "--forward", + "18080:8080", + ]) + .unwrap(); + let Commands::Test { + command: TestCommands::Vm { + command: VmCommands::Ssh(args), + }, + } = parsed.command + else { + panic!("expected VM SSH"); + }; + assert_eq!(args.target.hostname, "clamps"); + assert_eq!(args.target.instance, "restore"); + assert_eq!(args.forward.len(), 2); +} + +#[test] +fn vm_ssh_rejects_nonlocal_or_zero_port_forwards() { + for value in [ + "0:8443", + "18443:0", + "0.0.0.0:18443:8443", + "18443:remote:8443", + ] { + assert!(Args::try_parse_from([ + "skillet", + "test", + "vm", + "ssh", + "clamps", + "restore", + "--forward", + value + ]) + .is_err()); + } +} diff --git a/ublue/skillet/crates/cli/tests/nut_metrics.rs b/ublue/skillet/crates/cli/tests/nut_metrics.rs new file mode 100644 index 00000000..c15e3d04 --- /dev/null +++ b/ublue/skillet/crates/cli/tests/nut_metrics.rs @@ -0,0 +1,275 @@ +//! Real CLI transport coverage; no vault, provider API or running UPS required. +use std::{ + io::{BufRead as _, BufReader, Write as _}, + net::TcpListener, + os::unix::net::UnixDatagram, + process::Command, + thread, + time::Duration, +}; + +fn server(response: &'static str) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let worker = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut request = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut request) + .unwrap(); + assert_eq!(request, "LIST VAR test-ups\n"); + stream.write_all(response.as_bytes()).unwrap(); + }); + (address, worker) +} + +#[test] +fn help_and_dry_run_work_without_credentials_or_a_datadog_socket() { + let help = Command::new(env!("CARGO_BIN_EXE_skillet")) + .args(["metrics", "nut", "--help"]) + .output() + .unwrap(); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("--dogstatsd-socket")); + let (address, worker) = server("BEGIN LIST VAR test-ups\nVAR test-ups ups.status \"OL\"\nVAR test-ups battery.charge \"100\"\nEND LIST VAR test-ups\n"); + let output = Command::new(env!("CARGO_BIN_EXE_skillet")) + .args([ + "metrics", + "nut", + "--ups", + "test-ups", + "--server", + &address, + "--dry-run", + ]) + .env_remove("CREDENTIALS_DIRECTORY") + .output() + .unwrap(); + worker.join().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let text = String::from_utf8_lossy(&output.stdout); + assert!(text.contains("nut.battery.charge:100|g")); + assert!(text.contains("_sc|nut.communication|0|")); +} + +#[test] +fn poll_failure_submits_failure_check_and_exits_nonzero() { + let temp = tempfile::tempdir().unwrap(); + let socket_path = temp.path().join("dsd.socket"); + let receiver = UnixDatagram::bind(&socket_path).unwrap(); + receiver + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let (address, worker) = server("ERR DATA-STALE\n"); + let output = Command::new(env!("CARGO_BIN_EXE_skillet")) + .args([ + "metrics", + "nut", + "--ups", + "test-ups", + "--server", + &address, + "--dogstatsd-socket", + ]) + .arg(&socket_path) + .output() + .unwrap(); + worker.join().unwrap(); + assert!(!output.status.success()); + let mut packet = [0u8; 1024]; + let length = receiver.recv(&mut packet).unwrap(); + assert_eq!( + &packet[..length], + b"_sc|nut.communication|2|#service:nut,ups:test-ups" + ); +} + +#[test] +fn successful_poll_delivers_the_complete_datagram_set() { + let temp = tempfile::tempdir().unwrap(); + let socket_path = temp.path().join("dsd.socket"); + let receiver = UnixDatagram::bind(&socket_path).unwrap(); + receiver + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let receive_worker = thread::spawn(move || { + let mut packets = Vec::new(); + for _ in 0..11 { + let mut buffer = [0u8; 1024]; + let length = receiver.recv(&mut buffer).unwrap(); + packets.push(String::from_utf8(buffer[..length].to_vec()).unwrap()); + } + packets + }); + let (address, worker) = server("BEGIN LIST VAR test-ups\nVAR test-ups ups.status \"OB\"\nVAR test-ups battery.charge \"70\"\nEND LIST VAR test-ups\n"); + let output = Command::new(env!("CARGO_BIN_EXE_skillet")) + .args([ + "metrics", + "nut", + "--ups", + "test-ups", + "--server", + &address, + "--dogstatsd-socket", + ]) + .arg(&socket_path) + .output() + .unwrap(); + worker.join().unwrap(); + let packets = receive_worker.join().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(packets + .iter() + .any(|packet| packet.starts_with("nut.battery.charge:70|g|"))); + assert!(packets + .iter() + .any(|packet| packet.starts_with("_sc|nut.power|1|"))); +} + +struct AgentContainer(String); +impl Drop for AgentContainer { + fn drop(&mut self) { + let _ = Command::new("podman").args(["rm", "-f", &self.0]).output(); + } +} + +fn podman(args: &[&str]) -> Vec { + let output = Command::new("podman").args(args).output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output.stdout +} + +fn start_agent(name: &str, mount: &str) { + podman(&[ + "run", + "-d", + "--name", + name, + "--network", + "none", + "--entrypoint", + "agent", + "-v", + mount, + "-e", + "DD_API_KEY=00000000000000000000000000000000", + "-e", + "DD_HOSTNAME=skillet-nut-test", + "-e", + "DD_TAGS=env:test region:lab", + "-e", + "DD_USE_DOGSTATSD=true", + "-e", + "DD_DOGSTATSD_PORT=0", + "-e", + "DD_DOGSTATSD_SOCKET=/run/skillet-dogstatsd/dsd.socket", + "-e", + "DD_DOGSTATSD_METRICS_STATS_ENABLE=true", + "-e", + "DD_APM_ENABLED=false", + "-e", + "DD_PROCESS_AGENT_ENABLED=false", + "-e", + "DD_SYSTEM_PROBE_ENABLED=false", + "-e", + "DD_LOGS_ENABLED=false", + "-e", + "DD_ENABLE_METADATA_COLLECTION=false", + skillet_datadog::IMAGE, + "run", + ]); +} + +#[test] +#[ignore = "requires Podman and the pinned official Agent image; no real key or network access"] +fn real_agent_accepts_nut_metrics_over_uds_without_udp() { + use std::{os::unix::fs::PermissionsExt as _, time::Instant}; + let temp = tempfile::tempdir().unwrap(); + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let name = format!( + "skillet-nut-agent-{}", + temp.path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .trim_start_matches('.') + ); + let _cleanup = AgentContainer(name.clone()); + let mount = format!("{}:/run/skillet-dogstatsd:rw,Z", temp.path().display()); + start_agent(&name, &mount); + let socket = temp.path().join("dsd.socket"); + let deadline = Instant::now() + Duration::from_secs(50); + while !socket.exists() { + assert!( + Instant::now() < deadline, + "Agent did not create its Unix socket" + ); + thread::sleep(Duration::from_millis(200)); + } + let (address, worker) = server("BEGIN LIST VAR test-ups\nVAR test-ups ups.status \"OL\"\nVAR test-ups battery.charge \"100\"\nEND LIST VAR test-ups\n"); + let output = Command::new(env!("CARGO_BIN_EXE_skillet")) + .args([ + "metrics", + "nut", + "--ups", + "test-ups", + "--server", + &address, + "--dogstatsd-socket", + ]) + .arg(&socket) + .output() + .unwrap(); + worker.join().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + loop { + let bytes = podman(&["exec", &name, "agent", "status", "-j"]); + let report: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let stats = &report["dogstatsdStats"]; + if stats["MetricPackets"].as_u64().unwrap_or(0) >= 9 + && stats["ServiceCheckPackets"].as_u64().unwrap_or(0) >= 2 + { + for key in ["MetricParseErrors", "ServiceCheckParseErrors", "UdpPackets"] { + assert_eq!(stats[key], 0); + } + break; + } + assert!( + Instant::now() < deadline, + "Agent did not parse metrics and service checks" + ); + thread::sleep(Duration::from_millis(200)); + } + let stats = String::from_utf8(podman(&["exec", &name, "agent", "dogstatsd-stats"])).unwrap(); + assert!(stats.contains("nut.battery.charge")); + assert!(stats.contains("service:nut ups:test-ups")); + let udp = String::from_utf8(podman(&[ + "exec", + &name, + "cat", + "/proc/net/udp", + "/proc/net/udp6", + ])) + .unwrap(); + assert_eq!(udp.lines().count(), 2, "unexpected UDP listener: {udp}"); +} diff --git a/ublue/skillet/crates/core/Cargo.toml b/ublue/skillet/crates/core/Cargo.toml new file mode 100644 index 00000000..b3f17e88 --- /dev/null +++ b/ublue/skillet/crates/core/Cargo.toml @@ -0,0 +1,26 @@ +[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 +serde_yml.workspace = true + +[features] +test-utils = [] diff --git a/ublue/skillet/crates/core/src/activation.rs b/ublue/skillet/crates/core/src/activation.rs new file mode 100644 index 00000000..f2611a8d --- /dev/null +++ b/ublue/skillet/crates/core/src/activation.rs @@ -0,0 +1,93 @@ +use crate::{ + files::{FileError, FileMutationResource, FileReadResource}, + system::{ServiceResource, SystemError}, +}; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConsumerKind { + Persistent, + OneShot, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ActivationAction { + None, + Started, + Restarted, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ActivationOutcome { + pub definition_changed: bool, + pub action: ActivationAction, + pub recovered_pending_activation: bool, +} + +pub struct ActivationRequest { + pub service: String, + pub state_path: PathBuf, + pub revision: Vec, + pub definition_changed: bool, + pub reload_daemon: bool, + pub consumer_kind: ConsumerKind, +} + +#[derive(Debug, Error)] +pub enum ActivationError { + #[error("file operation failed: {0}")] + File(#[from] FileError), + #[error("system operation failed: {0}")] + System(#[from] SystemError), +} + +pub fn activate( + system: &S, + files: &F, + request: &ActivationRequest, +) -> Result +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let applied = files.read_file(&request.state_path)?; + let pending = applied.as_deref() != Some(request.revision.as_slice()); + let needs_activation = request.definition_changed || pending; + + if needs_activation && request.reload_daemon { + system.daemon_reload()?; + } + + let active = system.service_is_active(&request.service)?; + let action = match (request.consumer_kind, needs_activation, active) { + (ConsumerKind::Persistent, true, _) | (ConsumerKind::OneShot, true, true) => { + system.service_restart(&request.service)?; + ActivationAction::Restarted + } + (ConsumerKind::Persistent, false, false) | (ConsumerKind::OneShot, true, false) => { + system.service_start(&request.service)?; + ActivationAction::Started + } + _ => ActivationAction::None, + }; + + if needs_activation { + files.ensure_file( + &request.state_path, + &request.revision, + Some(0o644), + &crate::files::Ownership::named(Some("root"), Some("root")), + )?; + } + + Ok(ActivationOutcome { + definition_changed: request.definition_changed, + action, + recovered_pending_activation: pending && !request.definition_changed, + }) +} + +#[cfg(test)] +#[path = "activation/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/activation/tests.rs b/ublue/skillet/crates/core/src/activation/tests.rs new file mode 100644 index 00000000..0ddaf96f --- /dev/null +++ b/ublue/skillet/crates/core/src/activation/tests.rs @@ -0,0 +1,118 @@ +use super::*; +use crate::test_utils::{MockFiles, MockSystem}; +use std::path::Path; + +fn run( + system: &MockSystem, + files: &MockFiles, + request: &ActivationRequest, +) -> Result { + activate(system, files, request) +} + +fn request(revision: &[u8], changed: bool) -> ActivationRequest { + ActivationRequest { + service: "fixture.service".to_string(), + state_path: PathBuf::from("/var/lib/skillet/activation/fixture.applied"), + revision: revision.to_vec(), + definition_changed: changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + } +} + +#[test] +fn tracks_definition_change_then_a_true_noop() { + let system = MockSystem::new(); + let files = MockFiles::new(); + let first = run(&system, &files, &request(b"v1", true)).unwrap(); + assert_eq!(first.action, ActivationAction::Restarted); + let state_path = Path::new("/var/lib/skillet/activation/fixture.applied"); + assert_eq!( + files.read_file(state_path).unwrap().as_deref(), + Some(b"v1".as_slice()) + ); + let repeated = run(&system, &files, &request(b"v1", false)).unwrap(); + assert_eq!(repeated.action, ActivationAction::None); + assert!(!repeated.recovered_pending_activation); +} + +#[test] +fn stopped_persistent_service_is_started_without_definition_changes() { + let system = MockSystem::new(); + let files = MockFiles::new(); + run(&system, &files, &request(b"v1", true)).unwrap(); + system.service_stop("fixture.service").unwrap(); + let outcome = run(&system, &files, &request(b"v1", false)).unwrap(); + assert_eq!(outcome.action, ActivationAction::Started); +} + +#[test] +fn reload_and_restart_failures_leave_revision_pending_for_recovery() { + let system = MockSystem::new(); + let files = MockFiles::new(); + let state_path = Path::new("/var/lib/skillet/activation/fixture.applied"); + system + .fail_reload_once + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(run(&system, &files, &request(b"v1", true)).is_err()); + assert!(files.read_file(state_path).unwrap().is_none()); + run(&system, &files, &request(b"v1", true)).unwrap(); + + system + .fail_restart_once + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(run(&system, &files, &request(b"v2", true)).is_err()); + assert_eq!( + files.read_file(state_path).unwrap().as_deref(), + Some(b"v1".as_slice()) + ); + let retry = run(&system, &files, &request(b"v2", false)).unwrap(); + assert!(retry.recovered_pending_activation); + assert_eq!(retry.action, ActivationAction::Restarted); + assert_eq!( + files.read_file(state_path).unwrap().as_deref(), + Some(b"v2".as_slice()) + ); +} + +#[test] +fn failed_start_does_not_commit_revision_and_retries() { + let system = MockSystem::new(); + let files = MockFiles::new(); + let state_path = Path::new("/var/lib/skillet/activation/fixture.applied"); + let mut first = request(b"v1", true); + first.consumer_kind = ConsumerKind::OneShot; + system + .fail_start_once + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(run(&system, &files, &first).is_err()); + assert!(files.read_file(state_path).unwrap().is_none()); + let mut retry = request(b"v1", false); + retry.consumer_kind = ConsumerKind::OneShot; + let result = run(&system, &files, &retry).unwrap(); + assert!(result.recovered_pending_activation); + assert_eq!(result.action, ActivationAction::Started); + assert_eq!( + files.read_file(state_path).unwrap().as_deref(), + Some(b"v1".as_slice()) + ); +} + +#[test] +fn completed_oneshot_is_not_started_again_when_inactive() { + let system = MockSystem::new(); + let files = MockFiles::new(); + let mut initial = request(b"v1", true); + initial.consumer_kind = ConsumerKind::OneShot; + run(&system, &files, &initial).unwrap(); + system.service_stop("fixture.service").unwrap(); + let mut repeat = request(b"v1", false); + repeat.consumer_kind = ConsumerKind::OneShot; + let outcome = run(&system, &files, &repeat).unwrap(); + assert_eq!(outcome.action, ActivationAction::None); + assert_eq!( + system.start_count.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); +} diff --git a/ublue/skillet/crates/core/src/credential_install.rs b/ublue/skillet/crates/core/src/credential_install.rs new file mode 100644 index 00000000..949ad51d --- /dev/null +++ b/ublue/skillet/crates/core/src/credential_install.rs @@ -0,0 +1,105 @@ +//! Guest-side inspection and atomic installation of encrypted credentials. + +use std::{ + fs, io, + os::unix::fs::PermissionsExt as _, + path::PathBuf, + process::{Command, Stdio}, +}; +use thiserror::Error; + +const CREDENTIAL_DIRECTORY: &str = "/etc/credstore.encrypted/skillet"; + +#[derive(Debug, Error)] +pub enum CredentialInstallError { + #[error("invalid credential name or service unit")] + InvalidName, + #[error("I/O error: {0}")] + Io(#[from] io::Error), + #[error("{0} failed")] + Command(&'static str), +} + +fn credential_path(name: &str) -> Result { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(CredentialInstallError::InvalidName); + } + Ok(PathBuf::from(CREDENTIAL_DIRECTORY).join(format!("{name}.cred"))) +} + +pub fn state(name: &str) -> Result<&'static str, CredentialInstallError> { + let path = credential_path(name)?; + match fs::symlink_metadata(path) { + Ok(_) => return Ok("present"), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let status = Command::new("podman") + .args(["secret", "exists", name]) + .status()?; + match status.code() { + Some(0) => Ok("present"), + Some(1) => Ok("absent"), + _ => Err(CredentialInstallError::Command("podman secret exists")), + } +} + +pub fn install(name: &str, unit: &str, start_unit: bool) -> Result<(), CredentialInstallError> { + let path = credential_path(name)?; + if !valid_unit(unit) { + return Err(CredentialInstallError::InvalidName); + } + let directory = PathBuf::from(CREDENTIAL_DIRECTORY); + fs::create_dir_all(&directory)?; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + let encrypted = tempfile::NamedTempFile::new_in(&directory)?; + let status = Command::new("systemd-creds") + .args(["encrypt", "--with-key=host"]) + .arg(format!("--name={name}")) + .args(["-", "-"]) + .stdin(Stdio::inherit()) + .stdout(Stdio::from(encrypted.reopen()?)) + .status()?; + if !status.success() { + return Err(CredentialInstallError::Command("systemd-creds encrypt")); + } + encrypted.as_file().sync_all()?; + let status = Command::new("systemd-creds") + .arg("decrypt") + .arg(format!("--name={name}")) + .arg(encrypted.path()) + .arg("-") + .stdout(Stdio::null()) + .status()?; + if !status.success() { + return Err(CredentialInstallError::Command("systemd-creds decrypt")); + } + encrypted.persist(path).map_err(|error| error.error)?; + fs::File::open(&directory)?.sync_all()?; + if start_unit { + let status = Command::new("systemctl").args(["start", unit]).status()?; + if !status.success() { + return Err(CredentialInstallError::Command("systemctl start")); + } + } + Ok(()) +} + +fn valid_unit(unit: &str) -> bool { + unit.ends_with(".service") + && unit + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && unit + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'@' | b'.')) +} + +#[cfg(test)] +#[path = "credential_install/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/credential_install/tests.rs b/ublue/skillet/crates/core/src/credential_install/tests.rs new file mode 100644 index 00000000..04c435d6 --- /dev/null +++ b/ublue/skillet/crates/core/src/credential_install/tests.rs @@ -0,0 +1,32 @@ +use super::{credential_path, valid_unit, CredentialInstallError}; +use std::path::Path; + +#[test] +fn credential_names_are_single_literal_components() { + assert_eq!( + credential_path("pihole_web_password").unwrap(), + Path::new("/etc/credstore.encrypted/skillet/pihole_web_password.cred") + ); + for name in ["", "../secret", "name.service", "name-with-dash", "a/b"] { + assert!(matches!( + credential_path(name), + Err(CredentialInstallError::InvalidName) + )); + } +} + +#[test] +fn consumer_units_are_service_unit_names() { + for unit in ["skillet-full-apply.service", "skillet-caddy-apply.service"] { + assert!(valid_unit(unit)); + } + for unit in [ + "", + "service", + "-service.service", + "../service.service", + "service.timer", + ] { + assert!(!valid_unit(unit)); + } +} diff --git a/ublue/skillet/crates/core/src/credentials.rs b/ublue/skillet/crates/core/src/credentials.rs new file mode 100644 index 00000000..af0d4f0d --- /dev/null +++ b/ublue/skillet/crates/core/src/credentials.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; +use std::{collections::HashMap, io::Read as _}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CredentialError { + #[error("CREDENTIALS_DIRECTORY environment variable not set")] + NoDirectory, + #[error("Invalid secret name {0:?}: must be a plain file name")] + InvalidName(String), + #[error("Failed to read secret {0}: {1}")] + ReadError(String, std::io::Error), + #[error("Required credential {0} was not supplied to host composition")] + MissingInput(String), +} + +/// Credential bytes loaded at an entry point and passed explicitly into +/// composition. Values intentionally have no Debug implementation. +#[derive(Default)] +pub struct CredentialInputs { + values: HashMap, +} + +impl CredentialInputs { + pub fn insert(&mut self, name: impl Into, value: String) { + self.values.insert(name.into(), value); + } + + pub fn require(&self, name: &str) -> Result<&str, CredentialError> { + self.values + .get(name) + .map(String::as_str) + .ok_or_else(|| CredentialError::MissingInput(name.to_string())) + } +} + +pub struct CredentialManager { + 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 }) + } + + /// Read a secret by name from the credentials directory. + /// + /// The name must be a plain file name: path separators, parent + /// references and absolute paths are rejected so a caller can never + /// escape the credentials directory, even if the name ever stops + /// being a hardcoded constant. + pub fn read_secret(&self, name: &str) -> Result { + if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") { + return Err(CredentialError::InvalidName(name.to_string())); + } + + let secret_path = self.base_path.join(name); + + let mut file = std::fs::File::open(&secret_path) + .map_err(|e| CredentialError::ReadError(name.to_string(), e))?; + let mut content = String::new(); + file.read_to_string(&mut content) + .map_err(|e| CredentialError::ReadError(name.to_string(), e))?; + Ok(content) + } +} + +#[cfg(test)] +#[path = "credentials/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/credentials/tests.rs b/ublue/skillet/crates/core/src/credentials/tests.rs new file mode 100644 index 00000000..9d22fb5f --- /dev/null +++ b/ublue/skillet/crates/core/src/credentials/tests.rs @@ -0,0 +1,48 @@ +use super::*; + +fn manager_with_secret(name: &str, payload: &str) -> (CredentialManager, tempfile::TempDir) { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join(name), payload).expect("write secret"); + let manager = CredentialManager { + base_path: dir.path().to_path_buf(), + }; + (manager, dir) +} + +#[test] +fn read_secret_happy_path() { + let (manager, _dir) = manager_with_secret("pihole_web_password", "s3cret\n"); + assert_eq!( + manager.read_secret("pihole_web_password").unwrap(), + "s3cret\n" + ); +} + +#[test] +fn read_secret_rejects_traversal() { + let (manager, _dir) = manager_with_secret("pihole_web_password", "s3cret"); + for evil in [ + "../pihole_web_password", + "sub/dir", + "..\\pihole_web_password", + "/etc/passwd", + "..", + "", + ] { + let err = manager.read_secret(evil).unwrap_err(); + assert!( + matches!(err, CredentialError::InvalidName(_)), + "expected InvalidName for {evil:?}, got {err:?}" + ); + } +} + +#[test] +fn read_secret_missing_file_is_read_error() { + let (manager, _dir) = manager_with_secret("pihole_web_password", "s3cret"); + let err = manager.read_secret("does_not_exist").unwrap_err(); + assert!( + matches!(err, CredentialError::ReadError(_, _)), + "expected ReadError, got {err:?}" + ); +} diff --git a/ublue/skillet/crates/core/src/files.rs b/ublue/skillet/crates/core/src/files.rs new file mode 100644 index 00000000..812d9db1 --- /dev/null +++ b/ublue/skillet/crates/core/src/files.rs @@ -0,0 +1,539 @@ +use nix::unistd::{chown, fchown, Gid, Uid}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs::{self, File}; +use std::io::{self, BufReader, Write}; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; +use std::path::Path; +use std::process::Command; +use tempfile::NamedTempFile; +use thiserror::Error; +use tracing::info; +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), + #[error("Required data mount is absent: {0}")] + MountMissing(String), + #[error("Unexpected filesystem or subvolume at {0}")] + WrongMount(String), + #[error("Path {0} exists but is not a Btrfs subvolume")] + NotASubvolume(String), + #[error("Btrfs operation failed for {0}: {1}")] + Btrfs(String, String), +} + +/// Owner identity can be expressed by a host account name or a numeric ID. +/// Numeric identities do not require a matching NSS entry. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum OwnerIdentity { + Name(String), + Id(u32), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct Ownership { + pub uid: Option, + pub gid: Option, +} + +impl Ownership { + pub fn named(uid: Option<&str>, gid: Option<&str>) -> Self { + Self { + uid: uid.map(|name| OwnerIdentity::Name(name.to_string())), + gid: gid.map(|name| OwnerIdentity::Name(name.to_string())), + } + } +} + +pub trait FileReadResource { + fn read_file(&self, path: &Path) -> Result>, FileError>; +} + +pub trait FileMutationResource { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + mode: Option, + ownership: &Ownership, + ) -> Result; + fn ensure_directory( + &self, + path: &Path, + mode: Option, + ownership: &Ownership, + ) -> Result; + fn delete_file(&self, path: &Path) -> Result; +} + +pub trait StorageResource { + fn require_btrfs_subvolume_mount( + &self, + path: &Path, + backing_mount: &Path, + subvolume_root: &str, + ) -> Result<(), FileError>; + fn require_btrfs_subvolume(&self, path: &Path) -> Result<(), FileError>; + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result; +} + +/// Transitional aggregate for recipes that still consume more than one file +/// capability. New consumers should request the narrowest trait they need. +pub trait FileResource: FileReadResource + FileMutationResource + StorageResource {} + +impl FileResource for T where T: FileReadResource + FileMutationResource + StorageResource {} + +pub struct LocalFileResource; + +impl LocalFileResource { + pub fn new() -> Self { + Self + } + + fn check_metadata( + path: &Path, + mode: Option, + ownership: &Ownership, + ) -> 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; + } + } + + let uid = ownership.uid.as_ref().map(Self::identity_uid).transpose()?; + let gid = ownership.gid.as_ref().map(Self::identity_gid).transpose()?; + if uid.is_some_and(|desired| metadata.uid() != desired) + || gid.is_some_and(|desired| metadata.gid() != desired) + { + changed = true; + } + + Ok(changed) + } + + fn apply_metadata_to_file( + file: &File, + mode: Option, + ownership: &Ownership, + ) -> Result<(), FileError> { + use std::os::unix::io::AsRawFd; + + if let Some(m) = mode { + let mut perms = file.metadata().map_err(FileError::Io)?.permissions(); + perms.set_mode(m); + file.set_permissions(perms).map_err(FileError::Io)?; + } + + if ownership.uid.is_some() || ownership.gid.is_some() { + let uid = ownership + .uid + .as_ref() + .map(Self::identity_uid) + .transpose()? + .map(Uid::from_raw); + let gid = ownership + .gid + .as_ref() + .map(Self::identity_gid) + .transpose()? + .map(Gid::from_raw); + + 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, + ownership: &Ownership, + ) -> 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 ownership.uid.is_some() || ownership.gid.is_some() { + let uid = ownership + .uid + .as_ref() + .map(Self::identity_uid) + .transpose()? + .map(Uid::from_raw); + let gid = ownership + .gid + .as_ref() + .map(Self::identity_gid) + .transpose()? + .map(Gid::from_raw); + + chown(path, uid, gid) + .map_err(|e| FileError::SetOwnership(path.display().to_string(), e.to_string()))?; + } + + Ok(()) + } + + fn identity_uid(identity: &OwnerIdentity) -> Result { + match identity { + OwnerIdentity::Name(name) => get_user_by_name(name) + .map(|user| user.uid()) + .ok_or_else(|| FileError::UserNotFound(name.clone())), + OwnerIdentity::Id(uid) => Ok(*uid), + } + } + + fn identity_gid(identity: &OwnerIdentity) -> Result { + match identity { + OwnerIdentity::Name(name) => get_group_by_name(name) + .map(|group| group.gid()) + .ok_or_else(|| FileError::GroupNotFound(name.clone())), + OwnerIdentity::Id(gid) => Ok(*gid), + } + } + + fn check_directory_metadata( + path: &Path, + mode: Option, + ownership: &Ownership, + ) -> Result { + let metadata = fs::metadata(path) + .map_err(|error| FileError::Read(path.display().to_string(), error))?; + let uid = ownership.uid.as_ref().map(Self::identity_uid).transpose()?; + let gid = ownership.gid.as_ref().map(Self::identity_gid).transpose()?; + Ok( + mode.is_some_and(|desired| metadata.permissions().mode() & 0o7777 != desired) + || uid.is_some_and(|desired| metadata.uid() != desired) + || gid.is_some_and(|desired| metadata.gid() != desired), + ) + } + + fn apply_directory_metadata( + path: &Path, + mode: Option, + ownership: &Ownership, + ) -> Result<(), FileError> { + if let Some(mode) = mode { + let mut permissions = fs::metadata(path) + .map_err(|error| FileError::Read(path.display().to_string(), error))? + .permissions(); + permissions.set_mode(mode); + fs::set_permissions(path, permissions) + .map_err(|error| FileError::SetPermissions(path.display().to_string(), error))?; + } + if ownership.uid.is_some() || ownership.gid.is_some() { + let uid = ownership + .uid + .as_ref() + .map(Self::identity_uid) + .transpose()? + .map(Uid::from_raw); + let gid = ownership + .gid + .as_ref() + .map(Self::identity_gid) + .transpose()? + .map(Gid::from_raw); + chown(path, uid, gid).map_err(|error| { + FileError::SetOwnership(path.display().to_string(), error.to_string()) + })?; + } + Ok(()) + } + + fn get_file_hash(path: &Path) -> Result, FileError> { + let file = File::open(path).map_err(|e| FileError::Read(path.display().to_string(), e))?; + let mut reader = BufReader::new(file); + 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 StorageResource for LocalFileResource { + fn require_btrfs_subvolume_mount( + &self, + path: &Path, + backing_mount: &Path, + subvolume_root: &str, + ) -> Result<(), FileError> { + let mountinfo = fs::read_to_string("/proc/self/mountinfo")?; + require_btrfs_mount_in(&mountinfo, path, backing_mount, subvolume_root) + } + + fn require_btrfs_subvolume(&self, path: &Path) -> Result<(), FileError> { + let output = Command::new("btrfs") + .args(["subvolume", "show"]) + .arg(path) + .output()?; + if output.status.success() { + Ok(()) + } else { + Err(FileError::NotASubvolume(path.display().to_string())) + } + } + + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { + let path_text = path.display().to_string(); + if Command::new("btrfs") + .args(["subvolume", "show"]) + .arg(path) + .output()? + .status + .success() + { + return Ok(false); + } + if path.exists() { + return Err(FileError::NotASubvolume(path_text)); + } + let parent = path + .parent() + .ok_or_else(|| FileError::InvalidPath(path_text.clone()))?; + if !parent.is_dir() { + return Err(FileError::ParentMissing(path_text)); + } + let output = Command::new("btrfs") + .args(["subvolume", "create"]) + .arg(path) + .output()?; + if !output.status.success() { + return Err(FileError::Btrfs( + path_text, + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + Ok(true) + } +} + +impl FileReadResource for LocalFileResource { + fn read_file(&self, path: &Path) -> Result>, FileError> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(FileError::Read(path.display().to_string(), error)), + } + } +} + +impl FileMutationResource for LocalFileResource { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + mode: Option, + ownership: &Ownership, + ) -> 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, ownership)?; + 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, ownership)? { + Self::apply_metadata(path, mode, ownership)?; + changed = true; + info!("Updated file metadata for {}", path.display()); + } + } + + Ok(changed) + } + + fn ensure_directory( + &self, + path: &Path, + mode: Option, + ownership: &Ownership, + ) -> 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_directory_metadata(path, mode, ownership)? { + Self::apply_directory_metadata(path, mode, ownership)?; + 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) + } + } +} + +fn mount_entry<'a>(mountinfo: &'a str, path: &str) -> Option<(&'a str, &'a str, &'a str)> { + mountinfo.lines().find_map(|line| { + let (left, right) = line.split_once(" - ")?; + let mut fields = left.split_whitespace(); + let device = fields.nth(2)?; + let root = fields.next()?; + let mountpoint = fields.next()?; + let filesystem = right.split_whitespace().next()?; + (mountpoint == path).then_some((device, root, filesystem)) + }) +} + +fn require_btrfs_mount_in( + mountinfo: &str, + path: &Path, + backing_mount: &Path, + subvolume_root: &str, +) -> Result<(), FileError> { + let path_text = path + .to_str() + .ok_or_else(|| FileError::InvalidPath(path.display().to_string()))?; + let backing_text = backing_mount + .to_str() + .ok_or_else(|| FileError::InvalidPath(backing_mount.display().to_string()))?; + let (device, root, filesystem) = mount_entry(mountinfo, path_text) + .ok_or_else(|| FileError::MountMissing(path_text.to_string()))?; + let backing = mount_entry(mountinfo, backing_text); + if filesystem == "btrfs" + && root == subvolume_root + && backing.is_some_and(|(backing_device, _, backing_fs)| { + backing_device == device && backing_fs == "btrfs" + }) + { + Ok(()) + } else { + Err(FileError::WrongMount(path_text.to_string())) + } +} + +#[cfg(test)] +#[path = "files/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/files/tests.rs b/ublue/skillet/crates/core/src/files/tests.rs new file mode 100644 index 00000000..94cfd2dc --- /dev/null +++ b/ublue/skillet/crates/core/src/files/tests.rs @@ -0,0 +1,291 @@ +use super::*; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use tempfile::tempdir; + +#[test] +fn data_mount_requires_the_expected_btrfs_subvolume() { + let mountinfo = "1 0 0:31 /ostree/deploy/fedora-coreos/var /var rw - btrfs /dev/vda4 rw\n\ + 2 1 0:31 /data /var/lib/data rw - btrfs /dev/vda4 rw\n"; + let data = Path::new("/var/lib/data"); + let backing = Path::new("/var"); + + assert!(require_btrfs_mount_in(mountinfo, data, backing, "/data").is_ok()); + assert!(matches!( + require_btrfs_mount_in(mountinfo, data, backing, "/other"), + Err(FileError::WrongMount(_)) + )); + assert!(matches!( + require_btrfs_mount_in(mountinfo, Path::new("/missing"), backing, "/data"), + Err(FileError::MountMissing(_)) + )); +} + +#[test] +fn data_mount_rejects_an_unrelated_filesystem() { + let mountinfo = "1 0 0:31 /ostree/deploy/fedora-coreos/var /var rw - btrfs /dev/vda4 rw\n\ + 2 1 0:44 /data /var/lib/data rw - btrfs /dev/vdb1 rw\n"; + assert!(matches!( + require_btrfs_mount_in( + mountinfo, + Path::new("/var/lib/data"), + Path::new("/var"), + "/data" + ), + Err(FileError::WrongMount(_)) + )); +} + +#[test] +fn test_ensure_file_creates_file() { + let dir = tempdir().unwrap(); + 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, &Ownership::default()) + .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, &Ownership::default()) + .unwrap(); + assert!(changed); + + // Second write (same content) + let changed_again = resource + .ensure_file(&file_path, content, None, &Ownership::default()) + .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, &Ownership::default()) + .unwrap(); + + let changed = resource + .ensure_file(&file_path, b"updated", None, &Ownership::default()) + .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, &Ownership::default()) + .unwrap(); + + // 2. Change mode + let changed = resource + .ensure_file(&file_path, content, Some(0o644), &Ownership::default()) + .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), &Ownership::default()) + .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, &Ownership::default()) + .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), &Ownership::default()) + .unwrap(); + assert!(changed); + assert!(sub_dir.exists()); + assert!(sub_dir.is_dir()); +} + +#[test] +fn directory_numeric_owner_is_idempotent() { + use super::{OwnerIdentity, Ownership}; + use std::os::unix::fs::MetadataExt; + + let dir = tempdir().unwrap(); + let path = dir.path().join("numeric-owner"); + let parent = fs::metadata(dir.path()).unwrap(); + let resource = LocalFileResource::new(); + + assert!(resource + .ensure_directory( + &path, + Some(0o750), + &Ownership { + uid: Some(OwnerIdentity::Id(parent.uid())), + gid: Some(OwnerIdentity::Id(parent.gid())), + }, + ) + .unwrap()); + assert!(!resource + .ensure_directory( + &path, + Some(0o750), + &Ownership { + uid: Some(OwnerIdentity::Id(parent.uid())), + gid: Some(OwnerIdentity::Id(parent.gid())), + }, + ) + .unwrap()); + + let metadata = fs::metadata(path).unwrap(); + assert_eq!( + (metadata.uid(), metadata.gid()), + (parent.uid(), parent.gid()) + ); +} + +#[test] +fn file_numeric_owner_is_idempotent_and_does_not_need_name_lookup() { + use std::os::unix::fs::MetadataExt; + + let dir = tempdir().unwrap(); + let path = dir.path().join("numeric-owner-file"); + let parent = fs::metadata(dir.path()).unwrap(); + let resource = LocalFileResource::new(); + let ownership = Ownership { + uid: Some(OwnerIdentity::Id(parent.uid())), + gid: Some(OwnerIdentity::Id(parent.gid())), + }; + + assert!(resource + .ensure_file(&path, b"numeric owner", Some(0o640), &ownership) + .unwrap()); + assert!(!resource + .ensure_file(&path, b"numeric owner", Some(0o640), &ownership) + .unwrap()); + + let metadata = fs::metadata(path).unwrap(); + assert_eq!( + (metadata.uid(), metadata.gid()), + (parent.uid(), parent.gid()) + ); +} + +#[test] +fn numeric_file_owner_comparison_does_not_require_an_nss_entry() { + use std::os::unix::fs::MetadataExt; + + let dir = tempdir().unwrap(); + let path = dir.path().join("unmapped-numeric-owner"); + fs::write(&path, b"fixture").unwrap(); + let metadata = fs::metadata(&path).unwrap(); + let unmatched_uid = metadata.uid().wrapping_add(1); + + assert!(LocalFileResource::check_metadata( + &path, + None, + &Ownership { + uid: Some(OwnerIdentity::Id(unmatched_uid)), + gid: None, + }, + ) + .unwrap()); +} + +#[test] +fn test_ensure_directory_fails_if_file() { + let dir = tempdir().unwrap(); + 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, &Ownership::default()); + 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, &Ownership::default()) + .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..751f1869 --- /dev/null +++ b/ublue/skillet/crates/core/src/lib.rs @@ -0,0 +1,15 @@ +pub mod activation; +pub mod credential_install; +pub mod credentials; +pub mod files; +pub mod recorder; +pub mod resource_op; +pub mod system; +pub mod templates; +#[cfg(any(test, feature = "test-utils"))] +pub mod test_utils; +pub mod usb; + +#[cfg(test)] +#[path = "resource_contract_tests.rs"] +mod resource_contract_tests; diff --git a/ublue/skillet/crates/core/src/recorder.rs b/ublue/skillet/crates/core/src/recorder.rs new file mode 100644 index 00000000..0855d5fb --- /dev/null +++ b/ublue/skillet/crates/core/src/recorder.rs @@ -0,0 +1,275 @@ +use crate::files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}; +use crate::resource_op::{EffectResult, RecordedOperation, ResourceOp}; +use crate::system::{ + AccountLookupResource, AccountResource, GroupIdentity, PodmanSecretResource, ServiceResource, + SystemError, UserIdentity, +}; +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, operation: ResourceOp, result: EffectResult) { + self.ops + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(RecordedOperation { operation, result }); + } +} + +fn changed(result: &Result) -> EffectResult { + result + .as_ref() + .map_or(EffectResult::Failed, |value| EffectResult::Changed(*value)) +} + +fn succeeded(result: &Result) -> EffectResult { + if result.is_ok() { + EffectResult::Succeeded + } else { + EffectResult::Failed + } +} + +impl StorageResource for Recorder { + fn require_btrfs_subvolume_mount( + &self, + path: &Path, + backing_mount: &Path, + subvolume_root: &str, + ) -> Result<(), FileError> { + self.inner + .require_btrfs_subvolume_mount(path, backing_mount, subvolume_root) + } + fn require_btrfs_subvolume(&self, path: &Path) -> Result<(), FileError> { + self.inner.require_btrfs_subvolume(path) + } + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { + let result = self.inner.ensure_btrfs_subvolume(path); + self.record( + ResourceOp::EnsureBtrfsSubvolume { + path: path.display().to_string(), + }, + changed(&result), + ); + result + } +} + +impl FileReadResource for Recorder { + fn read_file(&self, path: &Path) -> Result>, FileError> { + self.inner.read_file(path) + } +} + +impl FileMutationResource for Recorder { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + mode: Option, + ownership: &Ownership, + ) -> Result { + let result = self.inner.ensure_file(path, content, mode, ownership); + self.record( + ResourceOp::EnsureFile { + path: path.display().to_string(), + mode: mode.map(|m| format!("0o{m:o}")), + ownership: ownership.clone(), + }, + changed(&result), + ); + result + } + fn ensure_directory( + &self, + path: &Path, + mode: Option, + ownership: &Ownership, + ) -> Result { + let result = self.inner.ensure_directory(path, mode, ownership); + self.record( + ResourceOp::EnsureDirectory { + path: path.display().to_string(), + mode: mode.map(|m| format!("0o{m:o}")), + ownership: ownership.clone(), + }, + changed(&result), + ); + result + } + fn delete_file(&self, path: &Path) -> Result { + let result = self.inner.delete_file(path); + self.record( + ResourceOp::DeleteFile { + path: path.display().to_string(), + }, + changed(&result), + ); + result + } +} + +impl PodmanSecretResource for Recorder { + fn podman_secret_id(&self, name: &str) -> Result { + self.inner.podman_secret_id(name) + } + fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { + let result = self.inner.ensure_podman_secret(name, payload); + self.record( + ResourceOp::EnsurePodmanSecret { + name: name.to_string(), + }, + changed(&result), + ); + result + } +} + +impl ServiceResource for Recorder { + fn service_is_active(&self, name: &str) -> Result { + self.inner.service_is_active(name) + } + fn service_start(&self, name: &str) -> Result<(), SystemError> { + let result = self.inner.service_start(name); + self.record( + ResourceOp::ServiceStart { + name: name.to_string(), + }, + succeeded(&result), + ); + result + } + fn service_stop(&self, name: &str) -> Result<(), SystemError> { + let result = self.inner.service_stop(name); + self.record( + ResourceOp::ServiceStop { + name: name.to_string(), + }, + succeeded(&result), + ); + result + } + fn service_restart(&self, name: &str) -> Result<(), SystemError> { + let result = self.inner.service_restart(name); + self.record( + ResourceOp::ServiceRestart { + name: name.to_string(), + }, + succeeded(&result), + ); + result + } + fn service_reload(&self, name: &str) -> Result<(), SystemError> { + let result = self.inner.service_reload(name); + self.record( + ResourceOp::ServiceReload { + name: name.to_string(), + }, + succeeded(&result), + ); + result + } + fn service_enable(&self, name: &str) -> Result<(), SystemError> { + let result = self.inner.service_enable(name); + self.record( + ResourceOp::ServiceEnable { + name: name.to_string(), + }, + succeeded(&result), + ); + result + } + fn daemon_reload(&self) -> Result<(), SystemError> { + let result = self.inner.daemon_reload(); + self.record(ResourceOp::DaemonReload, succeeded(&result)); + result + } +} + +impl AccountLookupResource for Recorder { + fn user_by_name(&self, name: &str) -> Result, SystemError> { + self.inner.user_by_name(name) + } + fn user_by_uid(&self, uid: u32) -> Result, SystemError> { + self.inner.user_by_uid(uid) + } + fn group_by_name(&self, name: &str) -> Result, SystemError> { + self.inner.group_by_name(name) + } +} + +impl AccountResource for Recorder { + fn ensure_group(&self, name: &str, gid: Option) -> Result { + let result = self.inner.ensure_group(name, gid); + self.record( + ResourceOp::EnsureGroup { + name: name.to_string(), + gid, + }, + changed(&result), + ); + result + } + fn ensure_user( + &self, + name: &str, + uid: Option, + gid: Option, + ) -> Result { + let result = self.inner.ensure_user(name, uid, gid); + self.record( + ResourceOp::EnsureUser { + name: name.to_string(), + uid, + gid, + }, + changed(&result), + ); + result + } +} + +#[cfg(test)] +#[path = "recorder_tests.rs"] +mod tests; + +impl crate::usb::UsbResource for Recorder { + fn usb_device_present(&self, vendor: u16, product: u16) -> Result { + self.inner.usb_device_present(vendor, product) + } + fn refresh_usb_rules(&self, vendor: u16, product: u16) -> Result<(), SystemError> { + let result = self.inner.refresh_usb_rules(vendor, product); + self.record( + ResourceOp::RefreshUsbRules { vendor, product }, + succeeded(&result), + ); + result + } +} diff --git a/ublue/skillet/crates/core/src/recorder_tests.rs b/ublue/skillet/crates/core/src/recorder_tests.rs new file mode 100644 index 00000000..5a66df68 --- /dev/null +++ b/ublue/skillet/crates/core/src/recorder_tests.rs @@ -0,0 +1,55 @@ +use super::*; +use crate::files::{FileMutationResource, OwnerIdentity, Ownership}; +use crate::resource_op::EffectResult; +use crate::test_utils::{MockFiles, MockSystem}; +use sha2::Digest as _; +use std::sync::atomic::Ordering; + +#[test] +fn records_results_without_payloads_or_fingerprints() { + let system = MockSystem::new(); + let recorder = Recorder::new(system); + let secret = "distinctive-private-payload-for-recording-test"; + assert!(recorder + .ensure_podman_secret("fixture-secret", secret) + .unwrap()); + recorder + .inner + .fail_restart_once + .store(true, Ordering::SeqCst); + assert!(recorder.service_restart("fixture.service").is_err()); + + let operations = recorder.get_ops(); + assert_eq!(operations[0].result, EffectResult::Changed(true)); + assert_eq!(operations[1].result, EffectResult::Failed); + let yaml = serde_yml::to_string(&operations).unwrap(); + assert!(!yaml.contains(secret)); + assert!(!yaml.contains(&hex::encode(sha2::Sha256::digest(secret.as_bytes())))); +} + +#[test] +fn file_recording_preserves_numeric_ownership() { + let files = MockFiles::new(); + let recorder = Recorder::new(files); + let ownership = Ownership { + uid: Some(OwnerIdentity::Id(1234)), + gid: Some(OwnerIdentity::Id(2345)), + }; + recorder + .ensure_file( + std::path::Path::new("/tmp/recorded-file"), + b"content", + Some(0o640), + &ownership, + ) + .unwrap(); + + let operations = recorder.get_ops(); + assert!(matches!( + &operations[0].operation, + ResourceOp::EnsureFile { + ownership: recorded_ownership, + .. + } if recorded_ownership == &ownership + )); +} diff --git a/ublue/skillet/crates/core/src/resource_contract_tests.rs b/ublue/skillet/crates/core/src/resource_contract_tests.rs new file mode 100644 index 00000000..8fe124f6 --- /dev/null +++ b/ublue/skillet/crates/core/src/resource_contract_tests.rs @@ -0,0 +1,59 @@ +use crate::{ + files::{FileMutationResource, FileReadResource, LocalFileResource, Ownership}, + test_utils::MockFiles, +}; +use std::path::Path; + +fn file_mutation_contract(resource: &(impl FileMutationResource + FileReadResource), path: &Path) { + assert_eq!(resource.read_file(path).unwrap(), None); + assert!(resource + .ensure_file(path, b"initial", Some(0o640), &Ownership::default()) + .unwrap()); + assert_eq!(resource.read_file(path).unwrap(), Some(b"initial".to_vec())); + assert!(!resource + .ensure_file(path, b"initial", Some(0o640), &Ownership::default()) + .unwrap()); + assert!(resource + .ensure_file(path, b"updated", Some(0o640), &Ownership::default()) + .unwrap()); + assert_eq!(resource.read_file(path).unwrap(), Some(b"updated".to_vec())); + assert!(resource.delete_file(path).unwrap()); + assert_eq!(resource.read_file(path).unwrap(), None); + assert!(!resource.delete_file(path).unwrap()); +} + +#[test] +fn local_file_resource_obeys_shared_mutation_contract() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("managed.conf"); + file_mutation_contract(&LocalFileResource::new(), &path); +} + +#[test] +fn mock_file_resource_obeys_shared_mutation_contract() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("managed.conf"); + file_mutation_contract(&MockFiles::new(), &path); +} + +#[test] +fn local_and_mock_file_resources_reject_file_directory_conflicts() { + let directory = tempfile::tempdir().unwrap(); + let local_path = directory.path().join("local-conflict"); + let mock_path = directory.path().join("mock-conflict"); + let local = LocalFileResource::new(); + let mock = MockFiles::new(); + + local + .ensure_file(&local_path, b"file", None, &Ownership::default()) + .unwrap(); + mock.ensure_file(&mock_path, b"file", None, &Ownership::default()) + .unwrap(); + + assert!(local + .ensure_directory(&local_path, None, &Ownership::default()) + .is_err()); + assert!(mock + .ensure_directory(&mock_path, None, &Ownership::default()) + .is_err()); +} 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..fc74d0e2 --- /dev/null +++ b/ublue/skillet/crates/core/src/resource_op.rs @@ -0,0 +1,70 @@ +use crate::files::Ownership; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub enum ResourceOp { + EnsureFile { + path: String, + mode: Option, + #[serde(default)] + ownership: Ownership, + }, + DeleteFile { + path: String, + }, + EnsureDirectory { + path: String, + mode: Option, + #[serde(default)] + ownership: Ownership, + }, + EnsureBtrfsSubvolume { + path: String, + }, + EnsureGroup { + name: String, + gid: Option, + }, + EnsureUser { + name: String, + uid: Option, + gid: Option, + }, + EnsurePodmanSecret { + name: String, + }, + ServiceStart { + name: String, + }, + ServiceStop { + name: String, + }, + ServiceRestart { + name: String, + }, + ServiceReload { + name: String, + }, + ServiceEnable { + name: String, + }, + DaemonReload, + RefreshUsbRules { + vendor: u16, + product: u16, + }, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +#[serde(tag = "status", content = "changed", rename_all = "snake_case")] +pub enum EffectResult { + Changed(bool), + Succeeded, + Failed, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct RecordedOperation { + pub operation: ResourceOp, + pub result: EffectResult, +} diff --git a/ublue/skillet/crates/core/src/system.rs b/ublue/skillet/crates/core/src/system.rs new file mode 100644 index 00000000..8647f67a --- /dev/null +++ b/ublue/skillet/crates/core/src/system.rs @@ -0,0 +1,509 @@ +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, get_user_by_uid}; +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), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserIdentity { + pub name: String, + pub uid: u32, + pub primary_gid: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GroupIdentity { + pub name: String, + pub gid: u32, +} + +/// Account lookups needed by host and container identity configuration. +/// Implementations are the only layer that consults NSS. +pub trait AccountLookupResource { + fn user_by_name(&self, name: &str) -> Result, SystemError>; + fn user_by_uid(&self, uid: u32) -> Result, SystemError>; + fn group_by_name(&self, name: &str) -> Result, SystemError>; +} + +pub trait AccountResource { + fn ensure_group(&self, name: &str, gid: Option) -> Result; + fn ensure_user( + &self, + name: &str, + uid: Option, + gid: Option, + ) -> Result; +} + +pub trait PodmanSecretResource { + fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result; + fn podman_secret_id(&self, name: &str) -> Result; +} + +pub trait ServiceResource { + fn service_is_active(&self, name: &str) -> Result; + fn service_start(&self, name: &str) -> Result<(), SystemError>; + fn service_stop(&self, name: &str) -> Result<(), SystemError>; + fn service_restart(&self, name: &str) -> Result<(), SystemError>; + fn service_reload(&self, name: &str) -> Result<(), SystemError>; + fn service_enable(&self, name: &str) -> Result<(), SystemError>; + fn daemon_reload(&self) -> Result<(), SystemError>; +} + +/// Aggregate retained for host composition and adapters while consumers move +/// to narrower account, service, and Podman-secret capabilities. +pub trait SystemResource: + AccountLookupResource + + AccountResource + + PodmanSecretResource + + ServiceResource + + crate::usb::UsbResource +{ +} + +impl SystemResource for T where + T: AccountLookupResource + + AccountResource + + PodmanSecretResource + + ServiceResource + + crate::usb::UsbResource +{ +} + +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 } + } + + /// Run a systemctl action via `DBus` where suitable, otherwise via the CLI. + /// + /// Start/restart operations use the CLI so the caller waits for the systemd + /// job result. Do not pass `--wait`: for a `RemainAfterExit` oneshot unit it + /// waits for the unit to stop rather than for its start job to complete. + fn run_systemctl(&self, action: &str, name: &str) -> Result<(), SystemError> { + let name_with_suffix = ensure_systemd_suffix(name); + + // D-Bus only queues the job; use the CLI to observe its result. + if matches!(action, "start" | "restart") { + return Self::run_systemctl_cli(action, &name_with_suffix); + } + + 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(_) => {} + Err(e) => { + warn!("DBus call failed, falling back to CLI: {e}"); + Self::run_systemctl_cli(action, &name_with_suffix)?; + } + } + } else { + Self::run_systemctl_cli(action, &name_with_suffix)?; + } + + Ok(()) + } + + /// Blocking `systemctl ` via the CLI. + /// + /// The default call blocks until the startup job completes. + fn run_systemctl_cli(action: &str, name_with_suffix: &str) -> Result<(), SystemError> { + info!("Running systemctl {action} {name_with_suffix} via CLI"); + let mut cmd = Command::new("systemctl"); + cmd.arg(action); + let output = cmd.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() + } +} + +impl AccountLookupResource for LinuxSystemResource { + fn user_by_name(&self, name: &str) -> Result, SystemError> { + Ok(get_user_by_name(name).map(|user| UserIdentity { + name: user.name().to_string_lossy().into_owned(), + uid: user.uid(), + primary_gid: user.primary_group_id(), + })) + } + + fn user_by_uid(&self, uid: u32) -> Result, SystemError> { + Ok(get_user_by_uid(uid).map(|user| UserIdentity { + name: user.name().to_string_lossy().into_owned(), + uid: user.uid(), + primary_gid: user.primary_group_id(), + })) + } + + fn group_by_name(&self, name: &str) -> Result, SystemError> { + Ok(get_group_by_name(name).map(|group| GroupIdentity { + name: group.name().to_string_lossy().into_owned(), + gid: group.gid(), + })) + } +} + +const EXIT_CODE_GROUP_EXISTS: i32 = 9; +const EXIT_CODE_USER_EXISTS: i32 = 9; + +impl AccountResource for LinuxSystemResource { + fn ensure_group(&self, name: &str, gid: Option) -> Result { + if let Some(grp) = get_group_by_name(name) { + debug!("Group {name} already exists"); + 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.or_else(|| get_group_by_name(name).map(|group| group.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) + && get_user_by_name(name).is_some() + { + 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) + } +} + +impl PodmanSecretResource for LinuxSystemResource { + fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { + let mut hasher = Sha256::new(); + hasher.update(payload.as_bytes()); + let hash = hex::encode(hasher.finalize()); + + let exists = Command::new("podman") + .args(["secret", "exists", name]) + .output()?; + let present = match exists.status.code() { + Some(0) => true, + Some(1) => false, + _ => { + return Err(SystemError::Command(format!( + "podman secret exists {name} failed: {}", + String::from_utf8_lossy(&exists.stderr) + ))) + } + }; + if present { + let inspect_output = Command::new("podman") + .args([ + "secret", + "inspect", + "--format", + "{{ index .Spec.Labels \"skillet.payload_hash\" }}", + name, + ]) + .output()?; + + if !inspect_output.status.success() { + return Err(SystemError::Command(format!( + "podman secret inspect {name} failed: {}", + String::from_utf8_lossy(&inspect_output.stderr) + ))); + } + let existing_hash = String::from_utf8_lossy(&inspect_output.stdout); + if existing_hash.trim() == hash { + debug!("Podman secret {name} already exists with correct hash"); + return Ok(false); + } + warn!("Podman secret {name} needs replacement"); + } + + info!("Creating or replacing podman secret {name}"); + let mut child = Command::new("podman") + .args([ + "secret", + "create", + "--replace", + "--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 podman_secret_id(&self, name: &str) -> Result { + let output = Command::new("podman") + .args(["secret", "inspect", "--format", "{{.ID}}", name]) + .output()?; + if !output.status.success() { + return Err(SystemError::Command(format!( + "podman secret inspect {name} failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if id.is_empty() { + return Err(SystemError::Command(format!( + "podman secret inspect {name} returned an empty ID" + ))); + } + Ok(id) + } +} + +impl ServiceResource for LinuxSystemResource { + fn service_is_active(&self, name: &str) -> Result { + let name = ensure_systemd_suffix(name); + let output = Command::new("systemctl") + .args(["is-active", "--quiet", &name]) + .output()?; + match output.status.code() { + Some(0) => Ok(true), + Some(3) => Ok(false), + _ => Err(SystemError::Command(format!( + "systemctl is-active {name} failed: {}", + String::from_utf8_lossy(&output.stderr) + ))), + } + } + + fn service_start(&self, name: &str) -> Result<(), SystemError> { + self.run_systemctl("start", name) + } + + 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 service_enable(&self, name: &str) -> Result<(), SystemError> { + let name_with_suffix = ensure_systemd_suffix(name); + // No DBus fast-path here: `systemctl enable` is idempotent and + // cheap, and the CLI keeps this simple. + info!("Enabling {name_with_suffix}"); + let output = Command::new("systemctl") + .arg("enable") + .arg(&name_with_suffix) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SystemError::Command(format!( + "systemctl enable {name_with_suffix} failed: {stderr}" + ))); + } + Ok(()) + } + + fn daemon_reload(&self) -> Result<(), SystemError> { + self.daemon_reload() + } +} + +#[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..d1272366 --- /dev/null +++ b/ublue/skillet/crates/core/src/system/tests.rs @@ -0,0 +1,170 @@ +#[cfg(feature = "test-utils")] +use super::{AccountLookupResource, AccountResource, ServiceResource}; +#[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" + ); +} + +#[test] +#[cfg(feature = "test-utils")] +fn mock_account_boundary_preserves_and_checks_numeric_identity() { + use super::AccountResource; + + let system = MockSystem::new(); + assert!(system + .ensure_user("service", Some(1042), Some(2042)) + .unwrap()); + assert_eq!( + system.user_by_name("service").unwrap(), + Some(super::UserIdentity { + name: "service".to_string(), + uid: 1042, + primary_gid: 2042, + }) + ); + assert_eq!(system.user_by_uid(1042).unwrap().unwrap().name, "service"); + assert!(!system + .ensure_user("service", Some(1042), Some(2042)) + .unwrap()); + assert!(system + .ensure_user("service", Some(1043), Some(2042)) + .is_err()); + assert!(system.group_by_name("service").unwrap().is_some()); +} + +fn account_lookup_contract( + resource: &impl super::AccountLookupResource, + expected_user: &super::UserIdentity, + expected_group: &super::GroupIdentity, +) { + assert_eq!( + resource.user_by_name(&expected_user.name).unwrap(), + Some(expected_user.clone()) + ); + assert_eq!( + resource.user_by_uid(expected_user.uid).unwrap(), + Some(expected_user.clone()) + ); + assert_eq!( + resource.group_by_name(&expected_group.name).unwrap(), + Some(expected_group.clone()) + ); +} + +fn service_resource_contract(resource: &impl super::ServiceResource) { + assert!(!resource.service_is_active("contract.service").unwrap()); + resource.service_start("contract.service").unwrap(); + assert!(resource.service_is_active("contract.service").unwrap()); + resource.service_reload("contract.service").unwrap(); + assert!(resource.service_is_active("contract.service").unwrap()); + resource.service_restart("contract.service").unwrap(); + assert!(resource.service_is_active("contract.service").unwrap()); + resource.service_stop("contract.service").unwrap(); + assert!(!resource.service_is_active("contract.service").unwrap()); +} + +#[test] +fn mock_reload_preserves_active_state_and_rejects_inactive_or_missing_units() { + use crate::{system::ServiceResource, test_utils::MockSystem}; + let system = MockSystem::new(); + assert!(system.service_reload("missing.service").is_err()); + system.service_enable("inactive.service").unwrap(); + assert!(system.service_reload("inactive.service").is_err()); + system.service_start("active.service").unwrap(); + system.service_reload("active.service").unwrap(); + assert!(system.service_is_active("active.service").unwrap()); +} + +fn podman_secret_resource_contract(resource: &impl super::PodmanSecretResource) { + assert!(resource.ensure_podman_secret("contract", "first").unwrap()); + let first = resource.podman_secret_id("contract").unwrap(); + assert!(!resource.ensure_podman_secret("contract", "first").unwrap()); + assert_eq!(resource.podman_secret_id("contract").unwrap(), first); + assert!(resource + .ensure_podman_secret("contract", "rotated") + .unwrap()); + assert_ne!(resource.podman_secret_id("contract").unwrap(), first); +} + +#[test] +fn mock_and_recorded_system_effects_follow_shared_contracts() { + use crate::{recorder::Recorder, test_utils::MockSystem}; + + let mock = MockSystem::new(); + service_resource_contract(&mock); + podman_secret_resource_contract(&mock); + + let recorder = Recorder::new(MockSystem::new()); + service_resource_contract(&recorder); + podman_secret_resource_contract(&recorder); +} + +#[test] +fn linux_and_mock_account_lookup_adapters_follow_shared_contract() { + use super::{GroupIdentity, LinuxSystemResource, UserIdentity}; + use crate::{system::AccountResource, test_utils::MockSystem}; + + let host_user = users::get_user_by_uid(users::get_current_uid()) + .expect("the test process must map to an account"); + let host_group = users::get_group_by_gid(host_user.primary_group_id()) + .expect("the test process primary group must exist"); + let user = UserIdentity { + name: host_user.name().to_string_lossy().into_owned(), + uid: host_user.uid(), + primary_gid: host_user.primary_group_id(), + }; + let group = GroupIdentity { + name: host_group.name().to_string_lossy().into_owned(), + gid: host_group.gid(), + }; + + let linux = LinuxSystemResource::new(); + account_lookup_contract(&linux, &user, &group); + + let mock = MockSystem::new(); + mock.ensure_group(&group.name, Some(group.gid)).unwrap(); + mock.ensure_user(&user.name, Some(user.uid), Some(user.primary_gid)) + .unwrap(); + account_lookup_contract(&mock, &user, &group); +} diff --git a/ublue/skillet/crates/core/src/templates.rs b/ublue/skillet/crates/core/src/templates.rs new file mode 100644 index 00000000..cb41761f --- /dev/null +++ b/ublue/skillet/crates/core/src/templates.rs @@ -0,0 +1,23 @@ +use crate::files::{FileError, FileResource, Ownership}; +use askama::Template; +use std::path::Path; + +pub fn ensure_templated_file( + files: &F, + path: &Path, + template: &T, + mode: Option, + ownership: &Ownership, +) -> 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, ownership) +} 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..159dc1f7 --- /dev/null +++ b/ublue/skillet/crates/core/src/test_utils.rs @@ -0,0 +1,594 @@ +use crate::files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}; +use crate::system::{ + AccountLookupResource, AccountResource, GroupIdentity, PodmanSecretResource, ServiceResource, + SystemError, UserIdentity, +}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +pub struct MockSystem { + pub usb_devices: Arc>>, + pub usb_refresh_count: Arc, + pub groups: Arc>>, + pub users: Arc>>, + pub user_identities: Arc>>, + pub group_identities: Arc>>, + pub podman_secrets: Arc>>, + pub secret_ids: Arc>>, + pub fail_restart_once: Arc, + pub fail_start_once: Arc, + pub fail_reload_once: Arc, + pub restart_count: Arc, + pub start_count: Arc, + pub services: Arc>>, // name -> state +} + +impl MockSystem { + pub fn new() -> Self { + Self { + usb_devices: Arc::new(Mutex::new(HashSet::new())), + usb_refresh_count: Arc::new(AtomicUsize::new(0)), + groups: Arc::new(Mutex::new(HashSet::new())), + users: Arc::new(Mutex::new(HashSet::new())), + user_identities: Arc::new(Mutex::new(HashMap::new())), + group_identities: Arc::new(Mutex::new(HashMap::new())), + podman_secrets: Arc::new(Mutex::new(HashSet::new())), + secret_ids: Arc::new(Mutex::new(HashMap::new())), + fail_restart_once: Arc::new(AtomicBool::new(false)), + fail_start_once: Arc::new(AtomicBool::new(false)), + fail_reload_once: Arc::new(AtomicBool::new(false)), + restart_count: Arc::new(AtomicUsize::new(0)), + start_count: Arc::new(AtomicUsize::new(0)), + services: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl Default for MockSystem { + fn default() -> Self { + Self::new() + } +} + +impl AccountLookupResource for MockSystem { + fn user_by_name(&self, name: &str) -> Result, SystemError> { + Ok(self + .user_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .cloned()) + } + + fn user_by_uid(&self, uid: u32) -> Result, SystemError> { + Ok(self + .user_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .find(|user| user.uid == uid) + .cloned()) + } + + fn group_by_name(&self, name: &str) -> Result, SystemError> { + Ok(self + .group_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .cloned()) + } +} + +impl AccountResource 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); + let mut identities = self + .group_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = identities.get(name) { + if gid.is_some_and(|desired| desired != existing.gid) { + return Err(SystemError::GroupCheck(format!( + "Group {name} exists with GID {}, requested {gid:?}", + existing.gid + ))); + } + return Ok(false); + } + groups.insert(name.to_string()); + identities.insert( + name.to_string(), + GroupIdentity { + name: name.to_string(), + gid: gid.unwrap_or(2000), + }, + ); + Ok(true) + } + + fn ensure_user( + &self, + name: &str, + uid: Option, + gid: Option, + ) -> Result { + if let Some(gid) = gid { + self.ensure_group(name, Some(gid))?; + } + let mut users = self + .users + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut identities = self + .user_identities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = identities.get(name) { + if uid.is_some_and(|desired| desired != existing.uid) + || gid.is_some_and(|desired| desired != existing.primary_gid) + { + return Err(SystemError::Command(format!( + "User {name} exists with UID:GID {}:{}, requested {uid:?}:{gid:?}", + existing.uid, existing.primary_gid + ))); + } + return Ok(false); + } + let uid = uid.unwrap_or(2000); + let primary_gid = gid.unwrap_or(2000); + users.insert(name.to_string()); + identities.insert( + name.to_string(), + UserIdentity { + name: name.to_string(), + uid, + primary_gid, + }, + ); + Ok(true) + } +} + +impl PodmanSecretResource for MockSystem { + fn podman_secret_id(&self, name: &str) -> Result { + self.secret_ids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .cloned() + .ok_or_else(|| SystemError::Command(format!("secret {name} missing"))) + } + + fn ensure_podman_secret(&self, name: &str, payload: &str) -> Result { + let mut secrets = self + .podman_secrets + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + secrets.insert(name.to_string()); + let id = hex::encode(Sha256::digest(payload.as_bytes())); + let mut ids = self + .secret_ids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ids.get(name) == Some(&id) { + Ok(false) + } else { + ids.insert(name.to_string(), id); + Ok(true) + } + } +} + +impl ServiceResource for MockSystem { + fn service_is_active(&self, name: &str) -> Result { + Ok(matches!( + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(name) + .map(String::as_str), + Some("started" | "restarted" | "reloaded") + )) + } + + fn service_start(&self, name: &str) -> Result<(), SystemError> { + self.start_count.fetch_add(1, Ordering::SeqCst); + if self.fail_start_once.swap(false, Ordering::SeqCst) { + return Err(SystemError::Command("injected start failure".to_string())); + } + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .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.restart_count.fetch_add(1, Ordering::SeqCst); + if self.fail_restart_once.swap(false, Ordering::SeqCst) { + return Err(SystemError::Command("injected restart failure".to_string())); + } + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(name.to_string(), "restarted".to_string()); + Ok(()) + } + + fn service_reload(&self, name: &str) -> Result<(), SystemError> { + let mut services = self + .services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match services.get(name).map(String::as_str) { + Some("started" | "restarted" | "reloaded") => { + services.insert(name.to_string(), "reloaded".to_string()); + Ok(()) + } + Some(state) => Err(SystemError::Command(format!( + "cannot reload inactive service {name} (state: {state})" + ))), + None => Err(SystemError::Command(format!("service {name} is missing"))), + } + } + + fn service_enable(&self, name: &str) -> Result<(), SystemError> { + let mut services = self + .services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + services + .entry(name.to_string()) + .or_insert_with(|| "enabled".to_string()); + Ok(()) + } + + fn daemon_reload(&self) -> Result<(), SystemError> { + if self.fail_reload_once.swap(false, Ordering::SeqCst) { + return Err(SystemError::Command("injected reload failure".to_string())); + } + self.services + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert("daemon-reload".to_string(), "reloaded".to_string()); + Ok(()) + } +} + +pub type FileMetadata = (Option, Ownership); +pub type DirectoryMetadata = (Option, Ownership); +pub type DirectoryMetadataMap = Arc>>; +pub type BtrfsMountMap = Arc>>; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BtrfsMount { + pub device: String, + pub root: String, + pub filesystem: String, +} + +pub struct MockFiles { + pub files: Arc>>>, + pub metadata: Arc>>, + pub directories: Arc>>, + pub btrfs_mounts: BtrfsMountMap, + pub btrfs_subvolumes: Arc>>, + pub directory_metadata: DirectoryMetadataMap, + pub fail_btrfs_mount_check: Arc, + pub fail_file_write_once: Arc, + pub fail_directory_once: 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())), + btrfs_mounts: Arc::new(Mutex::new(HashMap::new())), + btrfs_subvolumes: Arc::new(Mutex::new(HashSet::new())), + directory_metadata: Arc::new(Mutex::new(HashMap::new())), + fail_btrfs_mount_check: Arc::new(AtomicBool::new(false)), + fail_file_write_once: Arc::new(AtomicBool::new(false)), + fail_directory_once: Arc::new(AtomicBool::new(false)), + } + } +} + +impl Default for MockFiles { + fn default() -> Self { + Self::new() + } +} + +impl MockFiles { + /// Declare a mount-table entry for storage contract tests. + pub fn record_btrfs_mount(&self, path: &Path, device: &str, root: &str, filesystem: &str) { + let path = path.display().to_string(); + self.directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(path.clone()); + self.btrfs_mounts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + path, + BtrfsMount { + device: device.to_string(), + root: root.to_string(), + filesystem: filesystem.to_string(), + }, + ); + } + + /// Declare an existing Btrfs subvolume, including its directory ancestors. + pub fn record_btrfs_subvolume(&self, path: &Path) { + let path = path.display().to_string(); + self.btrfs_subvolumes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(path.clone()); + let mut directories = self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut current = Path::new(&path); + while let Some(parent) = current.parent() { + directories.insert(parent.display().to_string()); + current = parent; + } + directories.insert(path); + } +} + +impl StorageResource for MockFiles { + fn require_btrfs_subvolume_mount( + &self, + path: &Path, + backing_mount: &Path, + subvolume_root: &str, + ) -> Result<(), FileError> { + if self.fail_btrfs_mount_check.load(Ordering::SeqCst) { + Err(FileError::WrongMount(path.display().to_string())) + } else { + let mounts = self + .btrfs_mounts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let path_text = path.display().to_string(); + let mount = mounts + .get(&path_text) + .ok_or_else(|| FileError::MountMissing(path_text.clone()))?; + let backing = mounts.get(&backing_mount.display().to_string()); + if mount.filesystem == "btrfs" + && mount.root == subvolume_root + && backing.is_some_and(|backing| { + backing.device == mount.device && backing.filesystem == "btrfs" + }) + { + Ok(()) + } else { + Err(FileError::WrongMount(path_text)) + } + } + } + + fn require_btrfs_subvolume(&self, path: &Path) -> Result<(), FileError> { + if self + .btrfs_subvolumes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&path.display().to_string()) + { + Ok(()) + } else { + Err(FileError::NotASubvolume(path.display().to_string())) + } + } + + fn ensure_btrfs_subvolume(&self, path: &Path) -> Result { + let path_text = path.display().to_string(); + if self + .btrfs_subvolumes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&path_text) + { + return Ok(false); + } + if self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&path_text) + || self + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&path_text) + { + return Err(FileError::NotASubvolume(path_text)); + } + let parent = path + .parent() + .ok_or_else(|| FileError::InvalidPath(path_text.clone()))?; + if !self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&parent.display().to_string()) + { + return Err(FileError::ParentMissing(path_text)); + } + self.btrfs_subvolumes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(path_text.clone()); + self.directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(path_text); + Ok(true) + } +} + +impl FileReadResource for MockFiles { + fn read_file(&self, path: &Path) -> Result>, FileError> { + Ok(self + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&path.display().to_string()) + .cloned()) + } +} + +impl FileMutationResource for MockFiles { + fn ensure_file( + &self, + path: &Path, + content: &[u8], + mode: Option, + ownership: &Ownership, + ) -> Result { + let path_str = path.display().to_string(); + if self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&path_str) + { + return Err(FileError::NotAFile(path_str)); + } + if self.fail_file_write_once.swap(false, Ordering::SeqCst) { + return Err(FileError::Io(std::io::Error::other( + "injected file write failure", + ))); + } + let mut files = self + .files + .lock() + .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 current = metadata.get(&path_str).cloned().unwrap_or_default(); + let desired = ( + mode.or(current.0), + Ownership { + uid: ownership.uid.clone().or(current.1.uid), + gid: ownership.gid.clone().or(current.1.gid), + }, + ); + let unchanged = !changed && metadata.get(&path_str) == Some(&desired); + metadata.insert(path_str, desired); + changed |= !unchanged; + + Ok(changed) + } + + fn ensure_directory( + &self, + path: &Path, + mode: Option, + ownership: &Ownership, + ) -> Result { + let path_str = path.display().to_string(); + if self + .files + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(&path_str) + { + return Err(FileError::NotADirectory(path_str)); + } + if self.fail_directory_once.swap(false, Ordering::SeqCst) { + return Err(FileError::Io(std::io::Error::other( + "injected directory failure", + ))); + } + let mut directories = self + .directories + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let was_present = directories.contains(&path_str); + directories.insert(path_str.clone()); + drop(directories); + let mut metadata = self + .directory_metadata + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let current = metadata.get(&path_str).cloned().unwrap_or_default(); + let desired = ( + mode.or(current.0), + Ownership { + uid: ownership.uid.clone().or(current.1.uid), + gid: ownership.gid.clone().or(current.1.gid), + }, + ); + let unchanged = was_present && metadata.get(&path_str) == Some(&desired); + metadata.insert(path_str, desired); + Ok(!unchanged) + } + + fn delete_file(&self, path: &Path) -> Result { + 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) + } +} + +impl crate::usb::UsbResource for MockSystem { + fn usb_device_present(&self, vendor: u16, product: u16) -> Result { + Ok(self + .usb_devices + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&(vendor, product))) + } + fn refresh_usb_rules(&self, _vendor: u16, _product: u16) -> Result<(), SystemError> { + self.usb_refresh_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} diff --git a/ublue/skillet/crates/core/src/usb.rs b/ublue/skillet/crates/core/src/usb.rs new file mode 100644 index 00000000..bafdee90 --- /dev/null +++ b/ublue/skillet/crates/core/src/usb.rs @@ -0,0 +1,64 @@ +//! USB observations and rule refresh owned by the concrete host adapter. +use crate::system::{LinuxSystemResource, SystemError}; +use std::{fs, io, path::Path, process::Command}; + +pub trait UsbResource { + fn usb_device_present(&self, vendor: u16, product: u16) -> Result; + fn refresh_usb_rules(&self, vendor: u16, product: u16) -> Result<(), SystemError>; +} + +impl UsbResource for LinuxSystemResource { + fn usb_device_present(&self, vendor: u16, product: u16) -> Result { + device_present(Path::new("/sys/bus/usb/devices"), vendor, product) + } + fn refresh_usb_rules(&self, vendor: u16, product: u16) -> Result<(), SystemError> { + for args in [ + vec![ + "control".into(), + "--timeout=10".into(), + "--reload-rules".into(), + ], + vec![ + "trigger".into(), + "--action=add".into(), + "--subsystem-match=usb".into(), + format!("--attr-match=idVendor={vendor:04x}"), + format!("--attr-match=idProduct={product:04x}"), + ], + vec!["settle".into(), "--timeout=10".into()], + ] { + if !Command::new("udevadm").args(args).status()?.success() { + return Err(SystemError::Command("USB rule refresh failed".into())); + } + } + Ok(()) + } +} + +fn device_present(root: &Path, vendor: u16, product: u16) -> Result { + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + for entry in entries { + let entry = entry?; + let read_id = |name: &str| -> Result, SystemError> { + match fs::read_to_string(entry.path().join(name)) { + Ok(value) => u16::from_str_radix(value.trim(), 16) + .map(Some) + .map_err(|_| SystemError::Command("invalid USB identifier in sysfs".into())), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } + }; + if read_id("idVendor")? == Some(vendor) && read_id("idProduct")? == Some(product) { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(test)] +#[path = "usb/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/core/src/usb/tests.rs b/ublue/skillet/crates/core/src/usb/tests.rs new file mode 100644 index 00000000..94a7d6b1 --- /dev/null +++ b/ublue/skillet/crates/core/src/usb/tests.rs @@ -0,0 +1,15 @@ +use super::*; +#[test] +fn sysfs_device_lookup_is_exact_and_handles_absent_devices() { + let root = tempfile::tempdir().unwrap(); + let device = root.path().join("2-1"); + fs::create_dir(&device).unwrap(); + fs::write(device.join("idVendor"), "0764\n").unwrap(); + fs::write(device.join("idProduct"), "0501\n").unwrap(); + fs::create_dir(root.path().join("2-1:1.0")).unwrap(); + assert!(device_present(root.path(), 0x0764, 0x0501).unwrap()); + assert!(!device_present(root.path(), 0x0764, 0x0502).unwrap()); + assert!(!device_present(&root.path().join("absent"), 0x0764, 0x0501).unwrap()); + fs::write(device.join("idProduct"), "invalid").unwrap(); + assert!(device_present(root.path(), 0x0764, 0x0501).is_err()); +} diff --git a/ublue/skillet/crates/datadog/Cargo.toml b/ublue/skillet/crates/datadog/Cargo.toml new file mode 100644 index 00000000..fbc23036 --- /dev/null +++ b/ublue/skillet/crates/datadog/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skillet_datadog" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/datadog/examples/agent.rs b/ublue/skillet/crates/datadog/examples/agent.rs new file mode 100644 index 00000000..f040d0b4 --- /dev/null +++ b/ublue/skillet/crates/datadog/examples/agent.rs @@ -0,0 +1,29 @@ +//! Offline Quadlet fixture: no real containers, credentials, or host mutations. +use skillet_core::test_utils::{MockFiles, MockSystem}; +use skillet_datadog::{apply, RuntimeConfig}; +use std::path::Path; + +fn main() -> Result<(), Box> { + let files = MockFiles::new(); + files.record_btrfs_mount(Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount(Path::new("/var/lib/data"), "/dev/test", "/data", "btrfs"); + apply( + &MockSystem::new(), + &files, + r#"{"version":1,"api_key":"00000000000000000000000000000000","site":"datadoghq.eu","tags":["env:test"]}"#, + &RuntimeConfig { + hostname: "fixture-test-monitoring", + network_monitoring: true, + monitored_units: &["syncthing.service".into(), "btrbk.timer".into()], + }, + )?; + let state = files + .files + .lock() + .map_err(|_| std::io::Error::other("fixture lock poisoned"))?; + let quadlet = state + .get("/etc/containers/systemd/datadog-agent.container") + .ok_or_else(|| std::io::Error::other("fixture Quadlet missing"))?; + print!("{}", std::str::from_utf8(quadlet)?); + Ok(()) +} diff --git a/ublue/skillet/crates/datadog/examples/autodiscovery.rs b/ublue/skillet/crates/datadog/examples/autodiscovery.rs new file mode 100644 index 00000000..7a8c455b --- /dev/null +++ b/ublue/skillet/crates/datadog/examples/autodiscovery.rs @@ -0,0 +1,8 @@ +//! A passive fixture for checking labels with the real Quadlet generator. +fn main() { + println!("[Container]\nImage=registry.fedoraproject.org/fedora:latest"); + println!( + "{}", + skillet_datadog::http("syncthing", "http://%%host%%:8384/rest/noauth/health") + ); +} diff --git a/ublue/skillet/crates/datadog/src/lib.rs b/ublue/skillet/crates/datadog/src/lib.rs new file mode 100644 index 00000000..5edb82e4 --- /dev/null +++ b/ublue/skillet/crates/datadog/src/lib.rs @@ -0,0 +1,40 @@ +//! Service-owned, credential-free Datadog Autodiscovery declarations. +//! +//! Agent activation is separate from ordinary application convergence. +mod runtime; +pub use runtime::{apply, DatadogError, Input, RuntimeConfig, CREDENTIAL, DOGSTATSD_SOCKET, IMAGE}; +use serde_json::{json, Value}; + +fn label(integration: &str, instance: &Value) -> String { + let definition = json!({integration: {"init_config": {}, "instances": [instance]}}).to_string(); + // Quote the complete value for Quadlet's systemd word parser. Quadlet + // preserves specifiers; the generated service then expands %% to %. + let escaped = definition + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%"); + format!("Label=\"com.datadoghq.ad.checks={escaped}\"") +} + +/// An unauthenticated internal HTTP probe. Values must be public configuration; +/// credentials must never be supplied through container labels. +pub fn http(name: &str, url: &str) -> String { + label( + "http_check", + &json!({"name": name, "url": url, "timeout": 5, + "tags": [format!("service:{name}")]}), + ) +} + +/// Host-PID process liveness, distinct from application correctness. +pub fn process(name: &str, search: &str) -> String { + label( + "process", + &json!({"name": name, "search_string": [search], "exact_match": false, + "tags": [format!("service:{name}")]}), + ) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/datadog/src/runtime.rs b/ublue/skillet/crates/datadog/src/runtime.rs new file mode 100644 index 00000000..de0b0031 --- /dev/null +++ b/ublue/skillet/crates/datadog/src/runtime.rs @@ -0,0 +1,331 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use skillet_core::{ + files::{FileError, FileMutationResource, FileReadResource, Ownership, StorageResource}, + system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, + }, +}; +use skillet_podman::{ + MountDependency, NetworkAttachment, PodmanConfig, PodmanError, ProcessIdentity, QuadletSecret, + SecretTarget, Volume, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; + +pub const CREDENTIAL: &str = "datadog_config"; +/// Official Agent 7 index selected on 2026-10-06. +pub const IMAGE: &str = "registry.datadoghq.com/agent@sha256:161a43ad2b290f7527a70e70c8880c1b3d65b39411552a8cb41398049546daf5"; +const CONFIG_DIR: &str = "/etc/skillet/datadog/conf.d"; +pub const DOGSTATSD_DIR: &str = "/run/skillet-dogstatsd"; +pub const DOGSTATSD_SOCKET: &str = "/run/skillet-dogstatsd/dsd.socket"; + +#[derive(Debug, Error)] +pub enum DatadogError { + #[error("invalid Datadog configuration or credentials")] + Invalid, + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Podman(#[from] PodmanError), +} + +// Deliberately no Debug: this contains an API key and private tags. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Input { + version: u32, + api_key: String, + site: String, + #[serde(default)] + tags: Vec, +} + +impl Input { + pub fn render_for_environment( + mut self, + environment: &str, + production_region: &str, + ) -> Result { + if !matches!(environment, "prod" | "test") + || production_region.is_empty() + || !production_region + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(DatadogError::Invalid); + } + let expected = format!("env:{environment}"); + if self + .tags + .iter() + .any(|tag| tag.starts_with("env:") && tag != &expected) + { + return Err(DatadogError::Invalid); + } + if !self.tags.contains(&expected) { + self.tags.push(expected); + } + let region = if environment == "test" { + "lab" + } else { + production_region + }; + // Region is deployment policy: templates cannot move test telemetry + // into a production location or override the host caller's location. + self.tags.retain(|tag| !tag.starts_with("region:")); + self.tags.push(format!("region:{region}")); + serde_json::to_string(&self).map_err(|_| DatadogError::Invalid) + } + + pub fn parse(input: &str) -> Result { + let value: Self = serde_json::from_str(input).map_err(|_| DatadogError::Invalid)?; + if value.version != 1 + || value.api_key.len() != 32 + || !value.api_key.bytes().all(|byte| byte.is_ascii_hexdigit()) + || !matches!( + value.site.as_str(), + "datadoghq.com" + | "datadoghq.eu" + | "us3.datadoghq.com" + | "us5.datadoghq.com" + | "ap1.datadoghq.com" + | "ap2.datadoghq.com" + | "ddog-gov.com" + | "us2.ddog-gov.com" + ) + || value.tags.iter().any(|tag| { + tag.is_empty() + || tag.len() > 200 + || tag + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + }) + { + return Err(DatadogError::Invalid); + } + Ok(value) + } +} + +pub struct RuntimeConfig<'a> { + pub hostname: &'a str, + pub network_monitoring: bool, + pub monitored_units: &'a [String], +} + +pub fn apply( + system: &S, + files: &F, + input: &str, + runtime: &RuntimeConfig<'_>, +) -> Result<(), DatadogError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, +{ + let input = Input::parse(input)?; + if runtime.hostname.is_empty() + || runtime.hostname.len() > 63 + || !runtime + .hostname + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + || runtime.monitored_units.is_empty() + || runtime.monitored_units.iter().any(|unit| { + unit == "datadog-agent.service" + || !matches!(unit.rsplit_once('.'), Some((_, "service" | "timer"))) + || !unit.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'@') + }) + }) + { + return Err(DatadogError::Invalid); + } + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + let root = Ownership::named(Some("root"), Some("root")); + // The pinned Agent creates a write-only 0722 socket. Restrict traversal of + // its directory instead of relying on an unsupported socket-mode setting. + files.ensure_directory(Path::new(DOGSTATSD_DIR), Some(0o700), &root)?; + files.ensure_directory(Path::new("/etc/tmpfiles.d"), Some(0o755), &root)?; + files.ensure_file( + Path::new("/etc/tmpfiles.d/skillet-dogstatsd.conf"), + format!("d {DOGSTATSD_DIR} 0700 root root -\n").as_bytes(), + Some(0o644), + &root, + )?; + let mut revisions = Vec::new(); + for (name, instance) in [ + ("btrfs", json!({})), + ("network", json!({})), + ( + "tcp_check", + json!({"name": "host-ssh", "host": "127.0.0.1", "port": 22}), + ), + ( + "systemd", + json!({"unit_names": runtime.monitored_units, "private_socket": "/host/run/systemd/private"}), + ), + ] { + let directory = Path::new(CONFIG_DIR).join(format!("{name}.d")); + files.ensure_directory( + &directory, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + let config = json!({"init_config": {}, "instances": [instance]}).to_string(); + files.ensure_file( + &directory.join("conf.yaml"), + config.as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + revisions.push(config.into_bytes()); + } + let tags = input + .tags + .into_iter() + .chain(["service:host".into()]) + .collect::>() + .join(" "); + for (name, value) in [("datadog_api_key", input.api_key), ("datadog_tags", tags)] { + system.ensure_podman_secret(name, &value)?; + revisions.push(value.into_bytes()); + } + system.service_enable("podman.socket")?; + if !system.service_is_active("podman.socket")? { + system.service_start("podman.socket")?; + } + skillet_podman::container( + system, + files, + container_config(runtime, revisions, &input.site), + )?; + Ok(()) +} + +fn container_config( + runtime: &RuntimeConfig<'_>, + revisions: Vec>, + site: &str, +) -> PodmanConfig { + let mut settings = vec![ + "ContainerName=datadog-agent".into(), + "PodmanArgs=--pid=host --cgroupns=host --security-opt=label=disable".into(), + "Environment=DOCKER_HOST=unix:///run/podman/podman.sock".into(), + format!("Environment=DD_HOSTNAME={}", runtime.hostname), + format!("Environment=DD_SITE={site}"), + "Environment=DD_PROCESS_AGENT_ENABLED=true".into(), + "Environment=DD_APM_ENABLED=false".into(), + "Environment=DD_LOGS_ENABLED=false".into(), + "Environment=DD_USE_DOGSTATSD=true".into(), + "Environment=DD_DOGSTATSD_PORT=0".into(), + format!("Environment=DD_DOGSTATSD_SOCKET={DOGSTATSD_SOCKET}"), + ]; + if runtime.network_monitoring { + // Podman's default seccomp profile allows perf_event_open only when + // PERFMON is explicitly selected. SYS_ADMIN alone leaves the syscall + // at the profile's ENOSYS default even though the kernel supports it. + settings.extend([ + "Environment=DD_SYSTEM_PROBE_NETWORK_ENABLED=true".into(), + "Environment=DD_SYSTEM_PROBE_SERVICE_MONITORING_ENABLED=true".into(), + "AddCapability=SYS_ADMIN SYS_RESOURCE SYS_PTRACE NET_ADMIN NET_BROADCAST NET_RAW IPC_LOCK CHOWN PERFMON".into(), + ]); + } else { + settings.push("Environment=DD_SYSTEM_PROBE_ENABLED=false".into()); + } + let mut volumes = [ + ("/run/podman", "/run/podman"), + ("/proc", "/host/proc"), + ("/sys/fs/cgroup", "/host/sys/fs/cgroup"), + ("/run/systemd", "/host/run/systemd"), + ("/", "/host/root"), + ] + .into_iter() + .map(|(host, container)| Volume { + host_path: host.into(), + container_path: container.into(), + options: Some("ro".into()), + host_mode: None, + host_ownership: None, + }) + .collect::>(); + volumes.push(dogstatsd_volume()); + // Preserve the image's built-in integrations and auto-configuration. + for name in ["btrfs", "network", "tcp_check", "systemd"] { + volumes.push(Volume { + host_path: format!("{CONFIG_DIR}/{name}.d"), + container_path: format!("/etc/datadog-agent/conf.d/{name}.d"), + options: Some("ro".into()), + host_mode: None, + host_ownership: None, + }); + } + if runtime.network_monitoring { + volumes.push(Volume { + host_path: "/sys/kernel/debug".into(), + container_path: "/sys/kernel/debug".into(), + options: Some("ro".into()), + host_mode: None, + host_ownership: None, + }); + } + PodmanConfig { + name: "datadog-agent".into(), + image: IMAGE.into(), + network_attachments: vec![NetworkAttachment::Host], + port_publications: Vec::new(), + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::Numeric { uid: 0, gid: 0 }, + namespace_mapping: None, + volumes, + secrets: [ + ("datadog_api_key", "DD_API_KEY"), + ("datadog_tags", "DD_TAGS"), + ] + .into_iter() + .map(|(name, target)| QuadletSecret { + secret_name: name.into(), + target: SecretTarget::Environment { + env_var_name: target.into(), + }, + }) + .collect(), + config_revisions: revisions, + extra_config: BTreeMap::from([ + ("Container".into(), settings), + ( + "Unit".into(), + vec![ + "Requires=podman.socket".into(), + // The Agent's container metadata collector takes an initial + // inventory and then consumes runtime events. Order that + // inventory after concurrent application startup, without + // pulling in services whose credentials are not delivered. + format!( + "After=podman.socket network-online.target {}", + runtime.monitored_units.join(" ") + ), + ], + ), + ("Service".into(), vec!["Restart=always".into()]), + ("Install".into(), vec!["WantedBy=multi-user.target".into()]), + ]), + } +} + +fn dogstatsd_volume() -> Volume { + Volume { + host_path: DOGSTATSD_DIR.into(), + container_path: DOGSTATSD_DIR.into(), + options: Some("rw".into()), + host_mode: Some(0o700), + host_ownership: Some(Ownership::named(Some("root"), Some("root"))), + } +} + +#[cfg(test)] +#[path = "runtime/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/datadog/src/runtime/tests.rs b/ublue/skillet/crates/datadog/src/runtime/tests.rs new file mode 100644 index 00000000..f6c72f4b --- /dev/null +++ b/ublue/skillet/crates/datadog/src/runtime/tests.rs @@ -0,0 +1,246 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +fn payload(key: &str) -> String { + json!({"version":1,"api_key":key,"site":"datadoghq.eu","tags":["env:test"]}).to_string() +} + +fn mounted_files() -> MockFiles { + let files = MockFiles::new(); + files.record_btrfs_mount(Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount(Path::new("/var/lib/data"), "/dev/test", "/data", "btrfs"); + files +} + +fn runtime(network_monitoring: bool) -> RuntimeConfig<'static> { + RuntimeConfig { + hostname: "clamps-test-monitoring", + network_monitoring, + monitored_units: &[], + } +} + +fn run( + system: &MockSystem, + files: &MockFiles, + key: &str, + network: bool, +) -> Result<(), DatadogError> { + let units = vec!["syncthing.service".into(), "btrbk.timer".into()]; + let mut config = runtime(network); + config.monitored_units = &units; + apply(system, files, &payload(key), &config) +} + +#[test] +fn rootful_secrets_and_host_observation_are_explicit() { + let system = MockSystem::new(); + let files = mounted_files(); + run(&system, &files, "0123456789abcdef0123456789abcdef", true).unwrap(); + let state = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&state["/etc/containers/systemd/datadog-agent.container"]); + for expected in [ + "Network=host", + "User=0:0", + "--pid=host", + "--cgroupns=host", + "--security-opt=label=disable", + "AddCapability=SYS_ADMIN SYS_RESOURCE SYS_PTRACE NET_ADMIN NET_BROADCAST NET_RAW IPC_LOCK CHOWN PERFMON", + "Volume=/run/podman:/run/podman:ro", + "Environment=DOCKER_HOST=unix:///run/podman/podman.sock", + "Environment=DD_USE_DOGSTATSD=true", + "Environment=DD_DOGSTATSD_PORT=0", + "Environment=DD_DOGSTATSD_SOCKET=/run/skillet-dogstatsd/dsd.socket", + "Volume=/run/skillet-dogstatsd:/run/skillet-dogstatsd:rw", + "DD_HOSTNAME=clamps-test-monitoring", + "Secret=datadog_api_key,type=env,target=DD_API_KEY", + "/sys/kernel/debug", + ] { + assert!(quadlet.contains(expected), "missing {expected}: {quadlet}"); + } + assert!(!quadlet.contains("PublishPort=")); + assert!(!quadlet.contains("seccomp=unconfined")); + assert!(!quadlet.contains("--privileged")); + assert!(quadlet.contains("Volume=/sys/kernel/debug:/sys/kernel/debug:ro")); + assert!(quadlet.contains("/etc/datadog-agent/conf.d/btrfs.d:ro")); + assert!(!quadlet.contains("/etc/datadog-agent/conf.d:ro")); + assert!(!quadlet.contains("Environment=DD_API_KEY=")); + assert!(quadlet.contains("Environment=DD_SITE=datadoghq.eu")); + assert!(!quadlet.contains("Secret=datadog_site")); + assert!(!system + .podman_secrets + .lock() + .unwrap() + .contains("datadog_site")); + assert!(!quadlet.contains(":Z")); + assert!(state + .values() + .all(|bytes| !String::from_utf8_lossy(bytes).contains("0123456789abcdef"))); + let systemd = + String::from_utf8_lossy(&state["/etc/skillet/datadog/conf.d/systemd.d/conf.yaml"]); + assert!(systemd.contains("btrbk.timer")); + assert_eq!( + files.directory_metadata.lock().unwrap()[DOGSTATSD_DIR].0, + Some(0o700) + ); + assert!(state.contains_key("/etc/tmpfiles.d/skillet-dogstatsd.conf")); +} + +#[test] +fn instantiated_units_are_accepted_but_unsafe_names_are_rejected() { + for (unit, valid) in [ + ("nut-driver@cyberpower.service", true), + ("bad name.service", false), + ("bad%H.service", false), + ("bad\nunit.service", false), + ] { + let system = MockSystem::new(); + let files = mounted_files(); + let units = vec![unit.into()]; + let mut config = runtime(false); + config.monitored_units = &units; + assert_eq!( + apply( + &system, + &files, + &payload("0123456789abcdef0123456789abcdef"), + &config + ) + .is_ok(), + valid + ); + } +} + +#[test] +fn network_capabilities_are_optional() { + let system = MockSystem::new(); + let files = mounted_files(); + run(&system, &files, "0123456789abcdef0123456789abcdef", false).unwrap(); + let state = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&state["/etc/containers/systemd/datadog-agent.container"]); + assert!(!quadlet.contains("AddCapability=")); + assert!(!quadlet.contains("/sys/kernel/debug")); + assert!(quadlet.contains("DD_SYSTEM_PROBE_ENABLED=false")); +} + +#[test] +fn startup_order_follows_caller_units_without_enabling_them() { + let system = MockSystem::new(); + let files = mounted_files(); + let units = vec![ + "example-app.service".into(), + "example-optional.service".into(), + ]; + let mut config = runtime(false); + config.monitored_units = &units; + apply( + &system, + &files, + &payload("0123456789abcdef0123456789abcdef"), + &config, + ) + .unwrap(); + let state = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&state["/etc/containers/systemd/datadog-agent.container"]); + assert!(quadlet.contains( + "After=podman.socket network-online.target example-app.service example-optional.service" + )); + assert!(quadlet.lines().any(|line| line == "Requires=podman.socket")); + assert!(!quadlet.contains("Wants=")); + assert!(!quadlet.contains("Requires=example")); +} + +#[test] +fn rejects_agent_self_dependency_before_mutation() { + let system = MockSystem::new(); + let files = mounted_files(); + let units = vec!["datadog-agent.service".into()]; + let mut config = runtime(false); + config.monitored_units = &units; + assert!(apply( + &system, + &files, + &payload("0123456789abcdef0123456789abcdef"), + &config, + ) + .is_err()); + assert!(files.files.lock().unwrap().is_empty()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); +} + +#[test] +fn repeats_are_noops_and_rotation_retries_failed_activation() { + let system = MockSystem::new(); + let files = mounted_files(); + let first = "0123456789abcdef0123456789abcdef"; + let second = "1123456789abcdef0123456789abcdef"; + run(&system, &files, first, true).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + run(&system, &files, first, true).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(run(&system, &files, second, true).is_err()); + run(&system, &files, second, true).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts + 2); +} + +#[test] +fn rejects_missing_storage_bad_credentials_and_conflicting_environment() { + let system = MockSystem::new(); + let files = MockFiles::new(); + assert!(run(&system, &files, "0123456789abcdef0123456789abcdef", true).is_err()); + assert!(run(&system, &files, "invalid", true).is_err()); + assert!(files.files.lock().unwrap().is_empty()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); + let input = Input::parse(&payload("0123456789abcdef0123456789abcdef")).unwrap(); + assert!(input + .render_for_environment("prod", "example-region") + .is_err()); + let input = Input::parse(&payload("0123456789abcdef0123456789abcdef")).unwrap(); + assert!(input + .render_for_environment("test", "example-region") + .unwrap() + .contains("env:test")); +} + +#[test] +fn environment_and_region_follow_deployment_policy() { + for (environment, production_region, expected_region) in [ + ("prod", "example-region", "example-region"), + ("test", "example-region", "lab"), + ("test", "another-region", "lab"), + ] { + let payload = json!({"version":1,"api_key":"0123456789abcdef0123456789abcdef", + "site":"datadoghq.com","tags":["region:old", "region:duplicate", "role:sync"]}) + .to_string(); + let rendered = Input::parse(&payload) + .unwrap() + .render_for_environment(environment, production_region) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!( + value["tags"], + json!([ + "role:sync", + format!("env:{environment}"), + format!("region:{expected_region}") + ]) + ); + let again = Input::parse(&rendered) + .unwrap() + .render_for_environment(environment, production_region) + .unwrap(); + assert_eq!(rendered, again); + } + for (environment, region) in [("unknown", "valid"), ("prod", ""), ("test", "bad region")] { + assert!(Input::parse(&payload("0123456789abcdef0123456789abcdef")) + .unwrap() + .render_for_environment(environment, region) + .is_err()); + } +} diff --git a/ublue/skillet/crates/datadog/src/tests.rs b/ublue/skillet/crates/datadog/src/tests.rs new file mode 100644 index 00000000..76392b1d --- /dev/null +++ b/ublue/skillet/crates/datadog/src/tests.rs @@ -0,0 +1,31 @@ +use super::*; + +// Decode the systemd word, then account for ExecStart specifier expansion. +fn delivered_definition(label: &str) -> Value { + let quoted = label.strip_prefix("Label=").unwrap(); + let decoded: String = serde_json::from_str(quoted).unwrap(); + let expanded = decoded.replace("%%", "%"); + let definition = expanded.strip_prefix("com.datadoghq.ad.checks=").unwrap(); + serde_json::from_str(definition).unwrap() +} + +#[test] +fn http_probe_preserves_autodiscovery_host_after_systemd_expansion() { + let label = http("syncthing", "http://%%host%%:8384/rest/noauth/health"); + let definition = delivered_definition(&label); + let instance = &definition["http_check"]["instances"][0]; + assert_eq!(instance["url"], "http://%%host%%:8384/rest/noauth/health"); + assert_eq!(instance["tags"], json!(["service:syncthing"])); +} + +#[test] +fn process_probe_preserves_json_strings_and_has_no_exact_match() { + let label = process("example", "command --flag=\"value\""); + let definition = delivered_definition(&label); + let instance = &definition["process"]["instances"][0]; + assert_eq!( + instance["search_string"], + json!(["command --flag=\"value\""]) + ); + assert_eq!(instance["exact_match"], false); +} diff --git a/ublue/skillet/crates/ddns/Cargo.toml b/ublue/skillet/crates/ddns/Cargo.toml new file mode 100644 index 00000000..e5c0ebc6 --- /dev/null +++ b/ublue/skillet/crates/ddns/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "skillet_ddns" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_datadog.workspace = true +skillet_core.workspace = true +skillet_podman.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/ddns/src/config.rs b/ublue/skillet/crates/ddns/src/config.rs new file mode 100644 index 00000000..173e0963 --- /dev/null +++ b/ublue/skillet/crates/ddns/src/config.rs @@ -0,0 +1,203 @@ +//! Private input and the pinned updater's JSON contract. +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("invalid DDNS JSON or schema")] + Json, + #[error("unsupported DDNS config version")] + Version, + #[error("DDNS requires at least one valid, unique relative record name")] + Records, + #[error("DDNS record conflicts with a private UI record")] + Overlap, + #[error("invalid DDNS zone or credential")] + Credential, + #[error("unsupported DDNS address, TTL, or purge policy")] + Policy, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Record { + pub name: String, + pub proxied: bool, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateConfig { + pub version: u32, + pub records: Vec, + /// Required explicit approval before adopting an existing public A record. + #[serde(default)] + pub takeover_existing: bool, +} + +impl PrivateConfig { + pub fn parse(json: &str) -> Result { + let config: Self = serde_json::from_str(json).map_err(|_| ConfigError::Json)?; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> Result<(), ConfigError> { + if self.version != 1 { + return Err(ConfigError::Version); + } + let mut names = BTreeSet::new(); + if self.records.is_empty() + || self + .records + .iter() + .any(|record| !valid_dns_name(&record.name) || !names.insert(record.name.as_str())) + { + return Err(ConfigError::Records); + } + Ok(()) + } + + /// Resolve below the zone itself, independently of the private UI prefix. + pub fn validate_zone(&self, zone: &str, ui_records: &[String]) -> Result<(), ConfigError> { + self.validate()?; + if !valid_dns_name(zone) { + return Err(ConfigError::Credential); + } + for record in &self.records { + let name = format!("{}.{zone}", record.name); + if name.len() > 253 { + return Err(ConfigError::Records); + } + if ui_records.iter().any(|ui| ui.eq_ignore_ascii_case(&name)) { + return Err(ConfigError::Overlap); + } + } + Ok(()) + } + + pub fn payload(&self, zone_id: &str, token: &str) -> Result { + self.validate()?; + let payload = Payload { + cloudflare: vec![ZoneConfig { + authentication: Authentication { + api_token: token.to_string(), + }, + zone_id: zone_id.to_string(), + subdomains: self.records.clone(), + }], + a: true, + aaaa: false, + purge_unknown_records: false, + ttl: 300, + record_comment: None, + }; + payload.validate()?; + Ok(payload) + } +} + +// Deliberately no Debug: payloads contain credentials and private DNS names. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Payload { + cloudflare: Vec, + a: bool, + aaaa: bool, + #[serde(rename = "purgeUnknownRecords")] + purge_unknown_records: bool, + ttl: u32, + #[serde( + rename = "recordComment", + default, + skip_serializing_if = "Option::is_none" + )] + record_comment: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ZoneConfig { + authentication: Authentication, + zone_id: String, + subdomains: Vec, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Authentication { + api_token: String, +} + +impl Payload { + /// Distinct from UI ownership markers; the updater preserves this comment. + pub fn with_record_comment(mut self, comment: &str) -> Result { + if !comment.starts_with("skillet-ddns:") + || comment.len() > 200 + || comment.chars().any(char::is_control) + { + return Err(ConfigError::Credential); + } + self.record_comment = Some(comment.to_string()); + Ok(self) + } + pub fn parse(json: &str) -> Result { + let payload: Self = serde_json::from_str(json).map_err(|_| ConfigError::Json)?; + payload.validate()?; + Ok(payload) + } + + pub fn render(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| ConfigError::Json) + } + + fn validate(&self) -> Result<(), ConfigError> { + if self.record_comment.as_ref().is_some_and(|comment| { + !comment.starts_with("skillet-ddns:") + || comment.len() > 200 + || comment.chars().any(char::is_control) + }) { + return Err(ConfigError::Credential); + } + if !self.a || self.aaaa || self.purge_unknown_records || self.ttl != 300 { + return Err(ConfigError::Policy); + } + let [zone] = self.cloudflare.as_slice() else { + return Err(ConfigError::Credential); + }; + if zone.zone_id.len() != 32 + || !zone.zone_id.bytes().all(|byte| byte.is_ascii_hexdigit()) + || zone.authentication.api_token.is_empty() + || zone.authentication.api_token.trim() != zone.authentication.api_token + || zone.authentication.api_token.chars().any(char::is_control) + { + return Err(ConfigError::Credential); + } + PrivateConfig { + version: 1, + records: zone.subdomains.clone(), + takeover_existing: false, + } + .validate() + } +} + +fn valid_dns_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 253 + && name.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) +} + +#[cfg(test)] +#[path = "config/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/ddns/src/config/tests.rs b/ublue/skillet/crates/ddns/src/config/tests.rs new file mode 100644 index 00000000..906d2f9a --- /dev/null +++ b/ublue/skillet/crates/ddns/src/config/tests.rs @@ -0,0 +1,93 @@ +use super::*; + +const ZONE: &str = "0123456789abcdef0123456789abcdef"; + +#[test] +fn renders_updater_schema_and_matches_chef_policy() { + let config = + PrivateConfig::parse(r#"{"version":1,"records":[{"name":"edge","proxied":true}]}"#) + .unwrap(); + config + .validate_zone("example.com", &["syncthing.host.ui.example.com".into()]) + .unwrap(); + let rendered = config + .payload(ZONE, "dummy-token") + .unwrap() + .render() + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(json["a"], true); + assert_eq!(json["aaaa"], false); + assert_eq!(json["ttl"], 300); + assert_eq!(json["purgeUnknownRecords"], false); + assert_eq!(json["cloudflare"][0]["subdomains"][0]["name"], "edge"); + assert_eq!(json["cloudflare"][0]["subdomains"][0]["proxied"], true); + assert_eq!( + Payload::parse(&rendered).unwrap().render().unwrap(), + rendered + ); +} + +#[test] +fn rejects_unsupported_schema_names_and_duplicates() { + for input in [ + r#"{"version":2,"records":[{"name":"edge","proxied":false}]}"#, + r#"{"version":1,"records":[]}"#, + r#"{"version":1,"records":[{"name":"edge"}]}"#, + r#"{"version":1,"records":[{"name":"edge","proxied":false}],"token":"secret"}"#, + r#"{"version":1,"records":[{"name":"edge","proxied":false},{"name":"edge","proxied":true}]}"#, + ] { + assert!(PrivateConfig::parse(input).is_err()); + } + for name in [ + "", + "*", + "@", + "Edge", + "-edge", + "edge-", + "edge..local", + "edge.", + "../../etc", + "edge\n", + ] { + let input = + serde_json::json!({"version":1,"records":[{"name":name,"proxied":false}]}).to_string(); + assert!(PrivateConfig::parse(&input).is_err(), "accepted {name:?}"); + } +} + +#[test] +fn rejects_ui_overlap_and_overlong_resolved_names() { + let config = + PrivateConfig::parse(r#"{"version":1,"records":[{"name":"edge","proxied":false}]}"#) + .unwrap(); + assert!(config + .validate_zone("example.com", &["EDGE.EXAMPLE.COM".into()]) + .is_err()); + let zone = vec!["x".repeat(63); 4].join("."); + assert!(config.validate_zone(&zone, &[]).is_err()); +} + +#[test] +fn errors_do_not_include_secret_values() { + let Err(error) = Payload::parse(r#"{"cloudflare":"sensitive-do-not-print"}"#) else { + panic!("invalid payload accepted"); + }; + assert!(!format!("{error:?} {error}").contains("sensitive-do-not-print")); + let config = + PrivateConfig::parse(r#"{"version":1,"records":[{"name":"edge","proxied":false}]}"#) + .unwrap(); + assert!(config.payload("bad-zone", "dummy-token").is_err()); + let payload = config + .payload(ZONE, "dummy-token") + .unwrap() + .render() + .unwrap(); + let mut json: serde_json::Value = serde_json::from_str(&payload).unwrap(); + json["aaaa"] = true.into(); + assert!(Payload::parse(&json.to_string()).is_err()); + json["aaaa"] = false.into(); + json["purgeUnknownRecords"] = true.into(); + assert!(Payload::parse(&json.to_string()).is_err()); +} diff --git a/ublue/skillet/crates/ddns/src/lib.rs b/ublue/skillet/crates/ddns/src/lib.rs new file mode 100644 index 00000000..eb9bf185 --- /dev/null +++ b/ublue/skillet/crates/ddns/src/lib.rs @@ -0,0 +1,88 @@ +//! Caller-selected public-address DDNS using a secret-backed updater config. +pub mod config; + +use skillet_core::{ + files::{FileError, FileMutationResource, FileReadResource, StorageResource}, + system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, + }, +}; +use skillet_podman::{ + MountDependency, NetworkAttachment, PodmanConfig, PodmanError, ProcessIdentity, QuadletSecret, + SecretTarget, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; + +pub const CREDENTIAL: &str = "cloudflare_ddns_config"; +/// Version 2.2.0 multi-platform index, evaluated against the shipped binary. +pub const IMAGE: &str = "docker.io/timothyjmiller/cloudflare-ddns@sha256:5f2471be9efd9f0c95f973645cc87f05d501020ded94d380a2120fbfdf812d3d"; + +#[derive(Debug, Error)] +pub enum DdnsError { + #[error(transparent)] + Config(#[from] config::ConfigError), + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Podman(#[from] PodmanError), +} + +pub fn apply(system: &S, files: &F, input: &str, network: &str) -> Result<(), DdnsError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, +{ + let payload = config::Payload::parse(input)?.render()?; + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + system.ensure_podman_secret(CREDENTIAL, &payload)?; + skillet_podman::container( + system, + files, + PodmanConfig { + name: "cloudflare-ddns".into(), + image: IMAGE.into(), + network_attachments: vec![NetworkAttachment::Bridge(network.into())], + port_publications: Vec::new(), + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::Numeric { uid: 0, gid: 0 }, + namespace_mapping: None, + volumes: Vec::new(), + secrets: vec![QuadletSecret { + secret_name: CREDENTIAL.into(), + target: SecretTarget::File { + target_path: "/config.json".into(), + mode: Some("0400".into()), + uid: Some(0), + gid: Some(0), + }, + }], + config_revisions: vec![payload.into_bytes()], + extra_config: BTreeMap::from([ + ( + "Container".into(), + vec![ + "ContainerName=cloudflare-ddns".into(), + skillet_datadog::process("ddns", "cloudflare-ddns"), + ], + ), + ( + "Unit".into(), + vec![ + "After=network-online.target".into(), + "Wants=network-online.target".into(), + ], + ), + ("Service".into(), vec!["Restart=always".into()]), + ("Install".into(), vec!["WantedBy=multi-user.target".into()]), + ]), + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/ddns/src/tests.rs b/ublue/skillet/crates/ddns/src/tests.rs new file mode 100644 index 00000000..b42e712e --- /dev/null +++ b/ublue/skillet/crates/ddns/src/tests.rs @@ -0,0 +1,68 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +fn payload(token: &str) -> String { + config::PrivateConfig::parse(r#"{"version":1,"records":[{"name":"edge","proxied":false}]}"#) + .unwrap() + .payload("0123456789abcdef0123456789abcdef", token) + .unwrap() + .render() + .unwrap() +} + +fn mounted_files() -> MockFiles { + let files = MockFiles::new(); + files.record_btrfs_mount(Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount(Path::new("/var/lib/data"), "/dev/test", "/data", "btrfs"); + files +} + +#[test] +fn mounts_secret_as_root_without_publishing_ports_or_plaintext() { + let system = MockSystem::new(); + let files = mounted_files(); + apply(&system, &files, &payload("dummy-token"), "test-net").unwrap(); + let state = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&state["/etc/containers/systemd/cloudflare-ddns.container"]); + assert!(quadlet.contains(IMAGE)); + assert!(quadlet.contains("Network=test-net.network")); + assert!(quadlet.contains("User=0:0")); + assert!( + quadlet.contains("Secret=cloudflare_ddns_config,target=/config.json,mode=0400,uid=0,gid=0") + ); + assert!(quadlet.contains("Restart=always")); + assert!(quadlet.contains("WantedBy=multi-user.target")); + assert!(quadlet.contains("AssertPathIsMountPoint=/var/lib/data")); + assert!(!quadlet.contains("PublishPort=")); + assert!(!quadlet.contains("AutoUpdate=")); + assert!(state + .values() + .all(|bytes| !String::from_utf8_lossy(bytes).contains("dummy-token"))); +} + +#[test] +fn no_op_rotation_and_activation_retry() { + let system = MockSystem::new(); + let files = mounted_files(); + apply(&system, &files, &payload("dummy-one"), "test-net").unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + apply(&system, &files, &payload("dummy-one"), "test-net").unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(apply(&system, &files, &payload("dummy-two"), "test-net").is_err()); + apply(&system, &files, &payload("dummy-two"), "test-net").unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts + 2); +} + +#[test] +fn rejects_missing_storage_and_bad_input_without_mutation() { + for input in [payload("dummy-one"), "invalid".into()] { + let system = MockSystem::new(); + let files = MockFiles::new(); + assert!(apply(&system, &files, &input, "test-net").is_err()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); + assert!(files.files.lock().unwrap().is_empty()); + } +} diff --git a/ublue/skillet/crates/hardening/Cargo.toml b/ublue/skillet/crates/hardening/Cargo.toml new file mode 100644 index 00000000..9debebd4 --- /dev/null +++ b/ublue/skillet/crates/hardening/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "skillet_hardening" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +thiserror.workspace = true +tracing.workspace = true +sha2.workspace = true +hex.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } +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..1c206183 --- /dev/null +++ b/ublue/skillet/crates/hardening/files/sshd_config @@ -0,0 +1,174 @@ +# **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 +AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys.d/ignition + + +# 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..89806404 --- /dev/null +++ b/ublue/skillet/crates/hardening/src/lib.rs @@ -0,0 +1,150 @@ +use sha2::{Digest, Sha256}; +use skillet_core::activation::{self, ActivationRequest, ConsumerKind}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource, Ownership}; +use skillet_core::system::{ServiceResource, SystemError}; +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), + #[error("Activation error: {0}")] + Activation(#[from] skillet_core::activation::ActivationError), +} + +pub fn apply(system: &S, files: &F) -> Result<(), HardeningError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?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), + &Ownership::named(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 converge_service_file( + system: &S, + files: &F, + path: &Path, + content: &[u8], + mode: u32, + service: &str, +) -> Result<(), HardeningError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let state_dir = Path::new("/var/lib/skillet/hardening"); + files.ensure_directory( + state_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + let applied_path = state_dir.join(format!("{service}.applied")); + let revision = hex::encode(Sha256::digest(content)); + let changed = files.ensure_file( + path, + content, + Some(mode), + &Ownership::named(Some("root"), Some("root")), + )?; + activation::activate( + system, + files, + &ActivationRequest { + service: service.to_string(), + state_path: applied_path, + revision: revision.into_bytes(), + definition_changed: changed, + reload_daemon: false, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + Ok(()) +} + +fn apply_sysctl_hardening(system: &S, files: &F) -> Result<(), HardeningError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + info!("Applying sysctl hardening..."); + let sysctl_dir = Path::new("/etc/sysctl.d"); + files.ensure_directory( + sysctl_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + + let content = include_bytes!("../files/sysctl.boxy.conf"); + let path = sysctl_dir.join("99-hardening.conf"); + + converge_service_file(system, files, &path, content, 0o644, "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: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + info!("Applying ssh-hardening::server"); + let content = include_bytes!("../files/sshd_config"); + let path = Path::new("/etc/ssh/sshd_config"); + + converge_service_file(system, files, path, content, 0o600, "sshd")?; + + Ok(()) +} + +fn apply_ssh_hardening_client(_system: &S, files: &F) -> Result<(), HardeningError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?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), + &Ownership::named(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..ceda5fc2 --- /dev/null +++ b/ublue/skillet/crates/hardening/src/tests.rs @@ -0,0 +1,95 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +#[test] +fn interrupted_service_activation_is_retried() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(apply(&system, &files).is_err()); + assert!(!files + .files + .lock() + .unwrap() + .contains_key("/var/lib/skillet/hardening/systemd-sysctl.applied")); + apply(&system, &files).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + apply(&system, &files).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); +} + +#[test] +fn stopped_sshd_is_started_without_rewriting_config() { + let system = MockSystem::new(); + let files = MockFiles::new(); + apply(&system, &files).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + system.service_stop("sshd").unwrap(); + apply(&system, &files).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + assert_eq!(system.start_count.load(Ordering::SeqCst), 1); +} + +#[test] +fn test_hardening_applies_sysctl() { + 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/host-profiles/Cargo.toml b/ublue/skillet/crates/host-profiles/Cargo.toml new file mode 100644 index 00000000..08612791 --- /dev/null +++ b/ublue/skillet/crates/host-profiles/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "skillet_hosts" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_smtp.workspace = true +skillet_datadog.workspace = true +skillet_core.workspace = true +skillet_hardening.workspace = true +skillet_pihole.workspace = true +skillet_unifi.workspace = true +skillet_syncthing.workspace = true +skillet_mikochi.workspace = true +skillet_nut.workspace = true +skillet_btrbk.workspace = true +skillet_caddy.workspace = true +skillet_ddns.workspace = true +skillet_podman.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/host-profiles/src/hosts_tests.rs b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs new file mode 100644 index 00000000..8ef50f35 --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/hosts_tests.rs @@ -0,0 +1,744 @@ +use super::{ + profile::{ + AcceptanceOwner, HostId, HostProfile, HostService, NetworkPolicy, ServiceConfig, + UiServiceDeclaration, + }, + tailscale_config, ui_config_for_host, TAILSCALE_AUTH_KEY_CREDENTIAL, +}; +use skillet_core::{ + credentials::CredentialInputs, files::LocalFileResource, test_utils::MockSystem, +}; +use skillet_podman::SecretTarget; + +#[test] +fn datadog_is_optional_and_separately_credential_gated() { + let files = skillet_core::test_utils::MockFiles::new(); + let system = MockSystem::new(); + assert!(super::apply_host_phase( + "clamps", + super::HostApplyPhase::Datadog, + &system, + &files, + &CredentialInputs::default() + ) + .is_err()); + assert!(files.files.lock().unwrap().is_empty()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); + let clamps = super::profile_for_name("clamps").unwrap(); + assert!(clamps.supports_service("datadog")); + assert!(!super::profile_for_name("agent") + .unwrap() + .supports_service("datadog")); + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Datadog).unwrap(), + ["datadog_config"] + ); + assert!( + !super::credentials_for_phase("clamps", super::HostApplyPhase::Full) + .unwrap() + .contains(&"datadog_config") + ); + assert!(clamps + .acceptance_plan() + .services + .iter() + .all(|service| service.unit != "datadog-agent.service")); +} + +#[test] +fn boot_expectations_are_profile_inputs_and_unknown_profiles_are_refused() { + let clamps = super::boot_policy_for_host("clamps").unwrap(); + assert_eq!(clamps.signed_image, "ghcr.io/gbagnoli/ucore-clamps"); + assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); + let other = super::boot_policy_for_host("joey").unwrap(); + assert_eq!(other.masked_units, ["systemd-resolved.service"]); + assert_eq!(other.signed_image, "ghcr.io/gbagnoli/ucore-joey"); + assert!(super::boot_policy_for_host("clamps-test-smoke").is_none()); + assert!(super::boot_policy_for_host("unknown").is_none()); +} + +#[test] +fn clamps_tailscale_uses_host_network_and_persistent_state() { + let config = tailscale_config( + "clamps-test-smoke", + "test-auth-key".to_string(), + "/var/lib/data/tailscale", + false, + ); + + assert_eq!(config.name, "tailscale"); + assert_eq!(config.image, "docker.io/tailscale/tailscale:stable"); + assert_eq!( + config.network_attachments, + [skillet_podman::NetworkAttachment::Host] + ); + assert_eq!(config.volumes.len(), 2); + assert_eq!(config.volumes[0].host_path, "/var/lib/data/tailscale"); + assert_eq!(config.volumes[0].container_path, "/var/lib/tailscale"); + let container = &config.extra_config["Container"]; + assert!(container.contains(&"ContainerName=tailscale".to_string())); + assert_eq!( + config.network_attachments, + [skillet_podman::NetworkAttachment::Host] + ); + assert!(container.contains(&"AddCapability=NET_ADMIN".to_string())); + assert!(container.contains(&"AddCapability=NET_RAW".to_string())); + assert!(container.contains(&"AddDevice=/dev/net/tun:/dev/net/tun".to_string())); + assert!(container.contains(&"Environment=TS_AUTH_ONCE=true".to_string())); + assert!(container.contains(&"Environment=TS_ACCEPT_DNS=false".to_string())); + assert!(container.contains(&"Environment=TS_HOSTNAME=clamps-test-smoke".to_string())); + assert!(config.secrets.iter().any(|secret| { + secret.secret_name == TAILSCALE_AUTH_KEY_CREDENTIAL + && matches!( + &secret.target, + SecretTarget::Environment { env_var_name } if env_var_name == "TS_AUTHKEY" + ) + })); +} + +#[test] +fn profile_capabilities_are_the_authority_for_services_credentials_network_and_snapshots() { + let clamps = super::profile_for_name("clamps").unwrap(); + assert_eq!( + clamps + .services + .iter() + .map(HostService::name) + .collect::>(), + [ + "pihole", + "tailscale", + "syncthing", + "nut", + "unifi", + "ddns", + "datadog", + "btrbk", + "mikochi", + "smtp" + ] + ); + let credentials = clamps.credential_consumers(); + assert!(credentials.iter().any(|use_| { + use_.credential == super::PIHOLE_WEB_PASSWORD_CREDENTIAL && use_.unit == "pihole.service" + })); + assert!(credentials.iter().any(|use_| { + use_.credential == super::TAILSCALE_AUTH_KEY_CREDENTIAL && use_.unit == "tailscale.service" + })); + assert!(credentials.iter().any(|use_| { + use_.credential == super::CLOUDFLARE_ACME_TOKEN_CREDENTIAL && use_.unit == "caddy.service" + })); + assert_eq!( + clamps.btrbk_config().unwrap().snapshot_subvolumes, + [std::path::PathBuf::from("syncthing")] + ); + assert_eq!(clamps.signed_image, Some("ghcr.io/gbagnoli/ucore-clamps")); + assert_eq!(clamps.masked_units, ["systemd-resolved.service"]); + assert!(clamps.requires_data_mount); + assert!(clamps.requires_pihole_dns_listener_policy()); + let network = clamps.service_network(); + assert!(network.options.contains(&"IPv6=true".to_string())); + assert!(network + .options + .contains(&"Subnet=172.26.26.0/24".to_string())); + assert!(network + .options + .contains(&"Subnet=fd59:4e23:2950:11f5::/64".to_string())); + + let joey = super::profile_for_name("joey").unwrap(); + assert_eq!( + joey.services + .iter() + .map(HostService::name) + .collect::>(), + [ + "pihole", + "tailscale", + "syncthing", + "ddns", + "datadog", + "btrbk", + "mikochi", + "smtp" + ] + ); + assert!(!joey.supports_service("unifi")); + assert_eq!( + joey.btrbk_config().unwrap().snapshot_subvolumes, + [std::path::PathBuf::from("syncthing")] + ); + assert_eq!(joey.signed_image, Some("ghcr.io/gbagnoli/ucore-joey")); + assert_eq!(joey.masked_units, ["systemd-resolved.service"]); + assert!(joey.requires_data_mount); + assert!(joey.requires_pihole_dns_listener_policy()); + assert!(joey.tailscale_exit_node()); + assert!(joey + .services + .iter() + .any(|service| matches!(service.config, ServiceConfig::Pihole { custom_dns: [] }))); +} + +#[test] +fn agent_baseline_declares_no_ui_or_credentials_and_unknown_full_profiles_fail() { + let baseline = super::profile_for_name("agent").unwrap(); + assert_eq!(baseline.services, Vec::::new()); + assert_eq!( + baseline.ui_services(), + Vec::::new() + ); + assert_eq!( + baseline.credential_consumers(), + Vec::::new() + ); + assert!(super::ui_config_for_host("agent").is_none()); + let system = MockSystem::new(); + let files = LocalFileResource::new(); + assert!(matches!( + super::apply_host( + "unknown-host", + &system, + &files, + &CredentialInputs::default() + ), + Err(super::ApplyError::UnknownHost(_)) + )); + assert!(system.podman_secrets.lock().unwrap().is_empty()); +} + +#[test] +fn credential_values_are_selected_by_host_and_apply_phase() { + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Ddns).unwrap(), + [super::CLOUDFLARE_DDNS_CONFIG_CREDENTIAL] + ); + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Base).unwrap(), + Vec::<&str>::new() + ); + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Full).unwrap(), + [ + super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + super::TAILSCALE_AUTH_KEY_CREDENTIAL + ] + ); + assert_eq!( + super::credentials_for_phase("clamps", super::HostApplyPhase::Caddy).unwrap(), + [ + super::CADDY_SITES_CREDENTIAL, + super::CLOUDFLARE_ACME_TOKEN_CREDENTIAL + ] + ); + assert_eq!( + super::credentials_for_phase("joey", super::HostApplyPhase::Full).unwrap(), + [ + super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + super::TAILSCALE_AUTH_KEY_CREDENTIAL + ] + ); + assert!(super::credentials_for_phase("unknown", super::HostApplyPhase::Base).is_err()); +} + +#[test] +fn ddns_is_caller_selected_and_independently_credential_gated() { + let system = MockSystem::new(); + let files = skillet_core::test_utils::MockFiles::new(); + let credentials = CredentialInputs::default(); + assert!(super::apply_host_phase( + "clamps", + super::HostApplyPhase::Ddns, + &system, + &files, + &credentials + ) + .is_err()); + assert!(super::apply_host_phase( + "agent", + super::HostApplyPhase::Ddns, + &system, + &files, + &credentials + ) + .is_err()); + assert!(system.podman_secrets.lock().unwrap().is_empty()); + assert!(files.files.lock().unwrap().is_empty()); + let profile = super::profile_for_name("clamps").unwrap(); + assert!(profile.supports_service("ddns")); + assert!(!profile + .acceptance_plan() + .services + .iter() + .any(|service| service.unit == "cloudflare-ddns.service")); + assert!(profile + .acceptance_plan_with_ddns(true) + .services + .iter() + .any(|service| service.unit == "cloudflare-ddns.service")); + assert!(!super::profile_for_name("agent") + .unwrap() + .supports_service("ddns")); +} + +#[test] +fn host_ui_declarations_include_only_the_services_each_host_runs() { + let clamps = ui_config_for_host("clamps").expect("clamps UI declaration"); + assert_eq!( + clamps + .services + .iter() + .map(|service| service.name.as_str()) + .collect::>(), + ["pihole", "syncthing", "files"] + ); + assert_eq!(clamps.network_name, "clamps"); + assert_eq!(clamps.services[0].upstream, "pihole"); + assert_eq!(clamps.services[0].port, 8088); + assert_eq!(clamps.services[0].aliases, Vec::::new()); + assert_eq!(clamps.services[1].upstream, "syncthing"); + assert_eq!(clamps.services[1].port, 8384); + assert_eq!(clamps.services[1].aliases, ["sync.{host}"]); + + let joey = ui_config_for_host("joey").expect("joey UI declaration"); + assert_eq!(joey.services.len(), 3); + assert_eq!(joey.services[0].name, "pihole"); + assert_eq!(joey.services[1].name, "syncthing"); + assert_eq!(joey.services[1].aliases, ["sync.{host}"]); + assert_eq!(joey.network_name, "joey"); + assert!(ui_config_for_host("unknown-host").is_none()); +} + +#[test] +fn application_acceptance_tracks_each_profiles_declared_services() { + use super::{profile_for_name, HealthProbe, ListenerProtocol}; + + let clamps = profile_for_name("clamps").unwrap().acceptance_plan(); + let clamps_names = clamps + .services + .iter() + .map(|service| service.unit.as_str()) + .collect::>(); + assert_eq!( + clamps_names, + [ + "pihole.service", + "tailscale.service", + "syncthing.service", + "unifi.service", + "skillet-btrbk.timer", + "mikochi.service", + "caddy.service", + "skillet-tailnet-ui.service" + ] + ); + let pihole = clamps + .services + .iter() + .find(|service| service.unit == "pihole.service") + .unwrap(); + assert_eq!(pihole.health_probe, Some(HealthProbe::Pihole)); + assert_eq!(pihole.owner, None); + assert!(pihole + .listeners + .iter() + .any(|listener| listener.port == 53 && listener.protocol == ListenerProtocol::Udp)); + let syncthing = clamps + .services + .iter() + .find(|service| service.unit == "syncthing.service") + .unwrap(); + assert_eq!( + syncthing.owner, + Some(AcceptanceOwner::Named { + user: "giacomo".into(), + group: "giacomo".into() + }) + ); + let unifi = clamps + .services + .iter() + .find(|service| service.unit == "unifi.service") + .unwrap(); + assert_eq!( + unifi.owner, + Some(AcceptanceOwner::Numeric { uid: 999, gid: 999 }) + ); + + let joey = profile_for_name("joey").unwrap().acceptance_plan(); + assert_eq!( + joey.services + .iter() + .map(|service| service.unit.as_str()) + .collect::>(), + [ + "pihole.service", + "tailscale.service", + "syncthing.service", + "skillet-btrbk.timer", + "mikochi.service", + "caddy.service", + "skillet-tailnet-ui.service" + ] + ); + assert!(joey + .services + .iter() + .filter(|service| matches!( + service.container.as_deref(), + Some("pihole" | "syncthing" | "caddy") + )) + .all(|service| service.network_mode.as_deref() == Some("joey"))); +} + +#[test] +fn host_ids_validate_ascii_dns_label_syntax() { + for valid in ["a", "clamps", "remote-node-2"] { + assert_eq!( + HostId::parse(valid).as_ref().map(HostId::as_str), + Some(valid) + ); + } + let max_length = "a".repeat(63); + assert_eq!( + HostId::parse(&max_length).as_ref().map(HostId::as_str), + Some(max_length.as_str()) + ); + for invalid in [ + "", + "Upper", + "-leading", + "trailing-", + "has space", + "a.b", + "é", + ] { + assert!(HostId::parse(invalid).is_none(), "accepted {invalid:?}"); + } +} + +#[test] +fn synthetic_profile_derives_network_and_ui_from_its_identity() { + let profile = HostProfile { + id: HostId::parse("remote-node").unwrap(), + signed_image: None, + masked_units: &[], + network: NetworkPolicy { + ipv4_subnet: "192.0.2.0/24", + ipv4_gateway: "192.0.2.1", + ipv6_subnet: "2001:db8::/64", + ipv6_gateway: "2001:db8::1", + }, + requires_data_mount: false, + services: vec![HostService { + config: ServiceConfig::Syncthing { + files_path: "/var/lib/data/syncthing/data", + data_path: "/srv/sync", + data_owner: "sync", + data_group: "sync", + container_uid: 1000, + container_gid: 1000, + }, + ui: Some(UiServiceDeclaration { + name: "sync", + upstream: "sync", + port: 8384, + tailnet_identity: false, + aliases: &["files.{host}"], + }), + }], + }; + assert_eq!(profile.service_network().unit_name, "remote-node"); + assert_eq!(profile.ui_services()[0].name, "sync"); + assert_eq!(profile.ui_services()[0].aliases, ["files.{host}"]); + assert!(profile.credential_consumers().iter().any(|use_| { + use_.credential == super::CADDY_SITES_CREDENTIAL && use_.unit == "caddy.service" + })); +} + +#[test] +fn a_credential_service_remains_eligible_when_its_ui_is_not_exposed() { + let profile = HostProfile { + id: HostId::parse("pihole-node").unwrap(), + signed_image: None, + masked_units: &[], + network: NetworkPolicy { + ipv4_subnet: "192.0.2.0/24", + ipv4_gateway: "192.0.2.1", + ipv6_subnet: "2001:db8::/64", + ipv6_gateway: "2001:db8::1", + }, + requires_data_mount: false, + services: vec![HostService { + config: ServiceConfig::Pihole { custom_dns: &[] }, + ui: None, + }], + }; + assert_eq!( + profile.ui_services(), + Vec::::new() + ); + assert_eq!( + profile.credential_consumers(), + [super::CredentialConsumer { + credential: super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + unit: "pihole.service", + }] + ); +} + +#[test] +fn smtp_is_shared_and_has_an_independent_credential_phase() { + for host in ["clamps", "joey"] { + let profile = super::profile_for_name(host).unwrap(); + assert_eq!( + profile + .services + .iter() + .filter(|service| service.name() == "smtp") + .count(), + 1 + ); + assert_eq!( + super::credentials_for_phase(host, super::HostApplyPhase::Smtp).unwrap(), + [skillet_smtp::CREDENTIAL] + ); + assert!( + !super::credentials_for_phase(host, super::HostApplyPhase::Full) + .unwrap() + .contains(&skillet_smtp::CREDENTIAL) + ); + } +} + +#[test] +fn tailscale_exit_node_is_caller_selected_and_preferences_follow_readiness() { + assert!(super::profile_for_name("clamps") + .unwrap() + .tailscale_exit_node()); + assert!(!super::profile_for_name("agent") + .unwrap() + .tailscale_exit_node()); + for enabled in [false, true] { + let config = tailscale_config( + "fixture-host", + "fixture-key".into(), + "/var/lib/data/tailscale", + enabled, + ); + let container = &config.extra_config["Container"]; + assert!(container.contains(&format!( + "Environment=TS_EXTRA_ARGS=--advertise-exit-node={enabled}" + ))); + assert!(container.contains(&"Environment=TS_AUTH_ONCE=true".into())); + assert!(container.contains(&"Notify=healthy".into())); + assert!(container.contains(&"HealthCmd=tailscale status --peers=false".into())); + assert!(config.extra_config["Service"].contains(&format!("ExecStartPost=/usr/bin/podman exec tailscale tailscale set --advertise-exit-node={enabled}"))); + } +} + +#[test] +fn joey_boot_template_and_credential_unit_match_the_profile() { + let template = include_str!("../../../../butane/joey.bu"); + let image = include_str!("../../../../butane/includes/ucore-joey.bu"); + let unit = include_str!("../../../../butane/includes/skillet-full.bu"); + let profile = super::profile_for_name("joey").unwrap(); + assert!(template.contains("device: /dev/vda")); + for include in [ + "data-storage", + "resolver", + "skillet", + "skillet-full", + "skillet-joey", + "ucore-bootstrap", + "ucore-joey", + ] { + assert!(template.contains(&format!("local: includes/{include}.bu"))); + } + assert!(image.contains(profile.signed_image.unwrap())); + assert!(image.contains("name: systemd-resolved.service\n mask: true")); + for credential in super::credentials_for_phase("joey", super::HostApplyPhase::Full).unwrap() { + assert!(unit.contains(&format!("LoadCredentialEncrypted={credential}:/etc/credstore.encrypted/skillet/{credential}.cred"))); + } + assert!(unit.contains("apply --host-file /etc/skillet/host --phase full")); + assert!(super::profile_for_name("beezelbot").is_none()); +} + +#[test] +fn joey_full_apply_uses_shared_services_and_is_repeatable() { + use skillet_core::{ + system::{AccountResource, GroupIdentity, UserIdentity}, + test_utils::MockFiles, + }; + use std::{path::Path, sync::atomic::Ordering}; + let system = MockSystem::new(); + system.user_identities.lock().unwrap().insert( + "giacomo".into(), + UserIdentity { + name: "giacomo".into(), + uid: 1042, + primary_gid: 2047, + }, + ); + system.group_identities.lock().unwrap().insert( + "giacomo".into(), + GroupIdentity { + name: "giacomo".into(), + gid: 2047, + }, + ); + system.ensure_group("pihole", Some(2000)).unwrap(); + system + .ensure_user("pihole", Some(2000), Some(2000)) + .unwrap(); + let files = MockFiles::new(); + files.record_btrfs_mount(Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount(Path::new("/var/lib/data"), "/dev/test", "/data", "btrfs"); + for path in ["/etc/subuid", "/etc/subgid"] { + files + .files + .lock() + .unwrap() + .insert(path.into(), b"giacomo:100000:65536\n".to_vec()); + } + let mut credentials = CredentialInputs::default(); + credentials.insert( + super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + "fixture-password".into(), + ); + credentials.insert( + super::TAILSCALE_AUTH_KEY_CREDENTIAL, + super::tailscale::EnrollmentInput::new("fixture-auth-key".into(), false) + .payload() + .unwrap(), + ); + super::apply_host("joey", &system, &files, &credentials).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + let starts = system.start_count.load(Ordering::SeqCst); + let before = files.files.lock().unwrap().clone(); + super::apply_host("joey", &system, &files, &credentials).unwrap(); + assert_eq!(*files.files.lock().unwrap(), before); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + assert_eq!(system.start_count.load(Ordering::SeqCst), starts); + let pihole = + String::from_utf8(before["/etc/containers/systemd/pihole.container"].clone()).unwrap(); + assert!(pihole.contains("Network=joey.network")); + for address in ["0.0.0.0", "[::]"] { + for protocol in ["tcp", "udp"] { + assert!(pihole.contains(&format!("PublishPort={address}:53:53/{protocol}"))); + } + } + let tailscale = + String::from_utf8(before["/etc/containers/systemd/tailscale.container"].clone()).unwrap(); + assert!(tailscale.contains("Network=host")); + assert!(tailscale.contains("--advertise-exit-node=false")); + assert!(!before.contains_key("/etc/containers/systemd/unifi.container")); + assert!(!before.contains_key("/etc/containers/systemd/datadog-agent.container")); + assert!(!before.contains_key("/etc/containers/systemd/cloudflare-ddns.container")); +} + +#[test] +fn every_syncthing_caller_has_matching_private_file_browsing() { + for profile in super::declared_profiles() { + let syncthing = profile + .services + .iter() + .find(|service| matches!(service.config, ServiceConfig::Syncthing { .. })); + let files = profile + .services + .iter() + .find(|service| matches!(service.config, ServiceConfig::Mikochi { .. })); + assert_eq!(syncthing.is_some(), files.is_some()); + if let Some(files) = files { + let ui = files.ui.unwrap(); + assert_eq!(ui.name, "files"); + assert!(ui.tailnet_identity); + assert_eq!(ui.upstream, "mikochi"); + let plan = profile.acceptance_plan(); + let acceptance = plan + .services + .iter() + .find(|service| service.unit == "mikochi.service") + .unwrap(); + assert_eq!(acceptance.health_probe, Some(crate::HealthProbe::Mikochi)); + } + } +} + +#[test] +fn ups_opt_in_is_hardware_scoped_without_vm_or_ui_requirements() { + let clamps = super::profile_for_name("clamps").unwrap(); + assert!(clamps.supports_service("nut")); + assert!(!super::profile_for_name("joey") + .unwrap() + .supports_service("nut")); + let config = clamps + .services + .iter() + .find_map(|service| { + if let ServiceConfig::Nut(config) = service.config { + Some(config) + } else { + None + } + }) + .unwrap(); + assert_eq!( + (config.name, config.vendor, config.product), + ("cyberpower", 0x0764, 0x0501) + ); + assert!(!clamps.ui_services().iter().any(|ui| ui.name == "nut")); + assert!(!clamps + .acceptance_plan() + .services + .iter() + .any(|service| service.unit.starts_with("nut-"))); + assert!(!clamps + .credential_consumers() + .iter() + .any(|consumer| consumer.unit.starts_with("nut-"))); +} + +#[test] +fn nut_metrics_follow_the_datadog_phase_and_same_host_declaration() { + use skillet_core::{files::FileReadResource as _, test_utils::MockFiles}; + use std::{path::Path, sync::atomic::Ordering}; + for attached in [false, true] { + let files = MockFiles::new(); + files.record_btrfs_mount(Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount(Path::new("/var/lib/data"), "/dev/test", "/data", "btrfs"); + let system = MockSystem::new(); + if attached { + system.usb_devices.lock().unwrap().insert((0x0764, 0x0501)); + } + let mut credentials = CredentialInputs::default(); + credentials.insert("datadog_config", r#"{"version":1,"api_key":"00000000000000000000000000000000","site":"datadoghq.com","tags":["env:test"]}"#.into()); + super::apply_host_phase( + "clamps", + super::HostApplyPhase::Datadog, + &system, + &files, + &credentials, + ) + .unwrap(); + assert!(files + .read_file(Path::new("/etc/systemd/system/skillet-nut-metrics.timer")) + .unwrap() + .is_some()); + let config = files + .read_file(Path::new("/etc/skillet/datadog/conf.d/systemd.d/conf.yaml")) + .unwrap() + .unwrap(); + let config = String::from_utf8(config).unwrap(); + assert!(config.contains("skillet-nut-metrics.timer")); + assert_eq!(config.contains("nut-driver@cyberpower.service"), attached); + let before = system.restart_count.load(Ordering::SeqCst); + super::apply_host_phase( + "clamps", + super::HostApplyPhase::Datadog, + &system, + &files, + &credentials, + ) + .unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), before); + } +} diff --git a/ublue/skillet/crates/host-profiles/src/lib.rs b/ublue/skillet/crates/host-profiles/src/lib.rs new file mode 100644 index 00000000..413c7785 --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/lib.rs @@ -0,0 +1,587 @@ +//! Canonical host profiles and their shared composition. + +use skillet_core::{ + credentials::{CredentialError, CredentialInputs}, + files::{FileError, FileResource}, + system::{SystemError, SystemResource}, +}; +use skillet_podman::{PodmanConfig, ProcessIdentity, QuadletSecret, SecretTarget, Volume}; +use std::collections::BTreeMap; +use thiserror::Error; + +pub mod profile; +pub mod tailscale; +pub use profile::{ + declared_profiles, profile_for_host, profile_for_name, AcceptanceListener, AcceptanceOwner, + AcceptanceService, CredentialConsumer, HealthProbe, HostAcceptancePlan, HostId, HostProfile, + HostService, ListenerProtocol, ServiceConfig, UiServiceDeclaration, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HostApplyPhase { + Base, + Full, + Caddy, + Ddns, + Datadog, + Smtp, +} + +pub fn credentials_for_phase( + hostname: &str, + phase: HostApplyPhase, +) -> Result, ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; + if phase == HostApplyPhase::Base { + return Ok(Vec::new()); + } + Ok(profile + .credential_consumers() + .into_iter() + .filter(|consumer| match phase { + HostApplyPhase::Full => !matches!( + consumer.unit, + "caddy.service" + | "cloudflare-ddns.service" + | "datadog-agent.service" + | "postfix.service" + ), + HostApplyPhase::Caddy => consumer.unit == "caddy.service", + HostApplyPhase::Ddns => consumer.unit == "cloudflare-ddns.service", + HostApplyPhase::Datadog => consumer.unit == "datadog-agent.service", + HostApplyPhase::Smtp => consumer.unit == "postfix.service", + HostApplyPhase::Base => false, + }) + .map(|consumer| consumer.credential) + .collect()) +} + +/// Explicit guest boot expectations. Consolidated with the canonical capability +/// declaration in refactoring workstream 2; the VM orchestrator has no host cases. +pub struct HostBootPolicy { + pub signed_image: String, + pub masked_units: Vec<&'static str>, +} + +pub fn boot_policy_for_host(hostname: &str) -> Option { + let profile = profile_for_name(hostname)?; + let signed_image = profile.signed_image?; + Some(HostBootPolicy { + signed_image: signed_image.to_string(), + masked_units: profile.masked_units.to_vec(), + }) +} + +#[derive(Error, Debug)] +pub enum ApplyError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Credential error: {0}")] + Credential(#[from] CredentialError), + #[error("Hardening apply error: {0}")] + Hardening(String), + #[error("Pihole apply error: {0}")] + Pihole(#[from] skillet_pihole::PiholeError), + #[error("UniFi apply error: {0}")] + Unifi(#[from] skillet_unifi::UnifiError), + #[error("Syncthing apply error: {0}")] + Syncthing(#[from] skillet_syncthing::SyncthingError), + #[error(transparent)] + Nut(#[from] skillet_nut::NutError), + #[error(transparent)] + Mikochi(#[from] skillet_mikochi::MikochiError), + #[error("Btrbk apply error: {0}")] + Btrbk(#[from] skillet_btrbk::BtrbkError), + #[error("Caddy apply error: {0}")] + Caddy(#[from] skillet_caddy::CaddyError), + #[error("DDNS apply error: {0}")] + Ddns(#[from] skillet_ddns::DdnsError), + #[error("Datadog apply error: {0}")] + Datadog(#[from] skillet_datadog::DatadogError), + #[error("SMTP apply error: {0}")] + Smtp(#[from] skillet_smtp::SmtpError), + #[error("Podman error: {0}")] + Podman(#[from] skillet_podman::PodmanError), + #[error("Fixture input error: {0}")] + FixtureInput(String), + #[error("Unknown host profile: {0}")] + UnknownHost(String), +} + +/// Shared host baseline used by both CLI entry points. +pub fn apply_base(system: &dyn SystemResource, files: &dyn FileResource) -> Result<(), ApplyError> { + skillet_hardening::apply(system, files).map_err(|e| ApplyError::Hardening(e.to_string())) +} + +/// Name of the systemd credential (and podman secret) holding the Pi-hole +/// web UI password. Full `clamps` apply reads it from the systemd credential +/// directory supplied to that invocation. +pub const PIHOLE_WEB_PASSWORD_CREDENTIAL: &str = "pihole_web_password"; +/// Name of the systemd credential (and Podman secret) holding the one-use +/// Tailscale enrollment key used when the node has not joined yet. +pub const TAILSCALE_AUTH_KEY_CREDENTIAL: &str = "tailscale_auth_key"; +/// Credential containing private Caddy hostnames and the ACME environment. +pub const CADDY_SITES_CREDENTIAL: &str = "caddy_sites"; +/// Cloudflare token used only by Caddy's DNS-01 challenge provider. +pub const CLOUDFLARE_ACME_TOKEN_CREDENTIAL: &str = "cloudflare_acme_token"; +pub const CLOUDFLARE_DDNS_CONFIG_CREDENTIAL: &str = skillet_ddns::CREDENTIAL; + +/// UI services declared by a host. This is the canonical input used by Caddy +/// both on the workstation (to build delivery payloads) and on the host (to +/// validate and apply them). +pub struct HostUiConfig { + pub network_name: String, + pub services: Vec, +} + +pub fn ui_config_for_host(hostname: &str) -> Option { + let profile = profile_for_name(hostname)?; + let services = profile.ui_services(); + (!services.is_empty()).then(|| HostUiConfig { + network_name: profile.id.as_str().to_string(), + services, + }) +} + +fn tailscale_config( + hostname: &str, + auth_key: String, + state_path: &str, + advertise_exit_node: bool, +) -> PodmanConfig { + let extra_config = BTreeMap::from([ + ( + "Container".to_string(), + vec![ + "ContainerName=tailscale".to_string(), + skillet_datadog::process("tailscale", "tailscaled"), + "AddCapability=NET_ADMIN".to_string(), + "AddCapability=NET_RAW".to_string(), + "AddDevice=/dev/net/tun:/dev/net/tun".to_string(), + "AutoUpdate=registry".to_string(), + "Environment=TS_ACCEPT_DNS=false".to_string(), + "Environment=TS_AUTH_ONCE=true".to_string(), + format!("Environment=TS_EXTRA_ARGS=--advertise-exit-node={advertise_exit_node}"), + "Notify=healthy".to_string(), + "HealthCmd=tailscale status --peers=false".to_string(), + "HealthInterval=10s".to_string(), + "HealthStartPeriod=60s".to_string(), + format!("Environment=TS_HOSTNAME={hostname}"), + "Environment=TS_STATE_DIR=/var/lib/tailscale".to_string(), + "Environment=TS_SOCKET=/var/run/tailscale/tailscaled.sock".to_string(), + "Environment=TS_USERSPACE=false".to_string(), + ], + ), + ( + "Service".to_string(), + vec![ + "TimeoutStartSec=180".to_string(), + format!("ExecStartPost=/usr/bin/podman exec tailscale tailscale set --advertise-exit-node={advertise_exit_node}"), + ], + ), + ( + "Unit".to_string(), + vec!["After=network-online.target".to_string()], + ), + ]); + PodmanConfig { + name: "tailscale".to_string(), + image: "docker.io/tailscale/tailscale:stable".to_string(), + network_attachments: vec![skillet_podman::NetworkAttachment::Host], + port_publications: Vec::new(), + storage_dependency: Some(skillet_podman::MountDependency::shared_service_data()), + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, + volumes: vec![ + Volume { + host_path: state_path.to_string(), + container_path: "/var/lib/tailscale".to_string(), + options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, + }, + Volume { + host_path: "/run/skillet-tailscale".into(), + container_path: "/var/run/tailscale".into(), + options: Some("z".into()), + host_mode: Some(0o700), + host_ownership: Some(skillet_core::files::Ownership::named( + Some("root"), + Some("root"), + )), + }, + ], + secrets: vec![QuadletSecret { + secret_name: TAILSCALE_AUTH_KEY_CREDENTIAL.to_string(), + target: SecretTarget::Environment { + env_var_name: "TS_AUTHKEY".to_string(), + }, + }], + config_revisions: vec![auth_key.into_bytes()], + extra_config, + } +} + +/// Apply a service from a host declaration using only its declared config. +fn apply_syncthing( + system: &dyn SystemResource, + files: &dyn FileResource, + profile: &HostProfile, + service: &HostService, +) -> Result<(), ApplyError> { + let ServiceConfig::Syncthing { + files_path, + data_path, + data_owner, + data_group, + container_uid, + container_gid, + } = service.config + else { + return Err(ApplyError::FixtureInput( + "syncthing apply received an incompatible service declaration".to_string(), + )); + }; + skillet_syncthing::apply( + system, + files, + skillet_syncthing::SyncthingConfig { + files_path: files_path.to_string(), + data_path: data_path.to_string(), + data_owner: data_owner.to_string(), + data_group: data_group.to_string(), + container_uid, + container_gid, + network_name: profile.id.as_str().to_string(), + }, + )?; + Ok(()) +} + +/// Apply a canonical host profile. Unknown identities are rejected. +pub fn apply_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialInputs, +) -> Result<(), ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; + apply_profile(&profile, system, files, credentials) +} + +#[allow(clippy::similar_names)] +fn apply_profile( + profile: &HostProfile, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialInputs, +) -> Result<(), ApplyError> { + apply_base(system, files)?; + if profile.requires_data_mount { + files.require_btrfs_subvolume_mount( + std::path::Path::new("/var/lib/data"), + std::path::Path::new("/var"), + "/data", + )?; + } + if profile.requires_pihole_dns_listener_policy() { + skillet_podman::ensure_dns_listener_port(files, 54)?; + } + if profile.requires_service_network() { + skillet_podman::ensure_network(system, files, &profile.service_network())?; + } + for service in &profile.services { + match &service.config { + // Independently credential-gated optional services have their own phase. + ServiceConfig::Ddns | ServiceConfig::Datadog { .. } | ServiceConfig::Smtp => {} + ServiceConfig::Pihole { custom_dns } => { + let password = credentials + .require(PIHOLE_WEB_PASSWORD_CREDENTIAL)? + .to_string(); + system.ensure_podman_secret(PIHOLE_WEB_PASSWORD_CREDENTIAL, &password)?; + let user = system.user_by_name("pihole")?; + let group = system.group_by_name("pihole")?; + skillet_pihole::apply( + system, + files, + &skillet_pihole::PiholeUser { + uid: user.map(|identity| identity.uid), + gid: group.map(|identity| identity.gid), + name: "pihole".to_string(), + group_name: "pihole".to_string(), + }, + vec![QuadletSecret { + secret_name: PIHOLE_WEB_PASSWORD_CREDENTIAL.to_string(), + target: SecretTarget::File { + target_path: "/run/secrets/pihole_web_password".to_string(), + mode: Some("0400".to_string()), + uid: None, + gid: None, + }, + }], + custom_dns + .iter() + .map(|(ip, domain)| ((*ip).to_string(), (*domain).to_string())) + .collect::>(), + profile.id.as_str().to_string(), + )?; + } + ServiceConfig::Syncthing { .. } => apply_syncthing(system, files, profile, service)?, + ServiceConfig::Mikochi { .. } => apply_mikochi(system, files, profile, service)?, + ServiceConfig::Nut(config) => { + skillet_nut::apply(system, files, *config)?; + } + ServiceConfig::Unifi => skillet_unifi::apply(system, files)?, + ServiceConfig::Tailscale { + state_path, + advertise_exit_node, + } => { + let input = tailscale::EnrollmentInput::parse( + credentials.require(TAILSCALE_AUTH_KEY_CREDENTIAL)?, + ) + .map_err(|e| ApplyError::FixtureInput(e.to_string()))?; + if input.advertise_exit_node && !advertise_exit_node { + return Err(ApplyError::FixtureInput( + "host does not declare exit-node routing".into(), + )); + } + let auth_key = input.auth_key; + system.ensure_podman_secret(TAILSCALE_AUTH_KEY_CREDENTIAL, &auth_key)?; + let hostname = runtime_hostname(files, profile.id.as_str())?; + skillet_podman::container( + system, + files, + tailscale_config(&hostname, auth_key, state_path, input.advertise_exit_node), + )?; + } + ServiceConfig::Btrbk { .. } => { + let config = profile.btrbk_config().ok_or_else(|| { + ApplyError::FixtureInput("btrbk service lacks snapshot configuration".into()) + })?; + skillet_btrbk::apply(system, files, &config)?; + } + } + } + Ok(()) +} + +fn runtime_hostname(files: &dyn FileResource, fallback: &str) -> Result { + let hostname = files + .read_file(std::path::Path::new("/etc/hostname"))? + .map(|contents| { + String::from_utf8(contents).map_err(|error| ApplyError::FixtureInput(error.to_string())) + }) + .transpose()? + .unwrap_or_else(|| fallback.to_string()); + let hostname = hostname.trim(); + if hostname.is_empty() + || hostname.len() > 63 + || !hostname + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(ApplyError::FixtureInput( + "invalid runtime hostname in /etc/hostname".to_string(), + )); + } + Ok(hostname.to_string()) +} + +#[cfg(test)] +#[path = "hosts_tests.rs"] +mod tests; + +pub fn apply_host_phase( + hostname: &str, + phase: HostApplyPhase, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialInputs, +) -> Result<(), ApplyError> { + match phase { + HostApplyPhase::Base => apply_base(system, files), + HostApplyPhase::Full => apply_host(hostname, system, files, credentials), + HostApplyPhase::Caddy => apply_caddy_host(hostname, system, files, credentials), + HostApplyPhase::Ddns => apply_ddns_host(hostname, system, files, credentials), + HostApplyPhase::Datadog => apply_datadog_host(hostname, system, files, credentials), + HostApplyPhase::Smtp => { + profile_for_name(hostname) + .filter(|profile| profile.supports_service("smtp")) + .ok_or_else(|| ApplyError::FixtureInput("host does not declare SMTP".into()))?; + skillet_smtp::apply( + system, + files, + credentials.require(skillet_smtp::CREDENTIAL)?, + )?; + Ok(()) + } + } +} + +fn apply_ddns_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialInputs, +) -> Result<(), ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; + if !profile.supports_service("ddns") { + return Err(ApplyError::FixtureInput( + "host does not declare DDNS".into(), + )); + } + let payload = credentials.require(CLOUDFLARE_DDNS_CONFIG_CREDENTIAL)?; + // Reject malformed secret input before any network or container mutation. + skillet_ddns::config::Payload::parse(payload).map_err(skillet_ddns::DdnsError::from)?; + files.require_btrfs_subvolume_mount( + std::path::Path::new("/var/lib/data"), + std::path::Path::new("/var"), + "/data", + )?; + skillet_podman::ensure_network(system, files, &profile.service_network())?; + skillet_ddns::apply(system, files, payload, profile.id.as_str())?; + Ok(()) +} + +fn apply_datadog_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialInputs, +) -> Result<(), ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.into()))?; + let network_monitoring = profile + .services + .iter() + .find_map(|service| match service.config { + ServiceConfig::Datadog { + network_monitoring, .. + } => Some(network_monitoring), + _ => None, + }) + .ok_or_else(|| ApplyError::FixtureInput("host does not declare Datadog".into()))?; + let payload = credentials.require(skillet_datadog::CREDENTIAL)?; + let hostname = runtime_hostname(files, profile.id.as_str())?; + let mut monitored_units = profile + .acceptance_plan_with_ddns(true) + .services + .into_iter() + .map(|service| service.unit) + .collect::>(); + if profile.supports_service("smtp") { + monitored_units.extend( + skillet_smtp::MONITORED_UNITS + .iter() + .map(|unit| (*unit).to_string()), + ); + } + for service in &profile.services { + if let ServiceConfig::Nut(config) = service.config { + monitored_units.push(skillet_nut::METRICS_TIMER.into()); + if system.usb_device_present(config.vendor, config.product)? { + monitored_units.extend(skillet_nut::monitored_units(config)); + } + } + } + skillet_datadog::apply( + system, + files, + payload, + &skillet_datadog::RuntimeConfig { + hostname: &hostname, + network_monitoring, + monitored_units: &monitored_units, + }, + )?; + for service in &profile.services { + if let ServiceConfig::Nut(config) = service.config { + skillet_nut::apply_metrics( + system, + files, + config, + std::path::Path::new(skillet_datadog::DOGSTATSD_SOCKET), + )?; + } + } + Ok(()) +} + +fn apply_caddy_host( + hostname: &str, + system: &dyn SystemResource, + files: &dyn FileResource, + credentials: &CredentialInputs, +) -> Result<(), ApplyError> { + let profile = + profile_for_name(hostname).ok_or_else(|| ApplyError::UnknownHost(hostname.to_string()))?; + let services = profile.ui_services(); + if services.is_empty() { + return Err(ApplyError::FixtureInput(format!( + "host {hostname} declares no UI services" + ))); + } + let ui_config = HostUiConfig { + network_name: profile.id.as_str().to_string(), + services, + }; + if profile.requires_data_mount { + files.require_btrfs_subvolume_mount( + std::path::Path::new("/var/lib/data"), + std::path::Path::new("/var"), + "/data", + )?; + } + skillet_podman::ensure_network(system, files, &profile.service_network())?; + let sites = skillet_caddy::CaddySites::parse( + credentials.require(CADDY_SITES_CREDENTIAL)?, + hostname, + &ui_config.services, + )?; + let token = credentials + .require(CLOUDFLARE_ACME_TOKEN_CREDENTIAL)? + .to_string(); + system.ensure_podman_secret(CLOUDFLARE_ACME_TOKEN_CREDENTIAL, &token)?; + skillet_caddy::apply(system, files, &sites, &ui_config.network_name)?; + Ok(()) +} + +fn apply_mikochi( + system: &dyn SystemResource, + files: &dyn FileResource, + profile: &HostProfile, + service: &HostService, +) -> Result<(), ApplyError> { + let ServiceConfig::Mikochi { + files_path, + data_owner, + data_group, + container_uid, + container_gid, + } = service.config + else { + return Err(ApplyError::FixtureInput( + "Mikochi apply received an incompatible declaration".into(), + )); + }; + let network = skillet_caddy::private_network(profile.id.as_str(), "files"); + skillet_podman::ensure_network(system, files, &network)?; + skillet_mikochi::apply( + system, + files, + &skillet_mikochi::Config { + files_path, + owner: data_owner, + group: data_group, + container_uid, + container_gid, + private_network: &network.unit_name, + }, + )?; + Ok(()) +} diff --git a/ublue/skillet/crates/host-profiles/src/profile.rs b/ublue/skillet/crates/host-profiles/src/profile.rs new file mode 100644 index 00000000..f30a8f39 --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/profile.rs @@ -0,0 +1,744 @@ +//! Validated host identity and the single source of host capabilities. +use std::{borrow::Cow, path::PathBuf}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct HostId(Cow<'static, str>); + +impl HostId { + pub fn parse(value: &str) -> Option { + let valid = !value.is_empty() + && value.len() <= 63 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && value.as_bytes()[0].is_ascii_lowercase() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric(); + valid.then(|| Self(Cow::Owned(value.to_string()))) + } + + fn known(value: &'static str) -> Self { + Self(Cow::Borrowed(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UiServiceDeclaration { + pub name: &'static str, + pub upstream: &'static str, + pub port: u16, + pub tailnet_identity: bool, + pub aliases: &'static [&'static str], +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CredentialConsumer { + pub credential: &'static str, + pub unit: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceConfig { + Pihole { + custom_dns: &'static [(&'static str, &'static str)], + }, + Syncthing { + files_path: &'static str, + data_path: &'static str, + data_owner: &'static str, + data_group: &'static str, + container_uid: u32, + container_gid: u32, + }, + Mikochi { + files_path: &'static str, + data_owner: &'static str, + data_group: &'static str, + container_uid: u32, + container_gid: u32, + }, + Nut(skillet_nut::Config), + Unifi, + Smtp, + Ddns, + Datadog { + production_region: &'static str, + network_monitoring: bool, + }, + Tailscale { + advertise_exit_node: bool, + state_path: &'static str, + }, + Btrbk { + snapshot_subvolumes: &'static [&'static str], + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HostService { + pub config: ServiceConfig, + pub ui: Option, +} + +impl HostService { + pub fn name(&self) -> &'static str { + match self.config { + ServiceConfig::Pihole { .. } => "pihole", + ServiceConfig::Syncthing { .. } => "syncthing", + ServiceConfig::Mikochi { .. } => "mikochi", + ServiceConfig::Nut(_) => "nut", + ServiceConfig::Unifi => "unifi", + ServiceConfig::Ddns => "ddns", + ServiceConfig::Datadog { .. } => "datadog", + ServiceConfig::Smtp => "smtp", + ServiceConfig::Tailscale { .. } => "tailscale", + ServiceConfig::Btrbk { .. } => "btrbk", + } + } + + pub fn credential_consumers(&self) -> Vec { + match self.config { + ServiceConfig::Smtp => vec![CredentialConsumer { + credential: skillet_smtp::CREDENTIAL, + unit: "postfix.service", + }], + ServiceConfig::Datadog { .. } => vec![CredentialConsumer { + credential: skillet_datadog::CREDENTIAL, + unit: "datadog-agent.service", + }], + ServiceConfig::Ddns => vec![CredentialConsumer { + credential: super::CLOUDFLARE_DDNS_CONFIG_CREDENTIAL, + unit: "cloudflare-ddns.service", + }], + ServiceConfig::Pihole { .. } => vec![CredentialConsumer { + credential: super::PIHOLE_WEB_PASSWORD_CREDENTIAL, + unit: "pihole.service", + }], + ServiceConfig::Tailscale { .. } => vec![CredentialConsumer { + credential: super::TAILSCALE_AUTH_KEY_CREDENTIAL, + unit: "tailscale.service", + }], + _ => Vec::new(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NetworkPolicy { + pub ipv4_subnet: &'static str, + pub ipv4_gateway: &'static str, + pub ipv6_subnet: &'static str, + pub ipv6_gateway: &'static str, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HostProfile { + pub id: HostId, + pub signed_image: Option<&'static str>, + pub masked_units: &'static [&'static str], + pub network: NetworkPolicy, + pub requires_data_mount: bool, + pub services: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HostAcceptancePlan { + pub requires_data_mount: bool, + pub services: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcceptanceService { + pub unit: String, + pub container: Option, + pub network_mode: Option, + pub bind_paths: Vec, + pub owner: Option, + pub listeners: Vec, + pub health_probe: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AcceptanceOwner { + Named { user: String, group: String }, + Numeric { uid: u32, gid: u32 }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AcceptanceListener { + pub port: u16, + pub protocol: ListenerProtocol, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ListenerProtocol { + Tcp, + Udp, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HealthProbe { + Mikochi, + Pihole, + Tailscale, +} + +impl HostProfile { + pub fn tailscale_exit_node(&self) -> bool { + self.services.iter().any(|service| { + matches!( + service.config, + ServiceConfig::Tailscale { + advertise_exit_node: true, + .. + } + ) + }) + } + + /// Runtime checks follow the services and UI capabilities declared by + /// this profile, so fixture acceptance cannot stand in for application + /// acceptance. + pub fn acceptance_plan(&self) -> HostAcceptancePlan { + self.acceptance_plan_with_ddns(false) + } + + /// Public-address updates require a separate opt-in acceptance scenario; + /// ordinary application checks must not start an external DNS writer. + pub fn acceptance_plan_with_ddns(&self, with_ddns: bool) -> HostAcceptancePlan { + let mut services = self + .services + .iter() + .filter(|service| { + !matches!( + service.config, + ServiceConfig::Datadog { .. } | ServiceConfig::Smtp | ServiceConfig::Nut(_) + ) && (with_ddns || !matches!(service.config, ServiceConfig::Ddns)) + }) + .map(|service| acceptance_service(self.id.as_str(), service)) + .collect::>(); + if self.services.iter().any(|service| service.ui.is_some()) { + services.push(caddy_acceptance(self.id.as_str())); + if self.ui_services().iter().any(|ui| ui.tailnet_identity) { + services.push(AcceptanceService { + unit: "skillet-tailnet-ui.service".into(), + container: None, + network_mode: None, + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + }); + } + } + HostAcceptancePlan { + requires_data_mount: self.requires_data_mount, + services, + } + } + + pub fn supports_service(&self, name: &str) -> bool { + self.services.iter().any(|service| service.name() == name) + } + + pub fn requires_pihole_dns_listener_policy(&self) -> bool { + self.services + .iter() + .any(|service| matches!(service.config, ServiceConfig::Pihole { .. })) + } + + pub fn requires_service_network(&self) -> bool { + self.services.iter().any(|service| { + matches!( + service.config, + ServiceConfig::Pihole { .. } + | ServiceConfig::Syncthing { .. } + | ServiceConfig::Ddns + ) || service.ui.is_some() + }) + } + + pub fn requires_full_apply_credentials(&self) -> bool { + self.services.iter().any(|service| { + matches!( + service.config, + ServiceConfig::Pihole { .. } | ServiceConfig::Tailscale { .. } + ) + }) + } + + pub fn ui_services(&self) -> Vec { + self.services + .iter() + .filter_map(|service| service.ui.as_ref()) + .map(|ui| skillet_caddy::UiService { + name: ui.name.to_string(), + upstream: ui.upstream.to_string(), + port: ui.port, + tailnet_identity: ui.tailnet_identity, + aliases: ui + .aliases + .iter() + .map(|alias| (*alias).to_string()) + .collect(), + }) + .collect() + } + + pub fn credential_consumers(&self) -> Vec { + let mut consumers = self + .services + .iter() + .flat_map(HostService::credential_consumers) + .collect::>(); + if self.services.iter().any(|service| service.ui.is_some()) { + consumers.extend([ + CredentialConsumer { + credential: super::CADDY_SITES_CREDENTIAL, + unit: "caddy.service", + }, + CredentialConsumer { + credential: super::CLOUDFLARE_ACME_TOKEN_CREDENTIAL, + unit: "caddy.service", + }, + ]); + } + consumers + } + + pub fn service_network(&self) -> skillet_podman::PodmanNetwork { + let host = self.id.as_str(); + skillet_podman::PodmanNetwork { + unit_name: host.to_string(), + options: vec![ + "DisableDNS=false".to_string(), + "Driver=bridge".to_string(), + format!("Gateway={}", self.network.ipv4_gateway), + format!("Gateway={}", self.network.ipv6_gateway), + "IPv6=true".to_string(), + format!("NetworkName={host}"), + format!("Subnet={}", self.network.ipv4_subnet), + format!("Subnet={}", self.network.ipv6_subnet), + ], + } + } + + pub fn btrbk_config(&self) -> Option { + self.services + .iter() + .find_map(|service| match service.config { + ServiceConfig::Btrbk { + snapshot_subvolumes, + } => Some(skillet_btrbk::BtrbkConfig { + snapshot_subvolumes: snapshot_subvolumes + .iter() + .map(|subvolume| PathBuf::from(*subvolume)) + .collect(), + }), + _ => None, + }) + } +} + +fn acceptance_service(profile: &str, service: &HostService) -> AcceptanceService { + match service.config { + ServiceConfig::Nut(_) => AcceptanceService { + unit: "nut-monitor.service".into(), + container: None, + network_mode: None, + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + }, + ServiceConfig::Smtp => smtp_acceptance(), + ServiceConfig::Datadog { .. } => datadog_acceptance(), + ServiceConfig::Ddns => AcceptanceService { + unit: "cloudflare-ddns.service".into(), + container: Some("cloudflare-ddns".into()), + network_mode: Some(profile.into()), + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + }, + ServiceConfig::Pihole { .. } => AcceptanceService { + unit: "pihole.service".into(), + container: Some("pihole".into()), + network_mode: Some(profile.into()), + bind_paths: vec![ + "/var/lib/data/pihole/etc".into(), + "/var/lib/data/pihole/log".into(), + ], + // Pi-hole may change ownership of its writable bind directories + // during startup. The host profile does not declare numeric + // ownership for these paths, so acceptance validates their mounts + // and runtime without inventing an owner contract. + owner: None, + listeners: [ListenerProtocol::Tcp, ListenerProtocol::Udp] + .map(|protocol| AcceptanceListener { port: 53, protocol }) + .into(), + health_probe: Some(HealthProbe::Pihole), + }, + ServiceConfig::Syncthing { + data_path, + data_owner, + data_group, + .. + } => AcceptanceService { + unit: "syncthing.service".into(), + container: Some("syncthing".into()), + network_mode: Some(profile.into()), + bind_paths: vec![data_path.into()], + owner: Some(AcceptanceOwner::Named { + user: data_owner.into(), + group: data_group.into(), + }), + listeners: [ListenerProtocol::Tcp, ListenerProtocol::Udp] + .map(|protocol| AcceptanceListener { + port: 22000, + protocol, + }) + .into(), + health_probe: None, + }, + ServiceConfig::Mikochi { + files_path, + data_owner, + data_group, + .. + } => AcceptanceService { + unit: "mikochi.service".into(), + container: Some("mikochi".into()), + network_mode: Some(format!("{profile}-files")), + bind_paths: vec![files_path.into()], + owner: Some(AcceptanceOwner::Named { + user: data_owner.into(), + group: data_group.into(), + }), + listeners: Vec::new(), + health_probe: Some(HealthProbe::Mikochi), + }, + ServiceConfig::Unifi => unifi_acceptance(), + ServiceConfig::Tailscale { state_path, .. } => AcceptanceService { + unit: "tailscale.service".into(), + container: Some("tailscale".into()), + network_mode: Some("host".into()), + bind_paths: vec![state_path.into()], + owner: None, + listeners: Vec::new(), + health_probe: Some(HealthProbe::Tailscale), + }, + ServiceConfig::Btrbk { .. } => AcceptanceService { + unit: "skillet-btrbk.timer".into(), + container: None, + network_mode: None, + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + }, + } +} + +fn caddy_acceptance(profile: &str) -> AcceptanceService { + AcceptanceService { + unit: "caddy.service".into(), + container: Some("caddy".into()), + network_mode: Some(profile.into()), + bind_paths: vec!["/var/lib/data/caddy/data".into()], + owner: None, + listeners: vec![AcceptanceListener { + port: 443, + protocol: ListenerProtocol::Tcp, + }], + health_probe: None, + } +} + +const CLAMPS_DNS: &[(&str, &str)] = &[("192.168.1.100", "my.custom.domain")]; +const SYNCTHING_UI: UiServiceDeclaration = UiServiceDeclaration { + name: "syncthing", + upstream: "syncthing", + port: 8384, + tailnet_identity: false, + aliases: &["sync.{host}"], +}; + +fn clamps() -> HostProfile { + HostProfile { + id: HostId::known("clamps"), + signed_image: Some("ghcr.io/gbagnoli/ucore-clamps"), + masked_units: &["systemd-resolved.service"], + network: NetworkPolicy { + ipv4_subnet: "172.26.26.0/24", + ipv4_gateway: "172.26.26.1", + ipv6_subnet: "fd59:4e23:2950:11f5::/64", + ipv6_gateway: "fd59:4e23:2950:11f5::1", + }, + requires_data_mount: true, + services: vec![ + HostService { + config: ServiceConfig::Pihole { + custom_dns: CLAMPS_DNS, + }, + ui: Some(UiServiceDeclaration { + name: "pihole", + upstream: "pihole", + port: 8088, + tailnet_identity: false, + aliases: &[], + }), + }, + HostService { + config: ServiceConfig::Tailscale { + advertise_exit_node: true, + state_path: "/var/lib/data/tailscale", + }, + ui: None, + }, + HostService { + config: ServiceConfig::Syncthing { + files_path: "/var/lib/data/syncthing/data", + data_path: "/var/lib/data/syncthing", + data_owner: "giacomo", + data_group: "giacomo", + container_uid: 1000, + container_gid: 1000, + }, + ui: Some(SYNCTHING_UI), + }, + HostService { + config: ServiceConfig::Nut(skillet_nut::Config { + name: "cyberpower", + vendor: 0x0764, + product: 0x0501, + description: "CyberPower CP1500 AVR", + }), + ui: None, + }, + HostService { + config: ServiceConfig::Unifi, + ui: None, + }, + HostService { + config: ServiceConfig::Ddns, + ui: None, + }, + HostService { + config: ServiceConfig::Datadog { + production_region: "ftwo", + network_monitoring: true, + }, + ui: None, + }, + HostService { + config: ServiceConfig::Btrbk { + snapshot_subvolumes: &["syncthing"], + }, + ui: None, + }, + ], + } +} + +/// Boxy's replacement. Private DNS and routing inputs are delivered separately. +fn joey() -> HostProfile { + HostProfile { + id: HostId::known("joey"), + signed_image: Some("ghcr.io/gbagnoli/ucore-joey"), + masked_units: &["systemd-resolved.service"], + network: NetworkPolicy { + ipv4_subnet: "172.26.26.0/24", + ipv4_gateway: "172.26.26.1", + ipv6_subnet: "fd59:4e23:2950:11f5::/64", + ipv6_gateway: "fd59:4e23:2950:11f5::1", + }, + requires_data_mount: true, + services: vec![ + HostService { + config: ServiceConfig::Pihole { custom_dns: &[] }, + ui: Some(UiServiceDeclaration { + name: "pihole", + upstream: "pihole", + port: 8088, + tailnet_identity: false, + aliases: &[], + }), + }, + HostService { + config: ServiceConfig::Tailscale { + advertise_exit_node: true, + state_path: "/var/lib/data/tailscale", + }, + ui: None, + }, + HostService { + config: ServiceConfig::Syncthing { + files_path: "/var/lib/data/syncthing/data", + data_path: "/var/lib/data/syncthing", + data_owner: "giacomo", + data_group: "giacomo", + container_uid: 1000, + container_gid: 1000, + }, + ui: Some(SYNCTHING_UI), + }, + HostService { + config: ServiceConfig::Ddns, + ui: None, + }, + HostService { + config: ServiceConfig::Datadog { + production_region: "ftwo", + network_monitoring: true, + }, + ui: None, + }, + HostService { + config: ServiceConfig::Btrbk { + snapshot_subvolumes: &["syncthing"], + }, + ui: None, + }, + ], + } +} + +fn agent_baseline() -> HostProfile { + HostProfile { + id: HostId::known("agent"), + signed_image: None, + masked_units: &[], + network: NetworkPolicy { + ipv4_subnet: "172.26.26.0/24", + ipv4_gateway: "172.26.26.1", + ipv6_subnet: "fd59:4e23:2950:11f5::/64", + ipv6_gateway: "fd59:4e23:2950:11f5::1", + }, + requires_data_mount: false, + services: Vec::new(), + } +} + +/// Resolve a validated host identity to its declared configuration. +pub fn profile_for_host(host: &HostId) -> Option { + match host.as_str() { + "clamps" => Some(with_fleet_baseline(clamps())), + "joey" => Some(with_fleet_baseline(joey())), + "agent" => Some(agent_baseline()), + _ => None, + } +} + +pub fn profile_for_name(host: &str) -> Option { + HostId::parse(host).and_then(|id| profile_for_host(&id)) +} + +pub fn declared_profiles() -> Vec { + vec![ + with_fleet_baseline(clamps()), + with_fleet_baseline(joey()), + agent_baseline(), + ] +} + +fn with_fleet_baseline(mut profile: HostProfile) -> HostProfile { + // File browsing is part of the shared Syncthing composition, not a second + // independently maintained per-host inventory. + let file_browsers: Vec<_> = profile + .services + .iter() + .filter_map(|service| { + let ServiceConfig::Syncthing { + files_path, + data_owner, + data_group, + container_uid, + container_gid, + .. + } = service.config + else { + return None; + }; + Some(HostService { + config: ServiceConfig::Mikochi { + files_path, + data_owner, + data_group, + container_uid, + container_gid, + }, + ui: Some(UiServiceDeclaration { + name: "files", + upstream: "mikochi", + port: 8080, + aliases: &[], + tailnet_identity: true, + }), + }) + }) + .collect(); + profile.services.extend(file_browsers); + profile.services.push(HostService { + config: ServiceConfig::Smtp, + ui: None, + }); + profile +} + +fn smtp_acceptance() -> AcceptanceService { + AcceptanceService { + unit: "postfix.service".into(), + container: None, + network_mode: None, + bind_paths: Vec::new(), + owner: None, + listeners: Vec::new(), + health_probe: None, + } +} + +fn datadog_acceptance() -> AcceptanceService { + AcceptanceService { + unit: "datadog-agent.service".into(), + container: Some("datadog-agent".into()), + network_mode: Some("host".into()), + bind_paths: vec!["/etc/skillet/datadog/conf.d".into()], + owner: Some(AcceptanceOwner::Numeric { uid: 0, gid: 0 }), + listeners: Vec::new(), + health_probe: None, + } +} + +fn unifi_acceptance() -> AcceptanceService { + AcceptanceService { + unit: "unifi.service".into(), + container: Some("unifi".into()), + network_mode: Some("host".into()), + bind_paths: vec!["/var/lib/data/unifi".into()], + owner: Some(AcceptanceOwner::Numeric { uid: 999, gid: 999 }), + listeners: vec![ + AcceptanceListener { + port: 8080, + protocol: ListenerProtocol::Tcp, + }, + AcceptanceListener { + port: 8443, + protocol: ListenerProtocol::Tcp, + }, + AcceptanceListener { + port: 3478, + protocol: ListenerProtocol::Udp, + }, + AcceptanceListener { + port: 10001, + protocol: ListenerProtocol::Udp, + }, + ], + health_probe: None, + } +} diff --git a/ublue/skillet/crates/host-profiles/src/tailscale.rs b/ublue/skillet/crates/host-profiles/src/tailscale.rs new file mode 100644 index 00000000..8220ee8f --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/tailscale.rs @@ -0,0 +1,53 @@ +//! Typed encrypted enrollment payload; clear policy is resolved before delivery. +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +#[error("invalid Tailscale enrollment payload")] +pub struct InputError; + +// No Debug: auth_key is sensitive. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnrollmentInput { + version: u8, + pub auth_key: String, + pub advertise_exit_node: bool, +} + +impl EnrollmentInput { + pub fn new(auth_key: String, advertise_exit_node: bool) -> Self { + Self { + version: 1, + auth_key, + advertise_exit_node, + } + } + pub fn parse(payload: &str) -> Result { + let input = if payload.trim_start().starts_with('{') { + serde_json::from_str(payload).map_err(|_| InputError)? + } else { + // Retained raw credentials stay usable without implicitly enabling routing. + Self::new(payload.trim().to_string(), false) + }; + input.validate()?; + Ok(input) + } + pub fn payload(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| InputError) + } + fn validate(&self) -> Result<(), InputError> { + if self.version != 1 + || self.auth_key.trim().is_empty() + || self.auth_key.contains(['\n', '\r', '\0']) + { + return Err(InputError); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "tailscale/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/host-profiles/src/tailscale/tests.rs b/ublue/skillet/crates/host-profiles/src/tailscale/tests.rs new file mode 100644 index 00000000..b2e8d10f --- /dev/null +++ b/ublue/skillet/crates/host-profiles/src/tailscale/tests.rs @@ -0,0 +1,27 @@ +use super::*; +#[test] +fn explicit_payload_round_trips_and_legacy_defaults_to_no_exit_node() { + for enabled in [false, true] { + let input = EnrollmentInput::new("fixture-key".into(), enabled); + let parsed = EnrollmentInput::parse(&input.payload().unwrap()).unwrap(); + assert_eq!(parsed.auth_key, "fixture-key"); + assert_eq!(parsed.advertise_exit_node, enabled); + } + let legacy = EnrollmentInput::parse("fixture-key\n").unwrap(); + assert_eq!(legacy.auth_key, "fixture-key"); + assert!(!legacy.advertise_exit_node); +} +#[test] +fn malformed_versioned_input_is_rejected_without_echoing_values() { + for input in [ + "", + "{private-invalid", + r#"{"version":2,"auth_key":"private-key","advertise_exit_node":true}"#, + r#"{"version":1,"auth_key":"private-key","advertise_exit_node":true,"extra":1}"#, + ] { + assert_eq!( + EnrollmentInput::parse(input).err().unwrap().to_string(), + "invalid Tailscale enrollment payload" + ); + } +} diff --git a/ublue/skillet/crates/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..37c1a565 --- /dev/null +++ b/ublue/skillet/crates/hosts/clamps/src/main.rs @@ -0,0 +1,10 @@ +use anyhow::Result; +use skillet_cli_common::{hosts, run_host}; + +fn main() -> Result<()> { + run_host("clamps", |phase, system, files, credentials| { + hosts::apply_host_phase("clamps", phase, system, files, credentials) + .map_err(|e| e.to_string()) + })?; + Ok(()) +} diff --git a/ublue/skillet/crates/hosts/joey/Cargo.toml b/ublue/skillet/crates/hosts/joey/Cargo.toml new file mode 100644 index 00000000..9b014c7a --- /dev/null +++ b/ublue/skillet/crates/hosts/joey/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "skillet-joey" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_cli_common.workspace = true +anyhow = "1.0" diff --git a/ublue/skillet/crates/hosts/joey/src/main.rs b/ublue/skillet/crates/hosts/joey/src/main.rs new file mode 100644 index 00000000..4034065a --- /dev/null +++ b/ublue/skillet/crates/hosts/joey/src/main.rs @@ -0,0 +1,10 @@ +use anyhow::Result; +use skillet_cli_common::{hosts, run_host}; + +fn main() -> Result<()> { + run_host("joey", |phase, system, files, credentials| { + hosts::apply_host_phase("joey", phase, system, files, credentials) + .map_err(|e| e.to_string()) + })?; + Ok(()) +} diff --git a/ublue/skillet/crates/mikochi/Cargo.toml b/ublue/skillet/crates/mikochi/Cargo.toml new file mode 100644 index 00000000..47034ac8 --- /dev/null +++ b/ublue/skillet/crates/mikochi/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "skillet_mikochi" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +skillet_datadog.workspace = true +thiserror.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } +tempfile.workspace = true +reqwest = { workspace = true, features = ["multipart"] } +serde_json.workspace = true diff --git a/ublue/skillet/crates/mikochi/src/lib.rs b/ublue/skillet/crates/mikochi/src/lib.rs new file mode 100644 index 00000000..96e84a50 --- /dev/null +++ b/ublue/skillet/crates/mikochi/src/lib.rs @@ -0,0 +1,121 @@ +//! Private file browsing over a caller-declared user-file scope. +use skillet_core::{ + files::{FileError, FileMutationResource, FileReadResource, Ownership}, + system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, + }, +}; +use skillet_podman::{ + HostUser, MountDependency, NetworkAttachment, PodmanConfig, ProcessIdentity, + UserNamespaceMapping, Volume, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum MikochiError { + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Podman(#[from] skillet_podman::PodmanError), + #[error("Mikochi requires an existing data owner and its primary group")] + Owner, + #[error("Mikochi requires a dedicated file directory below /var/lib/data")] + Scope, +} + +pub struct Config<'a> { + pub files_path: &'a str, + pub owner: &'a str, + pub group: &'a str, + pub container_uid: u32, + pub container_gid: u32, + pub private_network: &'a str, +} + +pub fn apply(system: &S, files: &F, config: &Config<'_>) -> Result<(), MikochiError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let path = Path::new(config.files_path); + if !path.starts_with("/var/lib/data") + || path == Path::new("/var/lib/data") + || path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err(MikochiError::Scope); + } + let user = system + .user_by_name(config.owner)? + .ok_or(MikochiError::Owner)?; + let group = system + .group_by_name(config.group)? + .ok_or(MikochiError::Owner)?; + if user.primary_gid != group.gid { + return Err(MikochiError::Owner); + } + files.ensure_directory( + path, + Some(0o755), + &Ownership::named(Some(config.owner), Some(config.group)), + )?; + skillet_podman::container( + system, + files, + PodmanConfig { + name: "mikochi".into(), + image: "docker.io/zer0tonin/mikochi:1.12.0".into(), + network_attachments: vec![NetworkAttachment::Bridge(config.private_network.into())], + port_publications: Vec::new(), + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::Numeric { + uid: config.container_uid, + gid: config.container_gid, + }, + namespace_mapping: Some(UserNamespaceMapping { + host_user: HostUser::Name(config.owner.into()), + }), + volumes: vec![Volume { + host_path: config.files_path.into(), + container_path: "/data".into(), + options: Some("z".into()), + host_mode: None, + host_ownership: None, + }], + secrets: Vec::new(), + config_revisions: Vec::new(), + extra_config: BTreeMap::from([ + ( + "Container".into(), + vec![ + "ContainerName=mikochi".into(), + "Environment=NO_AUTH=true".into(), + "Environment=HOST=:8080".into(), + "Environment=DATA_DIR=/data".into(), + "Environment=FRONTEND_FILES=/usr/share/mikochi/static".into(), + "DropCapability=ALL".into(), + "NoNewPrivileges=true".into(), + skillet_datadog::process("mikochi", "mikochi"), + ], + ), + ("Service".into(), vec!["Restart=always".into()]), + ( + "Unit".into(), + vec![ + "Description=Mikochi private Syncthing file browser".into(), + "After=syncthing.service".into(), + ], + ), + ]), + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/mikochi/src/tests.rs b/ublue/skillet/crates/mikochi/src/tests.rs new file mode 100644 index 00000000..85daf10d --- /dev/null +++ b/ublue/skillet/crates/mikochi/src/tests.rs @@ -0,0 +1,223 @@ +use super::*; +use skillet_core::{ + system::{GroupIdentity, UserIdentity}, + test_utils::{MockFiles, MockSystem}, +}; +use std::os::unix::fs::MetadataExt as _; + +#[test] +fn private_scope_and_namespace_match_syncthing_without_publishing_a_port() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.user_identities.lock().unwrap().insert( + "owner".into(), + UserIdentity { + name: "owner".into(), + uid: 1042, + primary_gid: 2047, + }, + ); + system.group_identities.lock().unwrap().insert( + "owner".into(), + GroupIdentity { + name: "owner".into(), + gid: 2047, + }, + ); + files + .files + .lock() + .unwrap() + .insert("/etc/subuid".into(), b"owner:100000:65536\n".to_vec()); + files + .files + .lock() + .unwrap() + .insert("/etc/subgid".into(), b"owner:200000:65536\n".to_vec()); + let config = Config { + files_path: "/var/lib/data/syncthing/data", + owner: "owner", + group: "owner", + container_uid: 1000, + container_gid: 1000, + private_network: "example-files", + }; + apply(&system, &files, &config).unwrap(); + let definitions = files.files.lock().unwrap(); + let quadlet = + String::from_utf8_lossy(&definitions["/etc/containers/systemd/mikochi.container"]); + for directive in [ + "Network=example-files.network", + "Environment=NO_AUTH=true", + "User=1000:1000", + "Volume=/var/lib/data/syncthing/data:/data:z", + ] { + assert!(quadlet.contains(directive), "missing {directive}"); + } + assert!(!quadlet.contains("PublishPort")); + assert!(!quadlet.contains("config:/")); + let initial = quadlet.to_string(); + drop(definitions); + apply(&system, &files, &config).unwrap(); + assert_eq!( + initial.as_bytes(), + files.files.lock().unwrap()["/etc/containers/systemd/mikochi.container"] + ); +} + +struct TrialContainer(String); +impl Drop for TrialContainer { + fn drop(&mut self) { + let _ = std::process::Command::new("podman") + .args(["rm", "--force", &self.0]) + .output(); + } +} + +#[test] +#[ignore = "requires Podman and the Mikochi image"] +fn real_mikochi_has_no_second_login_and_supports_owned_file_operations() { + let temp = tempfile::tempdir().unwrap(); + let data = temp.path().join("data"); + std::fs::create_dir(&data).unwrap(); + let payload = vec![0x61; 3 * 1024 * 1024]; + std::fs::write(data.join("source file.txt"), &payload).unwrap(); + // A synchronized link must not expose a sibling host file outside the mount. + let outside = temp.path().join("private-host-file"); + std::fs::write(&outside, b"outside-host-scope").unwrap(); + std::os::unix::fs::symlink(&outside, data.join("external-link")).unwrap(); + let (_container, client, base) = start_trial(temp.path(), &data); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if client + .get(format!("{base}/ready")) + .send() + .is_ok_and(|response| response.status().is_success()) + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Mikochi did not become ready" + ); + std::thread::sleep(std::time::Duration::from_millis(200)); + } + assert!(client.get(&base).send().unwrap().status().is_success()); + let listed = client.get(format!("{base}/api/browse/")).send().unwrap(); + assert!(listed.status().is_success()); + assert!(listed.text().unwrap().contains("source file.txt")); + let download = client + .get(format!("{base}/api/stream/source%20file.txt")) + .send() + .unwrap(); + assert!(download.status().is_success()); + assert_eq!(download.bytes().unwrap().as_ref(), payload.as_slice()); + let linked = client + .get(format!("{base}/api/stream/external-link")) + .send() + .unwrap(); + assert!(!linked.status().is_success()); + assert!(!linked.text().unwrap().contains("outside-host-scope")); + assert!(client + .put(format!("{base}/api/mkdir/folder")) + .send() + .unwrap() + .status() + .is_success()); + let form = reqwest::blocking::multipart::Form::new().part( + "file", + reqwest::blocking::multipart::Part::bytes(payload.clone()).file_name("uploaded.txt"), + ); + assert!(client + .put(format!("{base}/api/upload/folder/uploaded.txt")) + .multipart(form) + .send() + .unwrap() + .status() + .is_success()); + assert_eq!( + std::fs::read(data.join("folder/uploaded.txt")).unwrap(), + payload + ); + assert!(client + .put(format!("{base}/api/move/folder/uploaded.txt")) + .json(&serde_json::json!({"newPath":"/folder/renamed.txt"})) + .send() + .unwrap() + .status() + .is_success()); + assert!(client + .delete(format!("{base}/api/delete/folder/renamed.txt")) + .send() + .unwrap() + .status() + .is_success()); + assert!(!data.join("folder/renamed.txt").exists()); + // User namespace mapping must preserve workstation ownership on written files. + assert_eq!( + std::fs::metadata(data.join("folder")).unwrap().uid(), + std::fs::metadata(&data).unwrap().uid() + ); +} + +fn start_trial( + temp: &std::path::Path, + data: &std::path::Path, +) -> (TrialContainer, reqwest::blocking::Client, String) { + let name = format!( + "skillet-mikochi-trial-{}-{}", + std::process::id(), + temp.file_name().unwrap().to_string_lossy() + ); + let volume = format!("{}:/data:z", data.display()); + let container = TrialContainer(name); + let run = std::process::Command::new("podman") + .args([ + "run", + "--detach", + "--name", + &container.0, + "--userns", + "keep-id:uid=1000,gid=1000", + "--user", + "1000:1000", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--publish", + "127.0.0.1::8080", + "--volume", + &volume, + "--env", + "HOST=:8080", + "--env", + "NO_AUTH=true", + "docker.io/zer0tonin/mikochi:1.12.0", + ]) + .output() + .unwrap(); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + let port = std::process::Command::new("podman") + .args(["port", &container.0, "8080/tcp"]) + .output() + .unwrap(); + assert!(port.status.success()); + let address: std::net::SocketAddr = String::from_utf8(port.stdout) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(address.ip().is_loopback()); + let base = format!("http://{address}"); + let client = reqwest::blocking::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap(); + (container, client, base) +} diff --git a/ublue/skillet/crates/nut/Cargo.toml b/ublue/skillet/crates/nut/Cargo.toml new file mode 100644 index 00000000..17c933ba --- /dev/null +++ b/ublue/skillet/crates/nut/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "skillet_nut" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +thiserror.workspace = true +sha2.workspace = true +uuid.workspace = true +tracing.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } +tempfile.workspace = true diff --git a/ublue/skillet/crates/nut/src/lib.rs b/ublue/skillet/crates/nut/src/lib.rs new file mode 100644 index 00000000..b05ad56d --- /dev/null +++ b/ublue/skillet/crates/nut/src/lib.rs @@ -0,0 +1,211 @@ +//! Optional native, loopback-only USB UPS protection. +use sha2::{Digest as _, Sha256}; +use skillet_core::{ + activation::{activate, ActivationRequest, ConsumerKind}, + files::{FileError, FileMutationResource, FileReadResource, Ownership}, + system::{ServiceResource, SystemError}, + usb::UsbResource, +}; +use std::path::Path; +use thiserror::Error; + +pub mod metrics; +mod monitoring; +pub use monitoring::{apply_metrics, METRICS_TIMER}; + +pub const MONITORED_UNITS: &[&str] = &["nut-server.service", "nut-monitor.service"]; +const STATE: &str = "/var/lib/skillet/nut"; +const PASSWORD: &str = "/var/lib/skillet/nut/monitor-password"; + +pub fn monitored_units(config: Config) -> Vec { + MONITORED_UNITS + .iter() + .map(|unit| (*unit).to_string()) + .chain(std::iter::once(format!( + "nut-driver@{}.service", + config.name + ))) + .collect() +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Config { + pub name: &'static str, + pub vendor: u16, + pub product: u16, + pub description: &'static str, +} + +#[derive(Debug, Error)] +pub enum NutError { + #[error("invalid UPS declaration")] + InvalidConfig, + #[error("invalid retained NUT monitor credential; refusing replacement")] + InvalidCredential, + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Activation(#[from] skillet_core::activation::ActivationError), +} + +#[derive(Debug, Eq, PartialEq)] +pub enum Outcome { + ConfiguredWithoutDevice, + Active, +} + +impl Config { + fn validate(self) -> Result<(), NutError> { + if metrics::validate_name(self.name).is_err() + || self.description.is_empty() + || !self + .description + .bytes() + .all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\' || b == b' ') + { + return Err(NutError::InvalidConfig); + } + Ok(()) + } + pub fn ups_config(self) -> Result { + self.validate()?; + Ok(format!("[{}]\n driver = usbhid-ups\n port = auto\n vendorid = {:04x}\n productid = {:04x}\n desc = \"{}\"\n", self.name, self.vendor, self.product, self.description)) + } +} + +pub fn apply(system: &S, files: &F, config: Config) -> Result +where + S: ServiceResource + UsbResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + config.validate()?; + let root = Ownership::named(Some("root"), Some("root")); + let nut = Ownership::named(Some("root"), Some("nut")); + files.ensure_directory(Path::new(STATE), Some(0o700), &root)?; + let password = monitor_password(files)?; + files.ensure_directory(Path::new("/etc/ups"), Some(0o750), &nut)?; + let ups = config.ups_config()?; + let server = "LISTEN 127.0.0.1 3493\nLISTEN ::1 3493\n"; + let users = format!("[skillet-monitor]\n password = {password}\n upsmon primary\n"); + let monitor = format!("MONITOR {}@127.0.0.1 1 skillet-monitor {password} primary\nMINSUPPLIES 1\nSHUTDOWNCMD \"/usr/bin/systemctl poweroff\"\nPOWERDOWNFLAG /run/nut/killpower\nRUN_AS_USER nut\nPOLLFREQ 5\nPOLLFREQALERT 5\nDEADTIME 15\nFINALDELAY 5\nNOTIFYFLAG ONLINE SYSLOG+WALL\nNOTIFYFLAG ONBATT SYSLOG+WALL\nNOTIFYFLAG LOWBATT SYSLOG+WALL\nNOTIFYFLAG COMMBAD SYSLOG\nNOTIFYFLAG COMMOK SYSLOG\nNOTIFYFLAG SHUTDOWN SYSLOG+WALL\n", config.name); + let mut changed = false; + for (name, content) in [ + ("nut.conf", "MODE=standalone\n"), + ("ups.conf", &ups), + ("upsd.conf", server), + ("upsd.users", &users), + ("upsmon.conf", &monitor), + ] { + changed |= files.ensure_file( + &Path::new("/etc/ups").join(name), + content.as_bytes(), + Some(0o640), + &nut, + )?; + } + let driver = format!("nut-driver@{}.service", config.name); + let units = unit_dropins(config, &driver); + for (unit, dropin) in &units { + let dir = Path::new("/etc/systemd/system").join(format!("{unit}.d")); + files.ensure_directory(&dir, Some(0o755), &root)?; + changed |= files.ensure_file( + &dir.join("skillet.conf"), + dropin.as_bytes(), + Some(0o644), + &root, + )?; + } + files.ensure_directory(Path::new("/etc/udev/rules.d"), Some(0o755), &root)?; + let rule = format!("SUBSYSTEM==\"usb\", ENV{{DEVTYPE}}==\"usb_device\", ATTR{{idVendor}}==\"{:04x}\", ATTR{{idProduct}}==\"{:04x}\", SYMLINK+=\"skillet-ups-{}\", TAG+=\"systemd\", ENV{{SYSTEMD_WANTS}}+=\"nut-monitor.service\"\n", config.vendor, config.product, config.name); + let rule_changed = files.ensure_file( + Path::new("/etc/udev/rules.d/70-skillet-ups.rules"), + rule.as_bytes(), + Some(0o644), + &root, + )?; + changed |= rule_changed; + if changed { + system.daemon_reload()?; + } + for unit in [ + &driver, + "nut-driver.target", + "nut-server.service", + "nut-monitor.service", + "nut.target", + ] { + system.service_enable(unit)?; + } + // Keep refresh pending until it succeeds, even if a previous apply wrote the rule. + let rule_revision = Sha256::digest(rule.as_bytes()).to_vec(); + let rule_state = Path::new(STATE).join("udev.applied"); + if files.read_file(&rule_state)?.as_deref() != Some(rule_revision.as_slice()) || rule_changed { + system.refresh_usb_rules(config.vendor, config.product)?; + files.ensure_file(&rule_state, &rule_revision, Some(0o600), &root)?; + } + if !system.usb_device_present(config.vendor, config.product)? { + // Never stop an existing monitor after unplug: its last on-battery state + // may require a protective shutdown on loss of communication. + tracing::info!( + ups = config.name, + "NUT configuration converged; matching USB UPS is not attached" + ); + return Ok(Outcome::ConfiguredWithoutDevice); + } + let mut hash = Sha256::new(); + for value in [&ups, server, &users, &monitor, &rule] { + hash.update(value); + } + for (unit, content) in &units { + hash.update(unit); + hash.update(content); + } + let revision = hash.finalize().to_vec(); + for unit in [&driver, "nut-server.service", "nut-monitor.service"] { + activate( + system, + files, + &ActivationRequest { + service: unit.to_string(), + state_path: Path::new(STATE).join(format!("{unit}.applied")), + revision: revision.clone(), + definition_changed: changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + } + Ok(Outcome::Active) +} + +fn monitor_password( + files: &F, +) -> Result { + let root = Ownership::named(Some("root"), Some("root")); + let password = match files.read_file(Path::new(PASSWORD))? { + Some(bytes) => String::from_utf8(bytes).map_err(|_| NutError::InvalidCredential)?, + None => uuid::Uuid::new_v4().simple().to_string(), + }; + if password.len() != 32 || !password.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(NutError::InvalidCredential); + } + // Persist first; interrupted configuration resumes with the same credential. + files.ensure_file(Path::new(PASSWORD), password.as_bytes(), Some(0o600), &root)?; + Ok(password) +} + +fn unit_dropins(config: Config, driver: &str) -> Vec<(String, String)> { + let condition = format!("ConditionPathExists=/dev/skillet-ups-{}\n", config.name); + [(driver, "Wants=nut-udev-settle.service\nAfter=nut-udev-settle.service\n".into()), + ("nut-server.service", format!("Requires={driver}\nAfter={driver}\n")), + ("nut-monitor.service", "Requires=nut-server.service\nAfter=nut-server.service\n".into())] + .into_iter().map(|(unit, dependencies)| (unit.to_string(), + format!("[Unit]\n{condition}{dependencies}\n[Service]\nExecStartPre=/usr/sbin/restorecon -RF /etc/ups\n"))) + .collect() +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/nut/src/metrics.rs b/ublue/skillet/crates/nut/src/metrics.rs new file mode 100644 index 00000000..53818944 --- /dev/null +++ b/ublue/skillet/crates/nut/src/metrics.rs @@ -0,0 +1,281 @@ +//! Read-only, bounded NUT polling and `DogStatsD` datagram submission. +use std::{ + collections::BTreeMap, + io::{BufRead, BufReader, Write}, + net::{SocketAddr, TcpStream}, + os::unix::net::UnixDatagram, + path::Path, + time::{Duration, Instant}, +}; +use thiserror::Error; + +const TIMEOUT: Duration = Duration::from_secs(5); +const MAX_LINE: usize = 4096; +const MAX_RESPONSE: usize = 65_536; +const VARIABLES: &[&str] = &[ + "battery.charge", + "battery.runtime", + "battery.voltage", + "input.voltage", + "input.frequency", + "output.voltage", + "output.frequency", + "ups.load", + "ups.realpower", + "ups.temperature", +]; +const FLAGS: &[(&str, &str)] = &[ + ("OL", "online"), + ("OB", "on_battery"), + ("LB", "low_battery"), + ("FSD", "forced_shutdown"), + ("RB", "replace_battery"), + ("OVER", "overload"), + ("OFF", "off"), + ("BYPASS", "bypass"), +]; + +#[derive(Debug, Error)] +pub enum MetricsError { + #[error("invalid UPS name")] + InvalidName, + #[error("invalid or incomplete NUT response")] + Protocol, + #[error("NUT polling deadline exceeded")] + Deadline, + #[error("NUT query failed: {0}")] + Query(#[source] std::io::Error), + #[error("DogStatsD submission failed: {0}")] + Submit(#[source] std::io::Error), +} + +#[derive(Debug, PartialEq)] +pub struct Snapshot { + values: BTreeMap, + status: Vec, +} + +pub fn validate_name(name: &str) -> Result<(), MetricsError> { + if name.is_empty() + || name.len() > 40 + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) + || !name.as_bytes()[0].is_ascii_alphanumeric() + { + return Err(MetricsError::InvalidName); + } + Ok(()) +} + +/// The transport owns live network observations. Parsing and rendering are pure. +pub fn collect(server: SocketAddr, ups: &str) -> Result { + validate_name(ups)?; + let deadline = Instant::now() + TIMEOUT; + let mut stream = + TcpStream::connect_timeout(&server, Duration::from_secs(2)).map_err(MetricsError::Query)?; + stream + .set_write_timeout(Some(TIMEOUT)) + .map_err(MetricsError::Query)?; + stream + .write_all(format!("LIST VAR {ups}\n").as_bytes()) + .map_err(MetricsError::Query)?; + let mut reader = BufReader::new(stream); + let mut response = Vec::new(); + for _ in 0..256 { + let line = read_line_until(&mut reader, deadline)?; + if response.len() + line.len() > MAX_RESPONSE { + return Err(MetricsError::Protocol); + } + let end = line == format!("END LIST VAR {ups}\n").as_bytes() + || line == format!("END LIST VAR {ups}\r\n").as_bytes(); + let error = line.starts_with(b"ERR "); + response.extend(line); + if error { + return Err(MetricsError::Protocol); + } + if end { + let text = std::str::from_utf8(&response).map_err(|_| MetricsError::Protocol)?; + return parse(text, ups); + } + } + Err(MetricsError::Protocol) +} + +// Reset the timeout before each actual socket read, so a slowly trickling line +// cannot extend the whole-query deadline indefinitely. +fn read_line_until( + reader: &mut BufReader, + deadline: Instant, +) -> Result, MetricsError> { + let mut line = Vec::new(); + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or(MetricsError::Deadline)?; + reader + .get_ref() + .set_read_timeout(Some(remaining)) + .map_err(MetricsError::Query)?; + let bytes = reader.fill_buf().map_err(MetricsError::Query)?; + if bytes.is_empty() { + return Err(MetricsError::Protocol); + } + let length = bytes + .iter() + .position(|b| *b == b'\n') + .map_or(bytes.len(), |index| index + 1); + if line.len() + length > MAX_LINE { + return Err(MetricsError::Protocol); + } + line.extend_from_slice(&bytes[..length]); + reader.consume(length); + if line.ends_with(b"\n") { + return Ok(line); + } + } +} + +/// Accept only a complete, matching LIST VAR frame and a finite metric inventory. +pub fn parse(response: &str, ups: &str) -> Result { + validate_name(ups)?; + if response.len() > MAX_RESPONSE { + return Err(MetricsError::Protocol); + } + let mut lines = response.lines(); + if lines.next() != Some(format!("BEGIN LIST VAR {ups}").as_str()) { + return Err(MetricsError::Protocol); + } + let mut values = BTreeMap::new(); + let mut status = None; + let mut ended = false; + let prefix = format!("VAR {ups} "); + for line in lines.by_ref() { + if line == format!("END LIST VAR {ups}") { + ended = true; + break; + } + let body = line.strip_prefix(&prefix).ok_or(MetricsError::Protocol)?; + let (key, quoted) = body.split_once(' ').ok_or(MetricsError::Protocol)?; + let value = unquote(quoted)?; + if key == "ups.status" { + if status.is_some() { + return Err(MetricsError::Protocol); + } + let flags = value + .split_whitespace() + .map(str::to_string) + .collect::>(); + if flags.is_empty() + || flags + .iter() + .any(|flag| !flag.bytes().all(|b| b.is_ascii_uppercase())) + { + return Err(MetricsError::Protocol); + } + status = Some(flags); + } else if VARIABLES.contains(&key) { + let number: f64 = value.parse().map_err(|_| MetricsError::Protocol)?; + if !number.is_finite() || values.insert(key.to_string(), number).is_some() { + return Err(MetricsError::Protocol); + } + } + } + if !ended || lines.next().is_some() { + return Err(MetricsError::Protocol); + } + Ok(Snapshot { + values, + status: status.ok_or(MetricsError::Protocol)?, + }) +} + +fn unquote(input: &str) -> Result { + let body = input + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .ok_or(MetricsError::Protocol)?; + let mut result = String::new(); + let mut chars = body.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.next() { + Some(escaped @ ('\\' | '"')) => result.push(escaped), + _ => return Err(MetricsError::Protocol), + }, + '"' => return Err(MetricsError::Protocol), + c if c.is_control() => return Err(MetricsError::Protocol), + c => result.push(c), + } + } + Ok(result) +} + +pub fn render(ups: &str, snapshot: Option<&Snapshot>) -> Result, MetricsError> { + validate_name(ups)?; + let tags = format!("service:nut,ups:{ups}"); + let Some(snapshot) = snapshot else { + return Ok(vec![format!("_sc|nut.communication|2|#{tags}")]); + }; + let has = |flag: &str| snapshot.status.iter().any(|s| s == flag); + let mut packets = snapshot + .values + .iter() + .filter(|(key, _)| VARIABLES.contains(&key.as_str())) + .map(|(key, value)| format!("nut.{key}:{value}|g|#{tags}")) + .collect::>(); + for (flag, name) in FLAGS { + packets.push(format!( + "nut.ups.status.{name}:{}|g|#{tags}", + u8::from(has(flag)) + )); + } + let power = if has("LB") || has("FSD") || has("OFF") || has("OVER") { + 2 + } else if has("OB") || has("RB") { + 1 + } else if has("OL") { + 0 + } else { + 3 + }; + packets.push(format!( + "_sc|nut.communication|{}|#{tags}", + if has("NOCOMM") { 2 } else { 0 } + )); + packets.push(format!("_sc|nut.power|{power}|#{tags}")); + Ok(packets) +} + +/// One datagram per metric/check; sending confirms local acceptance, not ingestion. +pub fn submit(socket: &Path, packets: &[String]) -> Result<(), MetricsError> { + let client = UnixDatagram::unbound().map_err(MetricsError::Submit)?; + client.connect(socket).map_err(MetricsError::Submit)?; + let deadline = Instant::now() + Duration::from_secs(2); + for packet in packets { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| { + MetricsError::Submit(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "submission deadline exceeded", + )) + })?; + client + .set_write_timeout(Some(remaining)) + .map_err(MetricsError::Submit)?; + let sent = client + .send(packet.as_bytes()) + .map_err(MetricsError::Submit)?; + if sent != packet.len() { + return Err(MetricsError::Submit(std::io::Error::other( + "short datagram", + ))); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "metrics/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/nut/src/metrics/tests.rs b/ublue/skillet/crates/nut/src/metrics/tests.rs new file mode 100644 index 00000000..3a94a746 --- /dev/null +++ b/ublue/skillet/crates/nut/src/metrics/tests.rs @@ -0,0 +1,161 @@ +use super::*; +use std::{net::TcpListener, thread}; + +fn frame(lines: &str) -> String { + format!("BEGIN LIST VAR test-ups\n{lines}END LIST VAR test-ups\n") +} + +#[test] +fn finite_inventory_and_combined_status_are_rendered_without_identity_leaks() { + let sample = parse(&frame("VAR test-ups ups.status \"OB LB\"\nVAR test-ups battery.charge \"19\"\nVAR test-ups battery.runtime \"120\"\nVAR test-ups device.serial \"private serial\"\nVAR test-ups driver.version \"2.8.1\"\n"), "test-ups").unwrap(); + let packets = render("test-ups", Some(&sample)).unwrap().join("\n"); + assert!(packets.contains("nut.battery.charge:19|g|#service:nut,ups:test-ups")); + assert!(packets.contains("nut.battery.runtime:120|g")); + assert!(packets.contains("nut.ups.status.online:0|g")); + assert!(packets.contains("nut.ups.status.on_battery:1|g")); + assert!(packets.contains("nut.ups.status.low_battery:1|g")); + assert!(packets.contains("_sc|nut.power|2|")); + assert!(!packets.contains("serial")); + assert!(!packets.contains("driver.version")); +} + +#[test] +fn malformed_stale_missing_and_nonfinite_readings_fail_closed() { + for response in [ + "ERR DATA-STALE\n".to_string(), + "BEGIN LIST VAR other\nEND LIST VAR other\n".to_string(), + frame("VAR test-ups battery.charge \"100\"\n"), + frame("VAR test-ups ups.status \"OL\"\nVAR test-ups battery.charge \"NaN\"\n"), + frame("VAR test-ups ups.status \"OL\"\nVAR test-ups battery.charge \"inf\"\n"), + frame("VAR test-ups ups.status \"OL\"\nVAR test-ups battery.charge \"100\"\nVAR test-ups battery.charge \"99\"\n"), + frame("VAR test-ups ups.status \"OL\"\nVAR other ups.load \"10\"\n"), + frame("VAR test-ups ups.status \"OL\"\nVAR test-ups ups.status \"OB\"\n"), + frame("VAR test-ups ups.status \"\"\n"), + frame("VAR test-ups ups.status \"OL\"\nVAR test-ups device.model \"bad\\n\"\n"), + format!("{}extra\n", frame("VAR test-ups ups.status \"OL\"\n")), + ] { + assert!(parse(&response, "test-ups").is_err(), "accepted {response}"); + } + assert!(unquote("\"escaped \\\"quote\\\" and \\\\slash\"").is_ok()); + assert!(parse( + &frame("VAR test-ups ups.status \"OL\"\n").replace('\n', "\r\n"), + "test-ups" + ) + .is_ok()); +} + +#[test] +fn status_changes_reset_gauges_and_failure_does_not_invent_zero_readings() { + for (status, power) in [ + ("OL", 0), + ("OB", 1), + ("OB LB", 2), + ("OL FSD", 2), + ("WAIT", 3), + ] { + let sample = parse( + &frame(&format!("VAR test-ups ups.status \"{status}\"\n")), + "test-ups", + ) + .unwrap(); + assert!(render("test-ups", Some(&sample)) + .unwrap() + .iter() + .any(|s| s.starts_with(&format!("_sc|nut.power|{power}|")))); + } + assert_eq!( + render("test-ups", None).unwrap(), + ["_sc|nut.communication|2|#service:nut,ups:test-ups"] + ); + for name in ["", "-ups", "ups\nLOGOUT", "ups,evil", "ups|g"] { + assert!(render(name, None).is_err()); + } +} + +#[test] +fn actual_tcp_request_is_read_only_and_datagrams_use_the_unix_socket() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let worker = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut command = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut command) + .unwrap(); + assert_eq!(command, "LIST VAR test-ups\n"); + stream + .write_all( + frame("VAR test-ups ups.status \"OL\"\nVAR test-ups ups.load \"11\"\n").as_bytes(), + ) + .unwrap(); + }); + let snapshot = collect(address, "test-ups").unwrap(); + worker.join().unwrap(); + let packets = render("test-ups", Some(&snapshot)).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("dsd.socket"); + let receiver = UnixDatagram::bind(&path).unwrap(); + receiver + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let expected = packets.clone(); + let worker = thread::spawn(move || { + for packet in &expected { + let mut buffer = [0u8; 1024]; + let length = receiver.recv(&mut buffer).unwrap(); + assert_eq!(&buffer[..length], packet.as_bytes()); + } + }); + submit(&path, &packets).unwrap(); + worker.join().unwrap(); + assert!(submit(&temp.path().join("absent.socket"), &packets).is_err()); +} + +#[test] +fn truncated_oversized_and_error_frames_are_bounded() { + for response in [ + "ERR UNKNOWN-UPS\n".to_string(), + "BEGIN LIST VAR test-ups\n".to_string(), + format!("{}\n", "x".repeat(4097)), + ] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let worker = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut command = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut command) + .unwrap(); + stream.write_all(response.as_bytes()).unwrap(); + }); + assert!(collect(address, "test-ups").is_err()); + worker.join().unwrap(); + } +} + +#[test] +fn a_trickling_line_cannot_extend_the_read_deadline() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let worker = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + for _ in 0..20 { + if stream.write_all(b"x").is_err() { + break; + } + thread::sleep(Duration::from_millis(20)); + } + }); + let stream = TcpStream::connect(address).unwrap(); + let start = Instant::now(); + assert!(read_line_until( + &mut BufReader::new(stream), + start + Duration::from_millis(80) + ) + .is_err()); + assert!(start.elapsed() < Duration::from_secs(1)); + worker.join().unwrap(); +} diff --git a/ublue/skillet/crates/nut/src/monitoring.rs b/ublue/skillet/crates/nut/src/monitoring.rs new file mode 100644 index 00000000..c686fc23 --- /dev/null +++ b/ublue/skillet/crates/nut/src/monitoring.rs @@ -0,0 +1,65 @@ +use crate::{Config, NutError, STATE}; +use sha2::{Digest as _, Sha256}; +use skillet_core::{ + activation::{activate, ActivationRequest, ConsumerKind}, + files::{FileMutationResource, FileReadResource, Ownership}, + system::ServiceResource, +}; +use std::path::Path; + +pub const METRICS_TIMER: &str = "skillet-nut-metrics.timer"; + +/// Called only after successful credential-gated Datadog activation. +pub fn apply_metrics( + system: &S, + files: &F, + config: Config, + socket: &Path, +) -> Result<(), NutError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + config.validate()?; + let Some(socket) = socket.to_str() else { + return Err(NutError::InvalidConfig); + }; + if !socket.starts_with('/') + || !socket + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.')) + { + return Err(NutError::InvalidConfig); + } + let root = Ownership::named(Some("root"), Some("root")); + files.ensure_directory(Path::new(STATE), Some(0o700), &root)?; + files.ensure_directory(Path::new("/etc/systemd/system"), Some(0o755), &root)?; + let service = format!("[Unit]\nDescription=Read-only NUT metrics for Datadog\nAfter=nut-server.service datadog-agent.service\nConditionPathExists=/dev/skillet-ups-{}\nConditionPathExists={socket}\n\n[Service]\nType=oneshot\nExecStart=/var/usrlocal/bin/skillet metrics nut --ups {} --dogstatsd-socket {socket}\nTimeoutStartSec=20\nNoNewPrivileges=true\nProtectSystem=strict\nProtectHome=true\nPrivateTmp=true\nProtectKernelTunables=true\nProtectKernelModules=true\nProtectControlGroups=true\nRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\nCapabilityBoundingSet=\nLockPersonality=true\nUMask=0077\n", config.name, config.name); + let timer = "[Unit]\nDescription=Poll local UPS metrics\n\n[Timer]\nOnBootSec=1min\nOnCalendar=*-*-* *:*:00,30\nAccuracySec=1s\nUnit=skillet-nut-metrics.service\n\n[Install]\nWantedBy=timers.target\n"; + let mut changed = false; + for (unit, content) in [ + ("skillet-nut-metrics.service", service.as_str()), + (METRICS_TIMER, timer), + ] { + changed |= files.ensure_file( + &Path::new("/etc/systemd/system").join(unit), + content.as_bytes(), + Some(0o644), + &root, + )?; + } + system.service_enable(METRICS_TIMER)?; + activate( + system, + files, + &ActivationRequest { + service: METRICS_TIMER.into(), + state_path: Path::new(STATE).join("metrics.applied"), + revision: Sha256::digest(format!("{service}{timer}")).to_vec(), + definition_changed: changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + Ok(()) +} diff --git a/ublue/skillet/crates/nut/src/tests.rs b/ublue/skillet/crates/nut/src/tests.rs new file mode 100644 index 00000000..4ba2604e --- /dev/null +++ b/ublue/skillet/crates/nut/src/tests.rs @@ -0,0 +1,349 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; +const CONFIG: Config = Config { + name: "cyberpower", + vendor: 0x0764, + product: 0x0501, + description: "CyberPower CP1500 AVR", +}; + +#[test] +fn absent_hardware_converges_without_starting_and_preserves_password() { + let files = MockFiles::new(); + let system = MockSystem::new(); + assert_eq!( + apply(&system, &files, CONFIG).unwrap(), + Outcome::ConfiguredWithoutDevice + ); + let before = files.read_file(Path::new(PASSWORD)).unwrap().unwrap(); + let all = files.files.lock().unwrap(); + assert_eq!(all["/etc/ups/nut.conf"], b"MODE=standalone\n"); + assert_eq!( + all["/etc/ups/upsd.conf"], + b"LISTEN 127.0.0.1 3493\nLISTEN ::1 3493\n" + ); + let driver = String::from_utf8(all["/etc/ups/ups.conf"].clone()).unwrap(); + assert!(driver.contains("vendorid = 0764")); + assert!(driver.contains("productid = 0501")); + let monitor = String::from_utf8(all["/etc/ups/upsmon.conf"].clone()).unwrap(); + assert!(monitor.contains(" primary\n")); + assert!(monitor.contains("POWERDOWNFLAG /run/nut/killpower")); + let meta = files.metadata.lock().unwrap(); + assert_eq!(meta["/etc/ups/upsd.users"].0, Some(0o640)); + assert_eq!(meta[PASSWORD].0, Some(0o600)); + drop(meta); + drop(all); + apply(&system, &files, CONFIG).unwrap(); + assert_eq!( + files.read_file(Path::new(PASSWORD)).unwrap().unwrap(), + before + ); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 0); + assert_eq!(system.start_count.load(Ordering::SeqCst), 0); + assert_eq!(system.usb_refresh_count.load(Ordering::SeqCst), 1); +} +#[test] +fn attached_hardware_activates_and_repeat_apply_does_not_restart() { + let files = MockFiles::new(); + let system = MockSystem::new(); + system + .usb_devices + .lock() + .unwrap() + .insert((CONFIG.vendor, CONFIG.product)); + assert_eq!(apply(&system, &files, CONFIG).unwrap(), Outcome::Active); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 3); + apply(&system, &files, CONFIG).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 3); + assert_eq!(system.start_count.load(Ordering::SeqCst), 0); + system.usb_devices.lock().unwrap().clear(); + apply(&system, &files, CONFIG).unwrap(); + assert!(system.service_is_active("nut-monitor.service").unwrap()); +} +#[test] +fn interrupted_activation_retries_without_replacing_credentials() { + let files = MockFiles::new(); + let system = MockSystem::new(); + system + .usb_devices + .lock() + .unwrap() + .insert((CONFIG.vendor, CONFIG.product)); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(apply(&system, &files, CONFIG).is_err()); + let before = files.read_file(Path::new(PASSWORD)).unwrap().unwrap(); + assert_eq!(apply(&system, &files, CONFIG).unwrap(), Outcome::Active); + assert_eq!( + files.read_file(Path::new(PASSWORD)).unwrap().unwrap(), + before + ); + files + .ensure_file( + Path::new(PASSWORD), + b"malformed", + Some(0o600), + &Ownership::default(), + ) + .unwrap(); + assert!(matches!( + apply(&system, &files, CONFIG), + Err(NutError::InvalidCredential) + )); + assert_eq!( + files.read_file(Path::new(PASSWORD)).unwrap().unwrap(), + b"malformed" + ); +} +#[test] +fn caller_inputs_cannot_inject_units_or_configuration() { + for config in [ + Config { + name: "-ups", + ..CONFIG + }, + Config { + name: "ups\n[evil]", + ..CONFIG + }, + Config { + description: "bad\"\nsetting", + ..CONFIG + }, + ] { + assert!(config.ups_config().is_err()); + let files = MockFiles::new(); + let system = MockSystem::new(); + assert!(apply(&system, &files, config).is_err()); + assert!(files.files.lock().unwrap().is_empty()); + } +} + +#[test] +fn telemetry_timer_is_explicit_private_and_idempotent() { + let files = MockFiles::new(); + let system = MockSystem::new(); + apply(&system, &files, CONFIG).unwrap(); + assert!(!files + .files + .lock() + .unwrap() + .contains_key("/etc/systemd/system/skillet-nut-metrics.timer")); + apply_metrics(&system, &files, CONFIG, Path::new("/run/test/dsd.socket")).unwrap(); + let state = files.files.lock().unwrap(); + let service = + String::from_utf8_lossy(&state["/etc/systemd/system/skillet-nut-metrics.service"]); + let bootstrap = include_str!("../../../../butane/includes/skillet.bu"); + let executable = service + .lines() + .find_map(|line| line.strip_prefix("ExecStart=")) + .unwrap() + .split_whitespace() + .next() + .unwrap(); + assert!(bootstrap.contains(&format!("- path: {executable}\n"))); + assert!(service.contains("/var/usrlocal/bin/skillet metrics nut --ups cyberpower")); + assert!(service.contains("ConditionPathExists=/dev/skillet-ups-cyberpower")); + assert!(service.contains("ConditionPathExists=/run/test/dsd.socket")); + assert!(service.contains("ProtectSystem=strict")); + assert!(!service.contains("Requires=")); + assert!(!service.contains("Wants=")); + drop(state); + let before = system.restart_count.load(Ordering::SeqCst); + apply_metrics(&system, &files, CONFIG, Path::new("/run/test/dsd.socket")).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), before); + assert!(!system.service_is_active("nut-server.service").unwrap()); + assert!(!system.service_is_active("datadog-agent.service").unwrap()); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(apply_metrics( + &system, + &files, + CONFIG, + Path::new("/run/changed/dsd.socket") + ) + .is_err()); + apply_metrics( + &system, + &files, + CONFIG, + Path::new("/run/changed/dsd.socket"), + ) + .unwrap(); + assert!(apply_metrics(&system, &files, CONFIG, Path::new("/run/inject%H.socket")).is_err()); +} + +use std::process::Command; +struct Container(String); +impl Drop for Container { + fn drop(&mut self) { + let _ = Command::new("podman").args(["rm", "-f", &self.0]).output(); + } +} +fn command(args: &[&str]) -> Vec { + let result = Command::new("podman").args(args).output().unwrap(); + assert!( + result.status.success(), + "podman operation failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + result.stdout +} + +fn write_consumer_configuration(root: &Path) { + let files = MockFiles::new(); + let system = MockSystem::new(); + apply(&system, &files, CONFIG).unwrap(); + apply_metrics(&system, &files, CONFIG, Path::new("/run/test/dsd.socket")).unwrap(); + for (path, bytes) in files.files.lock().unwrap().iter() { + if !path.starts_with("/etc/") { + continue; + } + let relative = Path::new(path).strip_prefix("/etc/").unwrap(); + let local = root.join(relative); + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + std::fs::write(&local, bytes).unwrap(); + } +} + +fn simulate_consumer_configuration(root: &Path) { + std::fs::write( + root.join("ups/ups.conf"), + "[cyberpower]\n driver = dummy-ups\n port = /etc/ups/simulated.dev\n mode = dummy-once\n", + ) + .unwrap(); + std::fs::write( + root.join("ups/simulated.dev"), + "ups.status: OL\nbattery.charge: 100\n", + ) + .unwrap(); + let monpath = root.join("ups/upsmon.conf"); + let monitor = std::fs::read_to_string(&monpath) + .unwrap() + .replace("/usr/bin/systemctl poweroff", "/usr/bin/true"); + std::fs::write(monpath, monitor).unwrap(); +} + +fn verify_consumer_units(name: &str) { + command(&[ + "exec", + name, + "udevadm", + "verify", + "/etc/udev/rules.d/70-skillet-ups.rules", + ]); + // Unit verification checks the executable exists but never starts it. The + // actual static CLI/protocol is exercised separately against the Agent. + command(&["exec", name, "mkdir", "-p", "/var/usrlocal/bin"]); + command(&[ + "exec", + name, + "ln", + "-s", + "/usr/bin/true", + "/var/usrlocal/bin/skillet", + ]); + command(&[ + "exec", + name, + "systemd-analyze", + "--man=no", + "verify", + "nut-driver@cyberpower.service", + "nut-server.service", + "nut-monitor.service", + "skillet-nut-metrics.service", + "skillet-nut-metrics.timer", + ]); +} + +#[test] +#[ignore = "requires the Fedora NUT test image and Podman"] +fn real_fedora_nut_accepts_config_and_monitors_a_safe_dummy_ups() { + use std::time::{Duration, Instant}; + let temp = tempfile::tempdir().unwrap(); + let name = format!("skillet-nut-consumer-{}", uuid::Uuid::new_v4().simple()); + let _cleanup = Container(name.clone()); + command(&[ + "run", + "-d", + "--name", + &name, + "localhost/skillet-nut-runtime", + "sleep", + "300", + ]); + write_consumer_configuration(temp.path()); + let source = format!("{}/.", temp.path().display()); + let dest = format!("{name}:/etc/"); + command(&["cp", &source, &dest]); + command(&["exec", &name, "chown", "-R", "root:nut", "/etc/ups"]); + command(&["exec", &name, "chmod", "-R", "u=rwX,g=rX,o=", "/etc/ups"]); + command(&[ + "exec", + &name, + "systemd-tmpfiles", + "--create", + "/usr/lib/tmpfiles.d/nut-common-tmpfiles.conf", + ]); + verify_consumer_units(&name); + command(&["exec", &name, "upsdrvctl", "-t", "start", "cyberpower"]); + let selected = command(&[ + "exec", + &name, + "/usr/libexec/nut-driver-enumerator.sh", + "--get-device-for-service", + "cyberpower", + ]); + assert_eq!(String::from_utf8_lossy(&selected).trim(), "cyberpower"); + // Only this disposable consumer receives simulation input. It has no USB + // device passthrough; shutdown is replaced by a harmless command. + simulate_consumer_configuration(temp.path()); + command(&["cp", &source, &dest]); + command(&["exec", &name, "chown", "-R", "root:nut", "/etc/ups"]); + command(&["exec", &name, "chmod", "-R", "u=rwX,g=rX,o=", "/etc/ups"]); + command(&[ + "exec", + &name, + "/usr/libexec/nut/dummy-ups", + "-a", + "cyberpower", + ]); + command(&["exec", &name, "upsd"]); + let start = Instant::now(); + loop { + let output = Command::new("podman") + .args(["exec", &name, "upsc", "cyberpower@127.0.0.1", "ups.status"]) + .output() + .unwrap(); + if output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "OL" { + break; + } + assert!( + start.elapsed() < Duration::from_secs(20), + "dummy UPS never became readable" + ); + std::thread::sleep(Duration::from_millis(200)); + } + assert_eq!( + String::from_utf8_lossy(&command(&[ + "exec", + &name, + "upsc", + "cyberpower@[::1]", + "ups.status" + ])) + .trim(), + "OL" + ); + command(&["exec", &name, "upsmon"]); + std::thread::sleep(Duration::from_secs(1)); + command(&["exec", &name, "upsmon", "-c", "reload"]); + let logs = command(&["exec", &name, "upsdrvctl", "status", "cyberpower"]); + assert!(String::from_utf8_lossy(&logs).contains("cyberpower")); + // Server startup refuses invalid syntax/listeners; verify only declared + // loopback addresses remain configured, never publish a container host port. + assert_eq!( + std::fs::read_to_string(temp.path().join("ups/upsd.conf")).unwrap(), + "LISTEN 127.0.0.1 3493\nLISTEN ::1 3493\n" + ); +} diff --git a/ublue/skillet/crates/nut/tests/Containerfile b/ublue/skillet/crates/nut/tests/Containerfile new file mode 100644 index 00000000..7f05b92f --- /dev/null +++ b/ublue/skillet/crates/nut/tests/Containerfile @@ -0,0 +1,2 @@ +FROM registry.fedoraproject.org/fedora:latest +RUN dnf -y install nut nut-client policycoreutils && dnf clean all diff --git a/ublue/skillet/crates/pihole/Cargo.toml b/ublue/skillet/crates/pihole/Cargo.toml new file mode 100644 index 00000000..45b44402 --- /dev/null +++ b/ublue/skillet/crates/pihole/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "skillet_pihole" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_datadog.workspace = true +skillet_core.workspace = true +skillet_podman.workspace = true +thiserror.workspace = true +tracing.workspace = true +askama.workspace = true + +[dev-dependencies] +skillet_core = { path = "../core", features = ["test-utils"] } +users.workspace = true diff --git a/ublue/skillet/crates/pihole/src/lib.rs b/ublue/skillet/crates/pihole/src/lib.rs new file mode 100644 index 00000000..021945c3 --- /dev/null +++ b/ublue/skillet/crates/pihole/src/lib.rs @@ -0,0 +1,190 @@ +use askama::Template; +use skillet_core::files::{ + FileError, FileMutationResource, FileReadResource, Ownership, StorageResource, +}; +use skillet_core::system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, +}; +use skillet_podman::{ + self, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, + PortPublication, ProcessIdentity, QuadletSecret, Volume, +}; +use std::collections::BTreeMap; +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: BTreeMap, +} + +pub struct PiholeUser { + pub uid: Option, + pub gid: Option, + pub name: String, + pub group_name: String, +} + +/// Host-specific Pi-hole configuration: custom DNS records (`ip -> fqdn`) +/// rendered into `custom.list`. Kept out of the shared crate so each host +/// supplies its own LAN topology. +pub fn apply( + system: &S, + files: &F, + user_config: &PiholeUser, + secrets: Vec, + custom_records: BTreeMap, + network_name: String, +) -> Result<(), PiholeError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, +{ + info!("Applying pihole configuration..."); + let root = "/var/lib/data/pihole"; + let etc = "/var/lib/data/pihole/etc"; + let logs = "/var/lib/data/pihole/log"; + + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + files.ensure_btrfs_subvolume(Path::new(root))?; + + // 1. Ensure user and group + system.ensure_group(&user_config.group_name, user_config.gid)?; + system.ensure_user(&user_config.name, user_config.uid, user_config.gid)?; + + // 2. Ensure directories + files.ensure_directory( + Path::new(root), + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + files.ensure_directory(Path::new(etc), Some(0o755), &Ownership::default())?; + files.ensure_directory( + &Path::new(root).join("dnsmasq.d"), + Some(0o755), + &Ownership::default(), + )?; + files.ensure_directory(Path::new(logs), Some(0o755), &Ownership::default())?; + + // 3. Custom list template (records supplied by the host) + let template = CustomListTemplate { + custom: custom_records, + }; + let custom_list = template.render().map_err(|error| { + FileError::Io(std::io::Error::other(format!( + "Template rendering failed: {error}" + ))) + })?; + files.ensure_file( + &Path::new(etc).join("custom.list"), + custom_list.as_bytes(), + Some(0o640), + &Ownership::default(), + )?; + + // 4. Define container + // SELinux relabeling (:z, shared) so the container can access these + // host paths on enforcing systems such as uCore. + let volumes = vec![ + Volume { + host_path: etc.to_string(), + container_path: "/etc/pihole".to_string(), + options: Some("z".to_string()), + host_mode: None, + host_ownership: None, + }, + Volume { + host_path: format!("{root}/dnsmasq.d"), + container_path: "/etc/dnsmasq.d".to_string(), + options: Some("z".to_string()), + host_mode: None, + host_ownership: None, + }, + Volume { + host_path: logs.to_string(), + container_path: "/var/log/pihole".to_string(), + options: Some("z".to_string()), + host_mode: None, + host_ownership: None, + }, + ]; + + let mut extra_config = BTreeMap::new(); + extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); + extra_config.insert( + "Container".to_string(), + vec![ + "AutoUpdate=registry".to_string(), + "ContainerName=pihole".to_string(), + skillet_datadog::process("pihole", "pihole-FTL"), + "Environment=FTLCONF_dns_listeningMode=ALL".to_string(), + "Environment=FTLCONF_webserver_port=8088o,[::]:8088o".to_string(), + "Environment=TZ=Europe/Madrid".to_string(), + "Environment=WEBPASSWORD_FILE=/run/secrets/pihole_web_password".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(), + network_attachments: vec![NetworkAttachment::Bridge(network_name)], + port_publications: dns_port_publications(), + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, + volumes, + secrets, + config_revisions: vec![custom_list.into_bytes()], + extra_config, + }, + )?; + + Ok(()) +} + +fn dns_port_publications() -> Vec { + [ + std::net::Ipv6Addr::UNSPECIFIED.into(), + std::net::Ipv4Addr::UNSPECIFIED.into(), + ] + .into_iter() + .flat_map(|host_address| { + [PortProtocol::Tcp, PortProtocol::Udp].map(move |protocol| PortPublication { + host_address, + host_port: 53, + container_port: 53, + protocol, + }) + }) + .collect() +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/pihole/src/tests.rs b/ublue/skillet/crates/pihole/src/tests.rs new file mode 100644 index 00000000..da9b43df --- /dev/null +++ b/ublue/skillet/crates/pihole/src/tests.rs @@ -0,0 +1,71 @@ +use crate::{apply, PiholeUser}; +use skillet_core::{ + system::PodmanSecretResource, + test_utils::{MockFiles, MockSystem}, +}; +use skillet_podman::{QuadletSecret, SecretTarget}; +use std::collections::BTreeMap; + +#[test] +fn pihole_uses_dual_stack_dns_network_and_registry_updates() { + let system = MockSystem::new(); + let files = MockFiles::new(); + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/test", + "/data", + "btrfs", + ); + system + .ensure_podman_secret("pihole_web_password", "dummy") + .unwrap(); + + apply( + &system, + &files, + &PiholeUser { + uid: None, + gid: None, + name: "pihole".to_string(), + group_name: "pihole".to_string(), + }, + vec![QuadletSecret { + secret_name: "pihole_web_password".to_string(), + target: SecretTarget::File { + target_path: "/run/secrets/pihole_web_password".to_string(), + mode: Some("0400".to_string()), + uid: None, + gid: None, + }, + }], + BTreeMap::new(), + "clamps".to_string(), + ) + .unwrap(); + + let generated = files.files.lock().unwrap(); + let quadlet = String::from_utf8_lossy( + generated + .get("/etc/containers/systemd/pihole.container") + .unwrap(), + ); + for directive in [ + "AutoUpdate=registry", + "ContainerName=pihole", + "Network=clamps.network", + "Environment=FTLCONF_dns_listeningMode=ALL", + "Environment=FTLCONF_webserver_port=8088o,[::]:8088o", + "Environment=TZ=Europe/Madrid", + "PublishPort=[::]:53:53/tcp", + "PublishPort=[::]:53:53/udp", + "PublishPort=0.0.0.0:53:53/tcp", + "PublishPort=0.0.0.0:53:53/udp", + ] { + assert!( + quadlet.contains(directive), + "missing Quadlet directive: {directive}" + ); + } + assert!(!quadlet.contains("Pod=")); +} 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..37087199 --- /dev/null +++ b/ublue/skillet/crates/podman/Cargo.toml @@ -0,0 +1,19 @@ +[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 +sha2.workspace = true +hex.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/podman/src/lib.rs b/ublue/skillet/crates/podman/src/lib.rs new file mode 100644 index 00000000..1b4432e0 --- /dev/null +++ b/ublue/skillet/crates/podman/src/lib.rs @@ -0,0 +1,838 @@ +use askama::Template; +use sha2::{Digest, Sha256}; +use skillet_core::activation::{self, ActivationOutcome, ActivationRequest, ConsumerKind}; +use skillet_core::files::{FileError, FileMutationResource, FileReadResource, Ownership}; +use skillet_core::system::{ + AccountLookupResource, PodmanSecretResource, ServiceResource, SystemError, +}; +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::{ + net::IpAddr, + path::{Path, PathBuf}, + str::FromStr, +}; +use thiserror::Error; +use tracing::info; + +#[derive(Error, Debug)] +pub enum PodmanError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Activation error: {0}")] + Activation(#[from] skillet_core::activation::ActivationError), + #[error("User mapping error: {0}")] + UserMapping(String), + #[error("Invalid Podman network unit name: {0}")] + InvalidNetworkName(String), + #[error("Podman directive {0} conflicts with the typed container identity")] + ConflictingIdentityDirective(&'static str), + #[error("invalid or conflicting typed Podman container directive: {0}")] + ConflictingContainerDirective(&'static str), + #[error("invalid Podman port publication")] + InvalidPortPublication, + #[error("invalid Podman storage dependency: {0}")] + InvalidStorageDependency(&'static str), + #[error("Network configuration changed for {0}; stop its consumers, remove the Podman network and its applied marker, then apply again")] + NetworkConfigChanged(String), + #[error("No valid {kind} subordinate-ID range for account {account}")] + MissingSubordinateRange { kind: &'static str, account: String }, + #[error("Invalid subordinate-ID range in {path} for account {account}")] + InvalidSubordinateRange { path: &'static str, account: String }, + #[error( + "Subordinate-ID range for account {account} is too small for container ID {container_id}" + )] + SubordinateRangeTooSmall { account: String, container_id: u32 }, +} + +#[derive(Template)] +#[template(path = "quadlet.container.j2", escape = "none")] +struct QuadletTemplate { + sections: BTreeMap>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProcessIdentity { + ImageDefault, + Numeric { uid: u32, gid: u32 }, + Named { user: String, group: Option }, +} + +/// Explicitly map one existing host account to the configured numeric +/// container identity through the user's subordinate-ID ranges. +pub struct UserNamespaceMapping { + pub host_user: HostUser, +} + +pub enum HostUser { + Name(String), + Uid(u32), +} + +pub struct Volume { + pub host_path: String, + pub container_path: String, + pub options: Option, + pub host_mode: Option, + /// Metadata applied only to the bind-mount root. Descendant data is never + /// recursively changed by generic volume convergence. + pub host_ownership: Option, +} + +pub enum SecretTarget { + 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 network_attachments: Vec, + pub port_publications: Vec, + pub storage_dependency: Option, + pub process_identity: ProcessIdentity, + pub namespace_mapping: Option, + pub volumes: Vec, + pub secrets: Vec, + /// Content consumed at container startup outside the Quadlet definition. + pub config_revisions: Vec>, + pub extra_config: BTreeMap>, +} + +/// A Quadlet-managed Podman network shared by containers needing +/// container-to-container traffic or DNS service discovery. +pub struct PodmanNetwork { + /// The `.network` Quadlet unit name, without its extension. + pub unit_name: String, + /// Options written to the unit's `[Network]` section. + pub options: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NetworkAttachment { + Host, + Bridge(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PortProtocol { + Tcp, + Udp, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PortPublication { + pub host_address: IpAddr, + pub host_port: u16, + pub container_port: u16, + pub protocol: PortProtocol, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MountDependency { + pub mount_path: PathBuf, + pub mount_unit: String, + pub prepare_unit: Option, +} + +impl MountDependency { + pub fn shared_service_data() -> Self { + Self { + mount_path: PathBuf::from("/var/lib/data"), + mount_unit: "var-lib-data.mount".to_string(), + prepare_unit: Some("skillet-data-prepare.service".to_string()), + } + } + + fn validate(&self) -> Result<(), PodmanError> { + if !self.mount_path.is_absolute() + || self.mount_path == Path::new("/") + || self + .mount_path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(PodmanError::InvalidStorageDependency("mount path")); + } + validate_unit_name(&self.mount_unit, "mount unit")?; + if let Some(unit) = &self.prepare_unit { + validate_unit_name(unit, "prepare unit")?; + } + Ok(()) + } + + fn unit_directives(&self) -> Vec { + let mut directives = Vec::new(); + if let Some(prepare) = &self.prepare_unit { + directives.push(format!("Requires={prepare}")); + directives.push(format!("After={prepare}")); + } + directives.push(format!("BindsTo={}", self.mount_unit)); + directives.push(format!("After={}", self.mount_unit)); + directives.push(format!( + "AssertPathIsMountPoint={}", + self.mount_path.display() + )); + directives + } +} + +impl PortPublication { + fn to_directive(&self) -> Result { + if self.host_port == 0 || self.container_port == 0 { + return Err(PodmanError::InvalidPortPublication); + } + let address = match self.host_address { + IpAddr::V4(address) => address.to_string(), + IpAddr::V6(address) => format!("[{address}]"), + }; + let protocol = match self.protocol { + PortProtocol::Tcp => "tcp", + PortProtocol::Udp => "udp", + }; + Ok(format!( + "PublishPort={address}:{}:{}/{}", + self.host_port, self.container_port, protocol + )) + } +} + +/// Configure Aardvark's host-wide listener before applying services that +/// publish DNS on the host. This policy belongs to host composition, not to +/// each container that happens to use a bridge network. +pub fn ensure_dns_listener_port( + files: &F, + port: u16, +) -> Result { + let config_dir = Path::new("/etc/containers/containers.conf.d"); + files.ensure_directory( + config_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + Ok(files.ensure_file( + &config_dir.join("90-skillet-aardvark.conf"), + format!("[network]\ndns_bind_port={port}\n").as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?) +} + +pub fn ensure_network( + system: &S, + files: &F, + network: &PodmanNetwork, +) -> Result<(), PodmanError> +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let content = render_network(network)?; + let marker_path = + Path::new("/var/lib/skillet/networks").join(format!("{}.applied", network.unit_name)); + if let Some(applied) = files.read_file(&marker_path)? { + if applied != content.as_bytes() { + return Err(PodmanError::NetworkConfigChanged(network.unit_name.clone())); + } + } + let quadlet_dir = Path::new("/etc/containers/systemd"); + files.ensure_directory( + quadlet_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + if files.ensure_file( + &quadlet_dir.join(format!("{}.network", network.unit_name)), + content.as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )? { + system.daemon_reload()?; + } + let state_dir = Path::new("/var/lib/skillet/networks"); + files.ensure_directory( + state_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + files.ensure_file( + &marker_path, + content.as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + Ok(()) +} + +#[allow(clippy::similar_names)] +pub fn container( + system: &S, + files: &F, + config: PodmanConfig, +) -> Result +where + S: AccountLookupResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + validate_process_identity(&config)?; + validate_container_settings(&config)?; + let name = &config.name; + info!("Ensuring podman container: {name}"); + + let mut extra_config = config.extra_config; + let config_revisions = config.config_revisions; + for network in &config.network_attachments { + let directive = match network { + NetworkAttachment::Host => "Network=host".to_string(), + NetworkAttachment::Bridge(name) => { + validate_network_attachment_name(name)?; + format!("Network={name}.network") + } + }; + extra_config + .entry("Container".to_string()) + .or_default() + .push(directive); + } + for publication in &config.port_publications { + extra_config + .entry("Container".to_string()) + .or_default() + .push(publication.to_directive()?); + } + if let Some(dependency) = &config.storage_dependency { + extra_config + .entry("Unit".to_string()) + .or_default() + .extend(dependency.unit_directives()); + } + + add_process_identity( + system, + files, + &config.process_identity, + config.namespace_mapping.as_ref(), + &mut extra_config, + )?; + + // 3. Ensure volumes and secrets + let container_section = extra_config.entry("Container".to_string()).or_default(); + container_section.push(format!("Image={}", config.image)); + + for vol in config.volumes { + // Ownership is part of this typed volume declaration and affects only + // the root directory. `None` preserves application-managed metadata. + let ownership = vol.host_ownership.unwrap_or_default(); + files.ensure_directory(Path::new(&vol.host_path), vol.host_mode, &ownership)?; + + let mut vol_line = format!("Volume={}:{}", vol.host_path, vol.container_path); + if let Some(opt) = vol.options { + let _ = write!(vol_line, ":{opt}"); + } + container_section.push(vol_line); + } + + let secret_ids = config + .secrets + .iter() + .map(|secret| system.podman_secret_id(&secret.secret_name)) + .collect::, _>>()?; + for secret in config.secrets { + container_section.push(secret.to_directive()); + } + + // Sort lines in each section for deterministic output + for lines in extra_config.values_mut() { + lines.sort(); + } + + // 4. Render and ensure Quadlet file + let changed = render_and_ensure_quadlet( + system, + files, + name, + extra_config, + &secret_ids, + &config_revisions, + )?; + + Ok(changed) +} + +fn validate_network_attachment_name(name: &str) -> Result<(), PodmanError> { + let valid = !name.is_empty() + && name + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_.-".contains(character)); + if valid { + Ok(()) + } else { + Err(PodmanError::ConflictingContainerDirective("Network")) + } +} + +fn validate_container_settings(config: &PodmanConfig) -> Result<(), PodmanError> { + let mut network_names = std::collections::BTreeSet::new(); + let mut has_host_network = false; + for attachment in &config.network_attachments { + match attachment { + NetworkAttachment::Host => { + has_host_network = true; + } + NetworkAttachment::Bridge(name) => { + validate_network_attachment_name(name)?; + if !network_names.insert(name) { + return Err(PodmanError::ConflictingContainerDirective("Network")); + } + } + } + } + if has_host_network && (!network_names.is_empty() || config.network_attachments.len() > 1) { + return Err(PodmanError::ConflictingContainerDirective("Network")); + } + let mut publications = std::collections::BTreeSet::new(); + for publication in &config.port_publications { + if publication.host_port == 0 || publication.container_port == 0 { + return Err(PodmanError::InvalidPortPublication); + } + let key = ( + publication.host_address, + publication.host_port, + publication.container_port, + match publication.protocol { + PortProtocol::Tcp => 0, + PortProtocol::Udp => 1, + }, + ); + if !publications.insert(key) { + return Err(PodmanError::ConflictingContainerDirective("PublishPort")); + } + } + for line in config.extra_config.get("Container").into_iter().flatten() { + if line.starts_with("Network=") { + return Err(PodmanError::ConflictingContainerDirective("Network")); + } + if line.starts_with("PublishPort=") { + return Err(PodmanError::ConflictingContainerDirective("PublishPort")); + } + } + if let Some(dependency) = &config.storage_dependency { + dependency.validate()?; + } + Ok(()) +} + +fn validate_unit_name(value: &str, context: &'static str) -> Result<(), PodmanError> { + let valid = !value.is_empty() + && value + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_.-@".contains(character)); + if valid { + Ok(()) + } else { + Err(PodmanError::InvalidStorageDependency(context)) + } +} + +fn render_network(network: &PodmanNetwork) -> Result { + let valid_name = !network.unit_name.is_empty() + && network + .unit_name + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && network + .unit_name + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_.-".contains(character)); + if !valid_name { + return Err(PodmanError::InvalidNetworkName(network.unit_name.clone())); + } + + let mut options = network.options.clone(); + options.sort(); + let mut content = String::from("[Network]\n"); + for option in options { + content.push_str(&option); + content.push('\n'); + } + Ok(content) +} + +fn resolve_host_user( + system: &S, + mapping: &UserNamespaceMapping, +) -> Result<(u32, u32, String), PodmanError> { + let (username, uid, gid) = match &mapping.host_user { + HostUser::Name(n) => { + let u = system + .user_by_name(n)? + .ok_or_else(|| PodmanError::UserMapping(format!("User {n} not found on host")))?; + (n.clone(), u.uid, u.primary_gid) + } + HostUser::Uid(u) => { + let u_info = system + .user_by_uid(*u)? + .ok_or_else(|| PodmanError::UserMapping(format!("UID {u} not found on host")))?; + (u_info.name, *u, u_info.primary_gid) + } + }; + Ok((uid, gid, username)) +} + +fn validate_identity_component(value: &str) -> Result<(), PodmanError> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(PodmanError::UserMapping( + "invalid named container identity component".to_string(), + )); + } + Ok(()) +} + +#[allow(clippy::similar_names)] +fn add_process_identity( + system: &S, + files: &F, + identity: &ProcessIdentity, + mapping: Option<&UserNamespaceMapping>, + extra_config: &mut BTreeMap>, +) -> Result<(), PodmanError> +where + S: AccountLookupResource + ?Sized, + F: FileReadResource + ?Sized, +{ + let container = extra_config.entry("Container".to_string()).or_default(); + match identity { + ProcessIdentity::Numeric { uid, gid } if mapping.is_none() => { + container.push(format!("User={uid}:{gid}")); + } + ProcessIdentity::ImageDefault | ProcessIdentity::Numeric { .. } => {} + ProcessIdentity::Named { user, group } => { + if let Some(group) = group { + container.push(format!("User={user}:{group}")); + } else { + container.push(format!("User={user}")); + } + } + } + let Some(mapping) = mapping else { + return Ok(()); + }; + let ProcessIdentity::Numeric { uid, gid } = identity else { + return Err(PodmanError::UserMapping( + "namespace mapping requires an explicit numeric container identity".to_string(), + )); + }; + let (host_uid, host_gid, username) = resolve_host_user(system, mapping)?; + let sub_uid = discover_subid_range(files, "/etc/subuid", &username, "UID")?; + let sub_gid = discover_subid_range(files, "/etc/subgid", &username, "GID")?; + calculate_user_mappings( + UserMappingInputs { + uid_container: *uid, + gid_container: *gid, + uid_host: host_uid, + gid_host: host_gid, + username: &username, + sub_uid, + sub_gid, + }, + extra_config, + ) +} + +fn validate_process_identity(config: &PodmanConfig) -> Result<(), PodmanError> { + for line in config.extra_config.get("Container").into_iter().flatten() { + for (key, prefix) in [ + ("User", "User="), + ("UIDMap", "UIDMap="), + ("GIDMap", "GIDMap="), + ] { + if line.starts_with(prefix) { + return Err(PodmanError::ConflictingIdentityDirective(key)); + } + } + } + match &config.process_identity { + ProcessIdentity::ImageDefault => { + if config.namespace_mapping.is_some() { + return Err(PodmanError::UserMapping( + "namespace mapping requires an explicit numeric container identity".to_string(), + )); + } + } + ProcessIdentity::Numeric { .. } => {} + ProcessIdentity::Named { user, group } => { + validate_identity_component(user)?; + if let Some(group) = group { + validate_identity_component(group)?; + } + if config.namespace_mapping.is_some() { + return Err(PodmanError::UserMapping( + "namespace mapping requires an explicit numeric container identity".to_string(), + )); + } + } + } + Ok(()) +} + +// uid/gid subid ranges are intentionally parallel; the one-letter +// difference is the whole point +#[allow(clippy::similar_names)] +fn calculate_user_mappings( + inputs: UserMappingInputs<'_>, + extra_config: &mut BTreeMap>, +) -> Result<(), PodmanError> { + let UserMappingInputs { + uid_container, + gid_container, + uid_host, + gid_host, + username, + sub_uid, + sub_gid, + } = inputs; + validate_subordinate_range(sub_uid, username, uid_container)?; + validate_subordinate_range(sub_gid, username, gid_container)?; + + 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:{}:{uid_container}", sub_uid.start)); + } + 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.start + uid_container + 1 + )); + } + + // GIDMap + if gid_container > 0 { + container_section.push(format!("GIDMap=0:{}:{gid_container}", sub_gid.start)); + } + 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.start + gid_container + 1 + )); + } + Ok(()) +} + +#[derive(Clone, Copy)] +#[allow(clippy::struct_field_names)] +struct UserMappingInputs<'a> { + uid_container: u32, + gid_container: u32, + uid_host: u32, + gid_host: u32, + username: &'a str, + sub_uid: SubordinateRange, + sub_gid: SubordinateRange, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SubordinateRange { + start: u32, + size: u32, +} + +fn discover_subid_range( + files: &F, + path: &'static str, + username: &str, + kind: &'static str, +) -> Result { + let contents = + files + .read_file(Path::new(path))? + .ok_or_else(|| PodmanError::MissingSubordinateRange { + kind, + account: username.to_string(), + })?; + let contents = + std::str::from_utf8(&contents).map_err(|_| PodmanError::InvalidSubordinateRange { + path, + account: username.to_string(), + })?; + let mut matching = contents + .lines() + .filter(|line| line.split(':').next() == Some(username)); + let line = matching + .next() + .ok_or_else(|| PodmanError::MissingSubordinateRange { + kind, + account: username.to_string(), + })?; + if matching.next().is_some() { + return Err(PodmanError::InvalidSubordinateRange { + path, + account: username.to_string(), + }); + } + let mut fields = line.split(':'); + let _name = fields.next(); + let start = fields.next().and_then(|value| u32::from_str(value).ok()); + let size = fields.next().and_then(|value| u32::from_str(value).ok()); + match (start, size, fields.next()) { + (Some(start), Some(size), None) if size > 0 && start.checked_add(size).is_some() => { + Ok(SubordinateRange { start, size }) + } + _ => Err(PodmanError::InvalidSubordinateRange { + path, + account: username.to_string(), + }), + } +} + +fn validate_subordinate_range( + range: SubordinateRange, + account: &str, + container_id: u32, +) -> Result<(), PodmanError> { + if range.size <= container_id { + return Err(PodmanError::SubordinateRangeTooSmall { + account: account.to_string(), + container_id, + }); + } + Ok(()) +} + +fn render_and_ensure_quadlet( + system: &S, + files: &F, + name: &str, + sections: BTreeMap>, + secret_ids: &[String], + config_revisions: &[Vec], +) -> Result +where + S: ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + ?Sized, +{ + let template = QuadletTemplate { sections }; + let content = template.render().map_err(|e| { + FileError::Io(std::io::Error::other(format!( + "Template rendering failed: {e}" + ))) + })?; + + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + for revision in config_revisions { + hasher.update((revision.len() as u64).to_le_bytes()); + hasher.update(revision); + } + for id in secret_ids { + hasher.update([0]); + hasher.update(id.as_bytes()); + } + let revision = hex::encode(hasher.finalize()); + let state_dir = Path::new("/var/lib/skillet/containers"); + files.ensure_directory( + state_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + let applied_path = state_dir.join(format!("{name}.applied")); + + let quadlet_dir = Path::new("/etc/containers/systemd"); + files.ensure_directory( + quadlet_dir, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + + let quadlet_path = quadlet_dir.join(format!("{name}.container")); + let changed = files.ensure_file( + &quadlet_path, + content.as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + + let outcome = activation::activate( + system, + files, + &ActivationRequest { + service: name.to_string(), + state_path: applied_path, + revision: revision.into_bytes(), + definition_changed: changed, + reload_daemon: true, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + Ok(outcome) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/podman/src/tests.rs b/ublue/skillet/crates/podman/src/tests.rs new file mode 100644 index 00000000..75ff0332 --- /dev/null +++ b/ublue/skillet/crates/podman/src/tests.rs @@ -0,0 +1,583 @@ +use super::*; +use skillet_core::activation::ActivationAction; +use skillet_core::test_utils::{MockFiles, MockSystem}; +use std::sync::atomic::Ordering; + +fn fixture() -> PodmanConfig { + let mut extra_config = BTreeMap::new(); + extra_config.insert( + "Install".to_string(), + vec!["WantedBy=multi-user.target".to_string()], + ); + PodmanConfig { + name: "unit-fixture".to_string(), + image: "example.invalid/fixture:1".to_string(), + network_attachments: Vec::new(), + port_publications: Vec::new(), + storage_dependency: None, + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, + volumes: Vec::new(), + secrets: vec![QuadletSecret { + secret_name: "dummy".to_string(), + target: SecretTarget::File { + target_path: "/run/secrets/dummy".to_string(), + mode: None, + uid: None, + gid: None, + }, + }], + config_revisions: Vec::new(), + extra_config, + } +} + +fn clamps_network() -> PodmanNetwork { + PodmanNetwork { + unit_name: "clamps".to_string(), + options: vec![ + "NetworkName=clamps".to_string(), + "IPv6=true".to_string(), + "DisableDNS=false".to_string(), + "Driver=bridge".to_string(), + ], + } +} + +#[test] +fn shared_network_is_created_separately_and_attached_idempotently() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + + ensure_network(&system, &files, &clamps_network()).unwrap(); + let mut config = fixture(); + config + .network_attachments + .push(NetworkAttachment::Bridge("clamps".to_string())); + container(&system, &files, config).unwrap(); + + let managed_files = files.files.lock().unwrap(); + assert!( + !managed_files.contains_key("/etc/containers/containers.conf.d/90-skillet-aardvark.conf") + ); + let network = managed_files + .get("/etc/containers/systemd/clamps.network") + .unwrap(); + let network = String::from_utf8_lossy(network); + assert!(network.contains("DisableDNS=false")); + assert!(network.contains("Driver=bridge")); + assert!(network.contains("IPv6=true")); + assert!(network.contains("NetworkName=clamps")); + + let quadlet = managed_files + .get("/etc/containers/systemd/unit-fixture.container") + .unwrap(); + assert!(String::from_utf8_lossy(quadlet).contains("Network=clamps.network")); + drop(managed_files); + + let mut repeated = fixture(); + repeated + .network_attachments + .push(NetworkAttachment::Bridge("clamps".to_string())); + container(&system, &files, repeated).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); +} + +#[test] +fn host_dns_listener_policy_is_independently_idempotent() { + let files = MockFiles::new(); + assert!(ensure_dns_listener_port(&files, 54).unwrap()); + assert!(!ensure_dns_listener_port(&files, 54).unwrap()); + assert_eq!( + files.files.lock().unwrap()["/etc/containers/containers.conf.d/90-skillet-aardvark.conf"], + b"[network]\ndns_bind_port=54\n" + ); +} + +#[test] +fn typed_network_and_dual_stack_port_publications_render_quadlet_directives() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config + .network_attachments + .push(NetworkAttachment::Bridge("clamps".to_string())); + config.port_publications = vec![ + PortPublication { + host_address: std::net::Ipv6Addr::UNSPECIFIED.into(), + host_port: 53, + container_port: 53, + protocol: PortProtocol::Udp, + }, + PortPublication { + host_address: std::net::Ipv4Addr::UNSPECIFIED.into(), + host_port: 53, + container_port: 53, + protocol: PortProtocol::Tcp, + }, + ]; + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("Network=clamps.network")); + assert!(quadlet.contains("PublishPort=[::]:53:53/udp")); + assert!(quadlet.contains("PublishPort=0.0.0.0:53:53/tcp")); +} + +#[test] +fn invalid_or_conflicting_typed_container_settings_fail_before_effects() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config.network_attachments = vec![ + NetworkAttachment::Host, + NetworkAttachment::Bridge("clamps".to_string()), + ]; + assert!(matches!( + container(&system, &files, config), + Err(PodmanError::ConflictingContainerDirective("Network")) + )); + assert!(files.files.lock().unwrap().is_empty()); + + let mut config = fixture(); + config.port_publications.push(PortPublication { + host_address: std::net::Ipv4Addr::UNSPECIFIED.into(), + host_port: 0, + container_port: 53, + protocol: PortProtocol::Udp, + }); + assert!(matches!( + container(&system, &files, config), + Err(PodmanError::InvalidPortPublication) + )); + assert!(files.files.lock().unwrap().is_empty()); +} + +#[test] +fn shared_data_mount_dependency_is_rendered_and_invalid_dependency_fails_early() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config.storage_dependency = Some(MountDependency::shared_service_data()); + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + for directive in [ + "Requires=skillet-data-prepare.service", + "After=skillet-data-prepare.service", + "BindsTo=var-lib-data.mount", + "After=var-lib-data.mount", + "AssertPathIsMountPoint=/var/lib/data", + ] { + assert!(quadlet.contains(directive), "missing {directive}"); + } + + let empty_files = MockFiles::new(); + let mut invalid = fixture(); + invalid.storage_dependency = Some(MountDependency { + mount_path: std::path::PathBuf::from("/var/lib/data/../other"), + mount_unit: "var-lib-data.mount".to_string(), + prepare_unit: None, + }); + assert!(matches!( + container(&system, &empty_files, invalid), + Err(PodmanError::InvalidStorageDependency("mount path")) + )); + assert!(empty_files.files.lock().unwrap().is_empty()); +} + +#[test] +fn typed_volume_ownership_changes_only_the_mount_root_metadata() { + use skillet_core::files::{OwnerIdentity, Ownership}; + + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let path = "/var/lib/data/owned-volume"; + files + .files + .lock() + .unwrap() + .insert(format!("{path}/existing.db"), b"preserve".to_vec()); + + let mut config = fixture(); + config.volumes.push(Volume { + host_path: path.to_string(), + container_path: "/data".to_string(), + options: None, + host_mode: Some(0o750), + host_ownership: Some(Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + }), + }); + container(&system, &files, config).unwrap(); + + assert_eq!( + files.directory_metadata.lock().unwrap().get(path), + Some(&( + Some(0o750), + Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + } + )) + ); + assert_eq!( + files + .files + .lock() + .unwrap() + .get(&format!("{path}/existing.db")), + Some(&b"preserve".to_vec()) + ); +} + +#[test] +fn typed_host_root_metadata_does_not_change_existing_quadlet_definition() { + use skillet_core::files::{OwnerIdentity, Ownership}; + + let mut previous = fixture(); + previous.volumes.push(Volume { + host_path: "/var/lib/data/owned-volume".to_string(), + container_path: "/data".to_string(), + options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, + }); + let previous_system = MockSystem::new(); + previous_system + .ensure_podman_secret("dummy", "first") + .unwrap(); + let previous_files = MockFiles::new(); + container(&previous_system, &previous_files, previous).unwrap(); + + let mut typed = fixture(); + typed.volumes.push(Volume { + host_path: "/var/lib/data/owned-volume".to_string(), + container_path: "/data".to_string(), + options: Some("Z".to_string()), + host_mode: Some(0o750), + host_ownership: Some(Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + }), + }); + let typed_system = MockSystem::new(); + typed_system.ensure_podman_secret("dummy", "first").unwrap(); + let typed_files = MockFiles::new(); + container(&typed_system, &typed_files, typed).unwrap(); + + assert_eq!( + previous_files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/unit-fixture.container"), + typed_files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/unit-fixture.container") + ); +} + +#[test] +fn changing_a_created_network_fails_before_replacing_its_quadlet() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + + ensure_network(&system, &files, &clamps_network()).unwrap(); + + let original = files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/clamps.network") + .cloned() + .unwrap(); + let mut changed = clamps_network(); + changed.options.push("Internal=true".to_string()); + assert!(matches!( + ensure_network(&system, &files, &changed), + Err(PodmanError::NetworkConfigChanged(name)) if name == "clamps" + )); + assert_eq!( + files + .files + .lock() + .unwrap() + .get("/etc/containers/systemd/clamps.network"), + Some(&original) + ); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 0); +} + +#[test] +fn repeat_apply_and_stopped_service() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + assert_eq!( + container(&system, &files, fixture()).unwrap().action, + ActivationAction::Restarted + ); + assert_eq!( + container(&system, &files, fixture()).unwrap().action, + ActivationAction::None + ); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); + system.service_stop("unit-fixture").unwrap(); + assert_eq!( + container(&system, &files, fixture()).unwrap().action, + ActivationAction::Started + ); + assert_eq!(system.start_count.load(Ordering::SeqCst), 1); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(!quadlet.lines().any(|line| line.starts_with("User="))); +} + +#[test] +fn numeric_process_identity_does_not_implicitly_create_namespace_mapping() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config.process_identity = ProcessIdentity::Numeric { + uid: 1001, + gid: 1002, + }; + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("User=1001:1002")); + assert!(!quadlet.contains("UIDMap=")); + assert!(!quadlet.contains("GIDMap=")); + assert!(files.files.lock().unwrap().get("/etc/subuid").is_none()); +} + +#[test] +fn named_identity_is_typed_and_conflicting_raw_identity_fails_before_effects() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let files = MockFiles::new(); + let mut config = fixture(); + config.process_identity = ProcessIdentity::Named { + user: "service-user".to_string(), + group: Some("service-group".to_string()), + }; + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("User=service-user:service-group")); + + let empty_files = MockFiles::new(); + let mut conflicting = fixture(); + conflicting + .extra_config + .entry("Container".to_string()) + .or_default() + .push("User=other".to_string()); + assert!(matches!( + container(&system, &empty_files, conflicting), + Err(PodmanError::ConflictingIdentityDirective("User")) + )); + assert!(empty_files.files.lock().unwrap().is_empty()); +} + +#[test] +fn secret_rotation_and_interrupted_restart_recover() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + container(&system, &files, fixture()).unwrap(); + system.ensure_podman_secret("dummy", "second").unwrap(); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(container(&system, &files, fixture()).is_err()); + assert!(container(&system, &files, fixture()).is_ok()); + let after_recovery = system.restart_count.load(Ordering::SeqCst); + assert!(container(&system, &files, fixture()).is_ok()); + assert_eq!(system.restart_count.load(Ordering::SeqCst), after_recovery); +} + +#[test] +fn interrupted_reload_retries_unchanged_quadlet() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + system.fail_reload_once.store(true, Ordering::SeqCst); + assert!(container(&system, &files, fixture()).is_err()); + assert!(container(&system, &files, fixture()).is_ok()); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 1); +} + +#[test] +fn consumed_file_change_restarts_even_when_quadlet_is_unchanged() { + let system = MockSystem::new(); + let files = MockFiles::new(); + system.ensure_podman_secret("dummy", "first").unwrap(); + let mut first = fixture(); + first.config_revisions = vec![b"one".to_vec()]; + container(&system, &files, first).unwrap(); + let mut second = fixture(); + second.config_revisions = vec![b"two".to_vec()]; + let changed = container(&system, &files, second).unwrap(); + assert!(!changed.definition_changed); + assert_eq!(changed.action, ActivationAction::Restarted); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 2); + let mut repeat = fixture(); + repeat.config_revisions = vec![b"two".to_vec()]; + container(&system, &files, repeat).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), 2); +} + +#[test] +fn subordinate_ranges_are_read_from_injected_files_and_render_user_maps() { + let system = MockSystem::new(); + system.ensure_podman_secret("dummy", "payload").unwrap(); + system.user_identities.lock().unwrap().insert( + "service".to_string(), + skillet_core::system::UserIdentity { + name: "service".to_string(), + uid: 1234, + primary_gid: 2345, + }, + ); + let files = MockFiles::new(); + files.files.lock().unwrap().insert( + "/etc/subuid".to_string(), + b"other:300000:65536\nservice:200000:65536\n".to_vec(), + ); + files.files.lock().unwrap().insert( + "/etc/subgid".to_string(), + b"service:400000:65536\n".to_vec(), + ); + let mut config = fixture(); + config.process_identity = ProcessIdentity::Numeric { uid: 999, gid: 999 }; + config.namespace_mapping = Some(UserNamespaceMapping { + host_user: HostUser::Name("service".to_string()), + }); + container(&system, &files, config).unwrap(); + let quadlet = String::from_utf8( + files.files.lock().unwrap()["/etc/containers/systemd/unit-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("User=999:999")); + assert!(quadlet.contains("UIDMap=0:200000:999")); + assert!(quadlet.contains("UIDMap=999:1234:1")); + assert!(quadlet.contains("GIDMap=0:400000:999")); + assert!(quadlet.contains("GIDMap=999:2345:1")); +} + +#[test] +fn missing_or_invalid_subordinate_ranges_fail_closed() { + let missing = MockFiles::new(); + assert!(matches!( + discover_subid_range(&missing, "/etc/subuid", "service", "UID"), + Err(PodmanError::MissingSubordinateRange { kind: "UID", .. }) + )); + + let invalid = MockFiles::new(); + invalid.files.lock().unwrap().insert( + "/etc/subuid".to_string(), + b"service:not-a-number:5\n".to_vec(), + ); + assert!(matches!( + discover_subid_range(&invalid, "/etc/subuid", "service", "UID"), + Err(PodmanError::InvalidSubordinateRange { .. }) + )); + + let duplicate = MockFiles::new(); + duplicate.files.lock().unwrap().insert( + "/etc/subuid".to_string(), + b"service:100000:65536\nservice:200000:65536\n".to_vec(), + ); + assert!(matches!( + discover_subid_range(&duplicate, "/etc/subuid", "service", "UID"), + Err(PodmanError::InvalidSubordinateRange { .. }) + )); +} + +#[test] +fn numeric_container_identity_must_fit_the_subordinate_range() { + assert!(matches!( + validate_subordinate_range( + SubordinateRange { + start: 100_000, + size: 999 + }, + "service", + 999 + ), + Err(PodmanError::SubordinateRangeTooSmall { .. }) + )); +} + +#[test] +fn filesystem_fake_enforces_object_types_and_injected_failures() { + let files = MockFiles::new(); + let file_path = std::path::Path::new("/tmp/mock-file"); + files + .ensure_file(file_path, b"data", None, &Ownership::default()) + .unwrap(); + assert!(matches!( + files.ensure_directory(file_path, None, &Ownership::default()), + Err(FileError::NotADirectory(_)) + )); + + let directory = std::path::Path::new("/tmp/mock-directory"); + files + .ensure_directory(directory, None, &Ownership::default()) + .unwrap(); + assert!(matches!( + files.ensure_file(directory, b"data", None, &Ownership::default()), + Err(FileError::NotAFile(_)) + )); + + files.fail_file_write_once.store(true, Ordering::SeqCst); + assert!(files + .ensure_file( + std::path::Path::new("/tmp/failing-file"), + b"data", + None, + &Ownership::default() + ) + .is_err()); + files.fail_directory_once.store(true, Ordering::SeqCst); + assert!(files + .ensure_directory( + std::path::Path::new("/tmp/failing-directory"), + None, + &Ownership::default() + ) + .is_err()); +} + +#[test] +fn quadlet_directives_preserve_configuration_quotes() { + let directive = r#"Label="example.config={\"name\":\"a&b\"}""#; + let template = QuadletTemplate { + sections: BTreeMap::from([("Container".into(), vec![directive.into()])]), + }; + let quadlet = template.render().unwrap(); + assert!(quadlet.lines().any(|line| line == directive)); + assert!(!quadlet.contains(""")); + assert!(!quadlet.contains("&")); +} 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/crates/smtp/Cargo.toml b/ublue/skillet/crates/smtp/Cargo.toml new file mode 100644 index 00000000..ef9d7253 --- /dev/null +++ b/ublue/skillet/crates/smtp/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "skillet_smtp" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +sha2.workspace = true +hex.workspace = true +askama.workspace = true + +[dev-dependencies] +serde_json.workspace = true +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/smtp/src/lib.rs b/ublue/skillet/crates/smtp/src/lib.rs new file mode 100644 index 00000000..cd9d7664 --- /dev/null +++ b/ublue/skillet/crates/smtp/src/lib.rs @@ -0,0 +1,316 @@ +//! Native fleet SMTP relay configuration and volatile credential preparation. +use askama::Template; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use skillet_core::{ + activation::{self, ActivationRequest, ConsumerKind}, + files::{FileError, FileMutationResource, FileReadResource, Ownership}, + system::{ServiceResource, SystemError}, +}; +use std::path::Path; +use thiserror::Error; + +pub const MONITORED_UNITS: &[&str] = &["postfix.service", "skillet-smtp-prepare.service"]; +pub const CREDENTIAL: &str = "smtp_config"; +pub const MAP_PATH: &str = "/run/postfix/skillet/sasl_passwd"; +const STATE_DIR: &str = "/var/lib/skillet/smtp"; + +#[derive(Debug, Error)] +pub enum SmtpError { + #[error("invalid SMTP configuration; check required fields and security policy")] + Invalid, + #[error("refusing to switch SMTP environment on a retained queue")] + EnvironmentChange, + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + System(#[from] SystemError), + #[error(transparent)] + Activation(#[from] activation::ActivationError), + #[error("SMTP configuration rendering failed")] + Render, +} + +// Intentionally no Debug: production input contains credentials. +#[derive(Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)] +pub enum Input { + Production { + host: String, + port: u16, + tls: String, + username: String, + password: String, + sender: String, + }, + Capture {}, +} +impl Input { + pub fn parse(payload: &str) -> Result { + let input: Self = serde_json::from_str(payload).map_err(|_| SmtpError::Invalid)?; + input.validate()?; + Ok(input) + } + pub fn validate(&self) -> Result<(), SmtpError> { + if let Self::Production { + host, + port, + tls, + username, + password, + sender, + } = self + { + if !valid_host(host) + || *port == 0 + || tls != "starttls" + || !valid_login(username) + || username.contains(':') + || !valid_login(password) + || !valid_sender(sender) + { + return Err(SmtpError::Invalid); + } + } + Ok(()) + } + pub fn payload(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| SmtpError::Invalid) + } + fn mode(&self) -> &'static str { + match self { + Self::Production { .. } => "production", + Self::Capture {} => "capture", + } + } + pub fn main_config(&self) -> Result { + self.validate()?; + let (hostname, relay, auth, tls, password_map, sender_map) = match self { + Self::Production { + host, port, sender, .. + } => ( + sender + .split_once('@') + .ok_or(SmtpError::Invalid)? + .1 + .to_string(), + format!("[{host}]:{port}"), + "yes", + "secure", + format!("texthash:{MAP_PATH}"), + format!("static:{sender}"), + ), + Self::Capture {} => ( + "smtp-capture.invalid".into(), + "[127.0.0.1]:1025".into(), + "no", + "none", + String::new(), + String::new(), + ), + }; + MainConfig { + hostname, + relay, + auth, + tls, + password_map, + sender_map, + } + .render() + .map_err(|_| SmtpError::Render) + } +} +#[derive(Template)] +#[template(path = "main.cf", escape = "none")] +struct MainConfig<'a> { + hostname: String, + relay: String, + auth: &'a str, + tls: &'a str, + password_map: String, + sender_map: String, +} +fn valid_login(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|b| b.is_ascii_graphic()) +} +fn valid_host(value: &str) -> bool { + !value.is_empty() + && value.len() <= 253 + && value.split('.').all(|s| { + !s.is_empty() + && s.len() <= 63 + && !s.starts_with('-') + && !s.ends_with('-') + && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) +} +pub fn valid_sender(value: &str) -> bool { + value.split_once('@').is_some_and(|(local, host)| { + !local.is_empty() + && local + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._+-".contains(&b)) + && valid_host(host) + }) +} + +/// Prepare only tmpfs files; never start a service from its own prerequisite. +pub fn prepare( + files: &F, + input: &Input, +) -> Result<(), SmtpError> { + input.validate()?; + if files + .read_file(&Path::new(STATE_DIR).join("mode"))? + .is_some_and(|mode| mode != input.mode().as_bytes()) + { + return Err(SmtpError::EnvironmentChange); + } + files.ensure_directory(Path::new("/run/postfix"), None, &Ownership::default())?; + files.ensure_directory( + Path::new("/run/postfix/skillet"), + Some(0o750), + &Ownership::named(Some("root"), Some("postfix")), + )?; + let map = match input { + Input::Production { + host, + port, + username, + password, + .. + } => format!("[{host}]:{port} {username}:{password}\n"), + Input::Capture {} => String::new(), + }; + files.ensure_file( + Path::new(MAP_PATH), + map.as_bytes(), + Some(0o640), + &Ownership::named(Some("root"), Some("postfix")), + )?; + Ok(()) +} + +pub fn apply(system: &S, files: &F, payload: &str) -> Result<(), SmtpError> +where + S: ServiceResource + ?Sized, + F: FileReadResource + FileMutationResource + ?Sized, +{ + let input = Input::parse(payload)?; + let mode_path = Path::new(STATE_DIR).join("mode"); + if files + .read_file(&mode_path)? + .is_some_and(|old| old != input.mode().as_bytes()) + { + return Err(SmtpError::EnvironmentChange); + } + files.ensure_directory( + Path::new(STATE_DIR), + Some(0o700), + &Ownership::named(Some("root"), Some("root")), + )?; + // Persist the environment guard before touching configuration or activating a queue. + files.ensure_file( + &mode_path, + input.mode().as_bytes(), + Some(0o600), + &Ownership::named(Some("root"), Some("root")), + )?; + initialize_persistent_state(files)?; + files.ensure_directory(Path::new("/etc/postfix"), None, &Ownership::default())?; + files.ensure_directory( + Path::new("/etc/systemd/system/postfix.service.d"), + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + let config = input.main_config()?; + let mut changed = files.ensure_file( + Path::new("/etc/postfix/main.cf"), + config.as_bytes(), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + changed |= files.ensure_file( + Path::new("/etc/systemd/system/postfix.service.d/skillet.conf"), + include_bytes!("postfix.conf"), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + changed |= files.ensure_file( + Path::new("/etc/systemd/system/skillet-smtp-prepare.service"), + include_bytes!("prepare.service"), + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + if changed { + system.daemon_reload()?; + } + // A fresh decrypt/map preparation is required on every credential redelivery. + let revision = hex::encode(Sha256::digest(payload.as_bytes())).into_bytes(); + if changed + || files + .read_file(&Path::new(STATE_DIR).join("applied"))? + .as_deref() + != Some(revision.as_slice()) + || !system.service_is_active("postfix.service")? + { + system.service_restart("skillet-smtp-prepare.service")?; + } + system.service_enable("postfix.service")?; + activation::activate( + system, + files, + &ActivationRequest { + service: "postfix.service".into(), + state_path: Path::new(STATE_DIR).join("applied"), + revision, + definition_changed: changed, + reload_daemon: false, + consumer_kind: ConsumerKind::Persistent, + }, + )?; + Ok(()) +} + +// Image-layer /var paths need explicit initialization before confined startup. +fn initialize_persistent_state( + files: &F, +) -> Result<(), SmtpError> { + let queue = Path::new("/var/spool/postfix"); + for path in [queue.to_path_buf(), queue.join("pid")] { + files.ensure_directory( + &path, + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + } + for name in [ + "active", "bounce", "corrupt", "defer", "deferred", "flush", "hold", "incoming", "private", + "saved", "trace", + ] { + files.ensure_directory( + &queue.join(name), + Some(0o700), + &Ownership::named(Some("postfix"), Some("root")), + )?; + } + for (name, mode) in [("maildrop", 0o730), ("public", 0o710)] { + files.ensure_directory( + &queue.join(name), + Some(mode), + &Ownership::named(Some("postfix"), Some("postdrop")), + )?; + } + files.ensure_directory( + Path::new("/var/lib/postfix"), + Some(0o700), + &Ownership::named(Some("postfix"), Some("root")), + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/smtp/src/postfix.conf b/ublue/skillet/crates/smtp/src/postfix.conf new file mode 100644 index 00000000..be567293 --- /dev/null +++ b/ublue/skillet/crates/smtp/src/postfix.conf @@ -0,0 +1,7 @@ +[Unit] +Requires=skillet-smtp-prepare.service +After=skillet-smtp-prepare.service network-online.target +Wants=network-online.target + +[Service] +ExecStartPre=/usr/sbin/postfix check diff --git a/ublue/skillet/crates/smtp/src/prepare.service b/ublue/skillet/crates/smtp/src/prepare.service new file mode 100644 index 00000000..e1c3d4f0 --- /dev/null +++ b/ublue/skillet/crates/smtp/src/prepare.service @@ -0,0 +1,10 @@ +[Unit] +Description=Prepare volatile Postfix relay credentials +Before=postfix.service + +[Service] +Type=oneshot +RemainAfterExit=yes +LoadCredentialEncrypted=smtp_config:/etc/credstore.encrypted/skillet/smtp_config.cred +ExecStart=/var/usrlocal/bin/skillet smtp prepare +ExecStartPost=/usr/sbin/restorecon -RF /var/spool/postfix /var/lib/postfix diff --git a/ublue/skillet/crates/smtp/src/tests.rs b/ublue/skillet/crates/smtp/src/tests.rs new file mode 100644 index 00000000..0ec2c8e5 --- /dev/null +++ b/ublue/skillet/crates/smtp/src/tests.rs @@ -0,0 +1,170 @@ +use super::*; +use skillet_core::{ + files::FileReadResource, + test_utils::{MockFiles, MockSystem}, +}; +use std::sync::atomic::Ordering; + +fn production() -> Input { + Input::Production { + host: "smtp.example.com".into(), + port: 587, + tls: "starttls".into(), + username: "fixture-key".into(), + password: "fixture-secret".into(), + sender: "server@example.com".into(), + } +} +#[test] +fn production_config_requires_verified_tls_and_loopback_submission() { + let config = production().main_config().unwrap(); + for directive in [ + "relayhost = [smtp.example.com]:587", + "smtp_tls_security_level = secure", + "smtp_tls_CAfile = /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "smtp_tls_loglevel = 1", + "inet_interfaces = loopback-only", + "smtp_sasl_auth_enable = yes", + "sender_canonical_maps = static:server@example.com", + "smtp_fallback_relay =\n", + ] { + assert!(config.contains(directive)); + } + assert!(!config.contains("fixture-secret")); + assert!(!config.contains("fixture-key")); + assert!(!config.contains("smtp_generic_maps")); +} +#[test] +fn capture_has_no_external_route_authentication_or_sender_rewriting() { + let config = Input::Capture {}.main_config().unwrap(); + assert!(config.contains("relayhost = [127.0.0.1]:1025")); + assert!(config.contains("smtp_sasl_auth_enable = no")); + assert!(config.contains("sender_canonical_maps = ")); +} +#[test] +fn invalid_input_is_rejected_without_echoing_payload_or_mutating_files() { + let files = MockFiles::new(); + let system = MockSystem::new(); + for input in [ + "PRIVATE-INVALID", + r#"{"mode":"PRIVATE-VALUE"}"#, + r#"{"mode":"capture","password":"PRIVATE-VALUE"}"#, + ] { + assert_eq!( + apply(&system, &files, input).unwrap_err().to_string(), + SmtpError::Invalid.to_string() + ); + } + assert_eq!(files.files.lock().unwrap().len(), 0); + for sender in [ + "bad\n@example.com", + "name@bad domain", + "name@example.com:25", + "a@b@c", + "", + ] { + assert!(!valid_sender(sender)); + } +} +#[test] +fn volatile_map_has_restricted_metadata_and_is_not_recorded() { + let files = MockFiles::new(); + let contents = files.files.clone(); + let metadata = files.metadata.clone(); + let recorder = skillet_core::recorder::Recorder::new(files); + prepare(&recorder, &production()).unwrap(); + assert_eq!( + contents.lock().unwrap()[MAP_PATH], + b"[smtp.example.com]:587 fixture-key:fixture-secret\n" + ); + assert_eq!(metadata.lock().unwrap()[MAP_PATH].0, Some(0o640)); + let recording = serde_json::to_string(&recorder.get_ops()).unwrap(); + assert!(!recording.contains("fixture-secret")); +} +#[test] +fn repeat_apply_is_noop_and_failed_activation_is_retryable() { + let files = MockFiles::new(); + let system = MockSystem::new(); + let payload = Input::Capture {}.payload().unwrap(); + apply(&system, &files, &payload).unwrap(); + let restarts = system.restart_count.load(Ordering::SeqCst); + apply(&system, &files, &payload).unwrap(); + assert_eq!(system.restart_count.load(Ordering::SeqCst), restarts); + assert!(apply(&system, &files, &production().payload().unwrap()).is_err()); + let files = MockFiles::new(); + let system = MockSystem::new(); + system.fail_restart_once.store(true, Ordering::SeqCst); + assert!(apply(&system, &files, &payload).is_err()); + assert!(files + .read_file(Path::new("/var/lib/skillet/smtp/applied")) + .unwrap() + .is_none()); + apply(&system, &files, &payload).unwrap(); + assert!(files + .read_file(Path::new("/var/lib/skillet/smtp/applied")) + .unwrap() + .is_some()); +} + +#[test] +fn mismatched_credentials_cannot_prepare_a_retained_environment() { + let files = MockFiles::new(); + apply( + &MockSystem::new(), + &files, + &Input::Capture {}.payload().unwrap(), + ) + .unwrap(); + assert!(matches!( + prepare(&files, &production()), + Err(SmtpError::EnvironmentChange) + )); + assert!(files.read_file(Path::new(MAP_PATH)).unwrap().is_none()); +} + +#[test] +fn fresh_apply_initializes_the_persistent_queue_root() { + let files = MockFiles::new(); + apply( + &MockSystem::new(), + &files, + &Input::Capture {}.payload().unwrap(), + ) + .unwrap(); + let metadata = files.directory_metadata.lock().unwrap(); + assert_eq!(metadata["/var/spool/postfix"].0, Some(0o755)); + assert_eq!(metadata["/var/spool/postfix/pid"].0, Some(0o755)); + for name in [ + "active", "bounce", "corrupt", "defer", "deferred", "flush", "hold", "incoming", "private", + "saved", "trace", + ] { + assert_eq!( + metadata[&format!("/var/spool/postfix/{name}")], + (Some(0o700), Ownership::named(Some("postfix"), Some("root"))) + ); + } + for (name, mode) in [("maildrop", 0o730), ("public", 0o710)] { + assert_eq!( + metadata[&format!("/var/spool/postfix/{name}")], + ( + Some(mode), + Ownership::named(Some("postfix"), Some("postdrop")) + ) + ); + } + assert_eq!( + metadata["/var/lib/postfix"], + (Some(0o700), Ownership::named(Some("postfix"), Some("root"))) + ); +} + +#[test] +fn prepare_unit_preserves_runtime_credential_labels() { + let unit = include_str!("prepare.service"); + let restore = unit + .lines() + .find(|line| line.starts_with("ExecStartPost=")) + .unwrap(); + assert!(!restore.contains("/run/postfix/skillet")); + assert!(restore.contains("/var/spool/postfix /var/lib/postfix")); +} diff --git a/ublue/skillet/crates/smtp/templates/main.cf b/ublue/skillet/crates/smtp/templates/main.cf new file mode 100644 index 00000000..7f39bee2 --- /dev/null +++ b/ublue/skillet/crates/smtp/templates/main.cf @@ -0,0 +1,33 @@ +# Managed by Skillet. Local submission only; no destination-MX fallback. +compatibility_level = 3.6 +queue_directory = /var/spool/postfix +inet_interfaces = loopback-only +inet_protocols = all +myhostname = {{ hostname }} +myorigin = $myhostname +mydestination = +mynetworks = 127.0.0.0/8 [::1]/128 +relay_domains = +smtpd_client_restrictions = permit_mynetworks, reject +smtpd_relay_restrictions = permit_mynetworks, reject +smtpd_recipient_restrictions = permit_mynetworks, reject +smtpd_helo_required = yes +disable_vrfy_command = yes +relayhost = {{ relay }} +smtp_fallback_relay = +default_transport = smtp +relay_transport = smtp +local_transport = error:5.1.1 Local delivery disabled +alias_maps = +alias_database = +smtp_sasl_auth_enable = {{ auth }} +smtp_sasl_password_maps = {{ password_map }} +smtp_sasl_security_options = noanonymous +smtp_sasl_tls_security_options = noanonymous +smtp_tls_security_level = {{ tls }} +smtp_tls_CAfile = /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem +smtp_tls_loglevel = 1 +smtp_tls_secure_cert_match = nexthop +smtp_tls_wrappermode = no +sender_canonical_classes = envelope_sender, header_sender +sender_canonical_maps = {{ sender_map }} diff --git a/ublue/skillet/crates/smtp/tests/native_acceptance.py b/ublue/skillet/crates/smtp/tests/native_acceptance.py new file mode 100644 index 00000000..eaab8eb6 --- /dev/null +++ b/ublue/skillet/crates/smtp/tests/native_acceptance.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Run inside a network-isolated, disposable Fedora systemd test container. + +Only synthetic credentials/addresses are used. VM transport/lifecycle remains +owned by Skillet. Supply the deployed CLI path and a declared host profile. +""" + +import argparse +import json +import pathlib +import shutil +import socket +import socketserver +import subprocess +import threading +import time + + +def run(*args, payload=None): + return subprocess.run( + args, input=payload, capture_output=True, check=True, timeout=45 + ).stdout + + +def wait_for(predicate): + end = time.monotonic() + 30 + while time.monotonic() < end: + try: + if predicate(): + return + except subprocess.CalledProcessError: + pass + time.sleep(0.2) + raise AssertionError("bounded SMTP observation timed out") + + +def queue(): + return [json.loads(line) for line in run("postqueue", "-j").splitlines()] + + +class Sink(socketserver.StreamRequestHandler): + def handle(self): + self.wfile.write(b"220 isolated test sink\r\n") + message = None + while line := self.rfile.readline(): + self.server.commands.append(line.split(b" ", 1)[0].strip().upper()) + if message is not None: + if line == b".\r\n": + self.server.messages.append(b"".join(message)) + message = None + self.wfile.write(b"250 captured\r\n") + else: + message.append(line) + continue + verb = line.split(b" ", 1)[0].strip().upper() + if verb == b"DATA": + message = [] + self.wfile.write(b"354 send data\r\n") + elif verb == b"QUIT": + self.wfile.write(b"221 bye\r\n") + break + else: + # No STARTTLS or AUTH advertised: production must defer. + self.wfile.write(b"250 isolated-test\r\n") + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary") + parser.add_argument("host") + parser.add_argument("--production-policy", action="store_true") + args = parser.parse_args() + if pathlib.Path("/run/systemd/container").read_text().strip() not in { + "podman", + "oci", + } or {name for _, name in socket.if_nameindex()} != {"lo"}: + raise RuntimeError( + "native fixture requires a disposable Podman container with network=none" + ) + wait_for( + lambda: ( + run( + "systemctl", "show", "multi-user.target", "-p", "ActiveState", "--value" + ).strip() + == b"active" + ) + ) + # Reproduce ostree: package image /var directories need runtime initialization. + assert ( + run( + "systemctl", "show", "postfix.service", "-p", "ActiveState", "--value" + ).strip() + != b"active" + ) + shutil.rmtree("/var/spool/postfix", ignore_errors=True) + assert not pathlib.Path("/var/spool/postfix").exists() + shutil.rmtree("/var/lib/postfix", ignore_errors=True) + assert not pathlib.Path("/var/lib/postfix").exists() + config = {"mode": "capture"} + if args.production_policy: + config = { + "mode": "production", + "host": "localhost", + "port": 1025, + "tls": "starttls", + "username": "fixture-key", + "password": "fixture-secret", + "sender": "server@example.invalid", + } + credential = pathlib.Path("/etc/credstore.encrypted/skillet/smtp_config.cred") + credential.parent.mkdir(parents=True, exist_ok=True) + run( + "systemd-creds", + "encrypt", + "--with-key=host", + "--name=smtp_config", + "-", + str(credential), + payload=json.dumps(config).encode(), + ) + + def apply(): + return run( + "systemd-run", + "--wait", + "--pipe", + "--collect", + "-p", + f"LoadCredentialEncrypted=smtp_config:{credential}", + args.binary, + "apply", + "--host", + args.host, + "--phase", + "smtp", + ) + + apply() + assert pathlib.Path( + run("postconf", "-h", "smtp_tls_CAfile").decode().strip() + ).is_file(), "configured CA trust bundle must exist" + pid = run("systemctl", "show", "postfix.service", "-p", "MainPID", "--value") + apply() + assert pid == run( + "systemctl", "show", "postfix.service", "-p", "MainPID", "--value" + ) + assert ( + pathlib.Path("/run/postfix/skillet/sasl_passwd").stat().st_mode & 0o777 == 0o640 + ) + if args.production_policy: + assert ( + run( + "runuser", + "-u", + "postfix", + "--", + "postmap", + "-q", + "[localhost]:1025", + "texthash:/run/postfix/skillet/sasl_passwd", + ).strip() + == b"fixture-key:fixture-secret" + ) + # No upstream exists yet: submission must queue durably. + run( + "sendmail", + "-i", + "-f", + "smoke@example.invalid", + "receiver@example.invalid", + payload=b"From: smoke@example.invalid\nTo: receiver@example.invalid\nSubject: isolated acceptance\n\nfixture-marker\n", + ) + wait_for(lambda: len(queue()) == 1) + assert queue()[0]["recipients"][0]["address"] == "receiver@example.invalid" + run("systemctl", "stop", "postfix.service") + apply() + assert len(queue()) == 1 + with Server(("127.0.0.1", 1025), Sink) as sink: + sink.messages = [] + sink.commands = [] + threading.Thread(target=sink.serve_forever, daemon=True).start() + run("postqueue", "-f") + if args.production_policy: + wait_for(lambda: b"EHLO" in sink.commands) + time.sleep(2) + assert len(queue()) == 1 + assert b"AUTH" not in sink.commands + assert b"MAIL" not in sink.commands + assert sink.messages == [] + assert queue()[0]["sender"] == "server@example.invalid" + else: + wait_for(lambda: bool(sink.messages)) + assert b"fixture-marker" in sink.messages[0] + wait_for(lambda: queue() == []) + sink.shutdown() + # The prerequisite regenerates tmpfs state without workstation access. + pathlib.Path("/run/postfix/skillet/sasl_passwd").unlink() + run("systemctl", "restart", "skillet-smtp-prepare.service") + assert pathlib.Path("/run/postfix/skillet/sasl_passwd").is_file() + print( + "PASS: native Postfix credential preparation, repeat apply, queue restart/retry, and TLS policy" + ) + + +if __name__ == "__main__": + main() diff --git a/ublue/skillet/crates/syncthing/Cargo.toml b/ublue/skillet/crates/syncthing/Cargo.toml new file mode 100644 index 00000000..89769bf0 --- /dev/null +++ b/ublue/skillet/crates/syncthing/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skillet_syncthing" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_datadog.workspace = true +skillet_core.workspace = true +skillet_podman.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/syncthing/src/lib.rs b/ublue/skillet/crates/syncthing/src/lib.rs new file mode 100644 index 00000000..5ce06998 --- /dev/null +++ b/ublue/skillet/crates/syncthing/src/lib.rs @@ -0,0 +1,185 @@ +use skillet_core::files::{ + FileError, FileMutationResource, FileReadResource, Ownership, StorageResource, +}; +use skillet_core::system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, +}; +use skillet_podman::{ + self, HostUser, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, PortProtocol, + PortPublication, ProcessIdentity, UserNamespaceMapping, Volume, +}; +use std::collections::BTreeMap; +use std::path::Path; +use thiserror::Error; +use tracing::info; + +#[derive(Error, Debug)] +pub enum SyncthingError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), + #[error("configured Syncthing data owner user `{0}` does not exist")] + UnknownDataOwner(String), + #[error("configured Syncthing data owner group `{0}` does not exist")] + UnknownDataGroup(String), + #[error("configured Syncthing data group `{group}` is not the primary group of `{owner}`")] + NonPrimaryDataGroup { owner: String, group: String }, + #[error("Syncthing user-file scope must be a separate directory beneath its data root")] + InvalidFileScope, +} + +pub struct SyncthingConfig { + pub files_path: String, + pub data_path: String, + pub data_owner: String, + pub data_group: String, + pub container_uid: u32, + pub container_gid: u32, + pub network_name: String, +} + +pub fn apply(system: &S, files: &F, config: SyncthingConfig) -> Result<(), SyncthingError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, +{ + info!("Applying Syncthing configuration..."); + let container_home = container_home(&config)?; + let user = system + .user_by_name(&config.data_owner)? + .ok_or_else(|| SyncthingError::UnknownDataOwner(config.data_owner.clone()))?; + let group = system + .group_by_name(&config.data_group)? + .ok_or_else(|| SyncthingError::UnknownDataGroup(config.data_group.clone()))?; + if user.primary_gid != group.gid { + return Err(SyncthingError::NonPrimaryDataGroup { + owner: config.data_owner, + group: config.data_group, + }); + } + + prepare_storage(files, &config)?; + let volumes = vec![Volume { + host_path: config.data_path, + container_path: "/var/syncthing".to_string(), + options: Some("z".to_string()), + host_mode: None, + host_ownership: None, + }]; + + let mut extra_config = BTreeMap::new(); + extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); + extra_config.insert( + "Container".to_string(), + vec![ + "AutoUpdate=registry".to_string(), + "ContainerName=syncthing".to_string(), + format!("Environment=HOME={container_home}"), + skillet_datadog::http("syncthing", "http://%%host%%:8384/rest/noauth/health"), + ], + ); + extra_config.insert( + "Unit".to_string(), + vec![ + "Description=Syncthing file synchronization".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: "syncthing".to_string(), + image: "docker.io/syncthing/syncthing:latest".to_string(), + network_attachments: vec![NetworkAttachment::Bridge(config.network_name)], + port_publications: vec![ + PortPublication { + host_address: std::net::Ipv6Addr::UNSPECIFIED.into(), + host_port: 22000, + container_port: 22000, + protocol: PortProtocol::Tcp, + }, + PortPublication { + host_address: std::net::Ipv4Addr::UNSPECIFIED.into(), + host_port: 22000, + container_port: 22000, + protocol: PortProtocol::Tcp, + }, + PortPublication { + host_address: std::net::Ipv6Addr::UNSPECIFIED.into(), + host_port: 22000, + container_port: 22000, + protocol: PortProtocol::Udp, + }, + PortPublication { + host_address: std::net::Ipv4Addr::UNSPECIFIED.into(), + host_port: 22000, + container_port: 22000, + protocol: PortProtocol::Udp, + }, + ], + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::Numeric { + uid: config.container_uid, + gid: config.container_gid, + }, + namespace_mapping: Some(UserNamespaceMapping { + host_user: HostUser::Name(config.data_owner), + }), + volumes, + secrets: Vec::new(), + config_revisions: Vec::new(), + extra_config, + }, + )?; + + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; + +fn container_home(config: &SyncthingConfig) -> Result { + let file_scope = Path::new(&config.files_path) + .strip_prefix(&config.data_path) + .map_err(|_| SyncthingError::InvalidFileScope)?; + if file_scope.as_os_str().is_empty() + || file_scope.starts_with("config") + || file_scope + .components() + .any(|part| !matches!(part, std::path::Component::Normal(_))) + { + return Err(SyncthingError::InvalidFileScope); + } + Ok(format!("/var/syncthing/{}", file_scope.display())) +} + +fn prepare_storage(files: &F, config: &SyncthingConfig) -> Result<(), SyncthingError> +where + F: FileMutationResource + StorageResource + ?Sized, +{ + let data_path = Path::new(&config.data_path); + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + files.ensure_btrfs_subvolume(data_path)?; + files.ensure_directory( + data_path, + Some(0o755), + &Ownership::named(Some(&config.data_owner), Some(&config.data_group)), + )?; + + files.ensure_directory( + Path::new(&config.files_path), + Some(0o755), + &Ownership::named(Some(&config.data_owner), Some(&config.data_group)), + )?; + Ok(()) +} diff --git a/ublue/skillet/crates/syncthing/src/tests.rs b/ublue/skillet/crates/syncthing/src/tests.rs new file mode 100644 index 00000000..494b7d29 --- /dev/null +++ b/ublue/skillet/crates/syncthing/src/tests.rs @@ -0,0 +1,192 @@ +use super::*; +use skillet_core::{ + system::{GroupIdentity, UserIdentity}, + test_utils::{MockFiles, MockSystem}, +}; + +#[test] +fn syncthing_uses_persistent_data_and_shared_dns_network() { + let system = MockSystem::new(); + system.user_identities.lock().unwrap().insert( + "giacomo".to_string(), + UserIdentity { + name: "giacomo".to_string(), + uid: 1042, + primary_gid: 2047, + }, + ); + system.group_identities.lock().unwrap().insert( + "giacomo".to_string(), + GroupIdentity { + name: "giacomo".to_string(), + gid: 2047, + }, + ); + let files = MockFiles::new(); + setup_data_mount(&files); + files.files.lock().unwrap().insert( + "/etc/subuid".to_string(), + b"giacomo:100000:65536\n".to_vec(), + ); + files.files.lock().unwrap().insert( + "/etc/subgid".to_string(), + b"giacomo:200000:65536\n".to_vec(), + ); + + apply( + &system, + &files, + SyncthingConfig { + files_path: "/var/lib/data/syncthing/data".to_string(), + data_path: "/var/lib/data/syncthing".to_string(), + data_owner: "giacomo".to_string(), + data_group: "giacomo".to_string(), + container_uid: 1000, + container_gid: 1000, + network_name: "clamps".to_string(), + }, + ) + .unwrap(); + + let managed_files = files.files.lock().unwrap(); + let quadlet = managed_files + .get("/etc/containers/systemd/syncthing.container") + .unwrap(); + let quadlet = String::from_utf8_lossy(quadlet); + for directive in [ + "AutoUpdate=registry", + "ContainerName=syncthing", + "Environment=HOME=/var/syncthing/data", + "User=1000:1000", + "UIDMap=1000:1042:1", + "GIDMap=1000:2047:1", + "Network=clamps.network", + "PublishPort=[::]:22000:22000/tcp", + "PublishPort=0.0.0.0:22000:22000/tcp", + "PublishPort=[::]:22000:22000/udp", + "PublishPort=0.0.0.0:22000:22000/udp", + "Volume=/var/lib/data/syncthing:/var/syncthing:z", + ] { + assert!(quadlet.contains(directive), "missing directive {directive}"); + } + assert!(!quadlet.contains("Environment=PGID=")); + assert!(!quadlet.contains("Environment=PUID=")); + assert!( + !quadlet + .lines() + .any(|line| line.starts_with("PublishPort=") && line.contains("8384")), + "the GUI must only be reachable through the private reverse proxy" + ); +} + +#[test] +fn user_files_are_separate_from_configuration_and_cannot_escape_the_mount() { + let mut config = SyncthingConfig { + files_path: "/var/lib/data/syncthing/data".into(), + data_path: "/var/lib/data/syncthing".into(), + data_owner: "owner".into(), + data_group: "owner".into(), + container_uid: 1000, + container_gid: 1000, + network_name: "example".into(), + }; + assert_eq!(container_home(&config).unwrap(), "/var/syncthing/data"); + for path in [ + "/var/lib/data/syncthing", + "/var/lib/data/syncthing/config", + "/var/lib/data/syncthing/config/files", + "/var/lib/data/syncthing/../other", + "/var/lib/data/other", + ] { + config.files_path = path.into(); + assert!(matches!( + container_home(&config), + Err(SyncthingError::InvalidFileScope) + )); + } +} + +#[test] +fn syncthing_requires_declared_host_account_identities() { + let system = MockSystem::new(); + let files = MockFiles::new(); + setup_data_mount(&files); + + let result = apply( + &system, + &files, + SyncthingConfig { + files_path: "/var/lib/data/syncthing/data".to_string(), + data_path: "/var/lib/data/syncthing".to_string(), + data_owner: "giacomo".to_string(), + data_group: "giacomo".to_string(), + container_uid: 1000, + container_gid: 1000, + network_name: "clamps".to_string(), + }, + ); + + assert!(matches!(result, Err(SyncthingError::UnknownDataOwner(name)) if name == "giacomo")); + assert!(!files + .directories + .lock() + .unwrap() + .contains("/var/lib/data/syncthing")); +} + +#[test] +fn syncthing_rejects_a_non_primary_host_group_for_namespace_mapping() { + let system = MockSystem::new(); + system.user_identities.lock().unwrap().insert( + "giacomo".to_string(), + UserIdentity { + name: "giacomo".to_string(), + uid: 1042, + primary_gid: 2047, + }, + ); + system.group_identities.lock().unwrap().insert( + "data".to_string(), + GroupIdentity { + name: "data".to_string(), + gid: 2048, + }, + ); + let files = MockFiles::new(); + setup_data_mount(&files); + + let result = apply( + &system, + &files, + SyncthingConfig { + files_path: "/var/lib/data/syncthing/data".to_string(), + data_path: "/var/lib/data/syncthing".to_string(), + data_owner: "giacomo".to_string(), + data_group: "data".to_string(), + container_uid: 1000, + container_gid: 1000, + network_name: "clamps".to_string(), + }, + ); + + assert!(matches!( + result, + Err(SyncthingError::NonPrimaryDataGroup { owner, group }) + if owner == "giacomo" && group == "data" + )); + assert!(!files + .directories + .lock() + .unwrap() + .contains("/var/lib/data/syncthing")); +} + +fn setup_data_mount(files: &MockFiles) { + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/test", + "/data", + "btrfs", + ); +} diff --git a/ublue/skillet/crates/test-fixture/Cargo.toml b/ublue/skillet/crates/test-fixture/Cargo.toml new file mode 100644 index 00000000..3db7b7ce --- /dev/null +++ b/ublue/skillet/crates/test-fixture/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skillet-smoke-fixture" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_core.workspace = true +skillet_podman.workspace = true +skillet_cli_common.workspace = true +thiserror.workspace = true +anyhow = "1.0" + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/test-fixture/src/lib.rs b/ublue/skillet/crates/test-fixture/src/lib.rs new file mode 100644 index 00000000..90c22dbb --- /dev/null +++ b/ublue/skillet/crates/test-fixture/src/lib.rs @@ -0,0 +1,128 @@ +//! Disposable application used only by the real-runtime acceptance fixture. + +use skillet_core::{ + files::{FileError, FileResource, Ownership}, + system::{SystemError, SystemResource}, +}; +use skillet_podman::{ + PodmanConfig, PodmanError, ProcessIdentity, QuadletSecret, SecretTarget, Volume, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; + +const INPUT_DIR: &str = "/var/lib/skillet-smoke/desired"; +const CONFIG_PATH: &str = "/etc/skillet-smoke/config"; +const SECRET_NAME: &str = "skillet-smoke-dummy"; +const ENTRYPOINT: &[u8] = include_bytes!("../../../integration_tests/fixture-entrypoint.sh"); + +#[derive(Debug, Error)] +pub enum FixtureError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), + #[error("Fixture input error: {0}")] + Input(String), +} + +pub fn apply(system: &dyn SystemResource, files: &dyn FileResource) -> Result<(), FixtureError> { + let config = files + .read_file(&Path::new(INPUT_DIR).join("config"))? + .ok_or_else(|| FixtureError::Input("missing desired config".to_string()))?; + let secret = files + .read_file(&Path::new(INPUT_DIR).join("secret"))? + .ok_or_else(|| FixtureError::Input("missing desired dummy secret".to_string()))?; + let secret = String::from_utf8(secret) + .map_err(|_| FixtureError::Input("dummy secret must be UTF-8".to_string()))?; + files.ensure_directory( + Path::new("/etc/skillet-smoke"), + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + files.ensure_directory( + Path::new("/var/lib/skillet-smoke/data"), + Some(0o755), + &Ownership::named(Some("root"), Some("root")), + )?; + files.ensure_file( + Path::new(CONFIG_PATH), + &config, + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + files.ensure_file( + Path::new("/etc/skillet-smoke/entrypoint.sh"), + ENTRYPOINT, + Some(0o644), + &Ownership::named(Some("root"), Some("root")), + )?; + system.ensure_podman_secret(SECRET_NAME, &secret)?; + + let mut extra_config = BTreeMap::new(); + extra_config.insert( + "Container".to_string(), + vec![ + "ContainerName=skillet-smoke-fixture".to_string(), + "Exec=/bin/sh /fixture/entrypoint.sh".to_string(), + ], + ); + extra_config.insert( + "Service".to_string(), + vec![ + "ExecStartPre=/usr/bin/test -e /var/lib/skillet-smoke/allow-start".to_string(), + "Restart=no".to_string(), + ], + ); + extra_config.insert( + "Install".to_string(), + vec!["WantedBy=multi-user.target".to_string()], + ); + + skillet_podman::container( + system, + files, + PodmanConfig { + name: "skillet-smoke-fixture".to_string(), + image: "docker.io/library/alpine:3.20".to_string(), + network_attachments: Vec::new(), + port_publications: Vec::new(), + storage_dependency: None, + process_identity: ProcessIdentity::ImageDefault, + namespace_mapping: None, + volumes: vec![ + Volume { + host_path: "/etc/skillet-smoke".to_string(), + container_path: "/fixture".to_string(), + options: Some("ro,Z".to_string()), + host_mode: None, + host_ownership: None, + }, + Volume { + host_path: "/var/lib/skillet-smoke/data".to_string(), + container_path: "/data".to_string(), + options: Some("Z".to_string()), + host_mode: None, + host_ownership: None, + }, + ], + secrets: vec![QuadletSecret { + secret_name: SECRET_NAME.to_string(), + target: SecretTarget::File { + target_path: "/run/secrets/skillet-smoke-dummy".to_string(), + mode: Some("0400".to_string()), + uid: None, + gid: None, + }, + }], + config_revisions: vec![config], + extra_config, + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/test-fixture/src/main.rs b/ublue/skillet/crates/test-fixture/src/main.rs new file mode 100644 index 00000000..33db9092 --- /dev/null +++ b/ublue/skillet/crates/test-fixture/src/main.rs @@ -0,0 +1,9 @@ +use anyhow::Context as _; + +fn main() -> anyhow::Result<()> { + skillet_cli_common::handle_apply("smoke fixture", None, |system, files| { + skillet_smoke_fixture::apply(system, files).map_err(|error| error.to_string()) + }) + .context("applying smoke fixture failed")?; + Ok(()) +} diff --git a/ublue/skillet/crates/test-fixture/src/tests.rs b/ublue/skillet/crates/test-fixture/src/tests.rs new file mode 100644 index 00000000..cd33884e --- /dev/null +++ b/ublue/skillet/crates/test-fixture/src/tests.rs @@ -0,0 +1,33 @@ +use super::*; +use skillet_core::test_utils::{MockFiles, MockSystem}; + +#[test] +fn fixture_requires_explicit_inputs() { + assert!(matches!( + apply(&MockSystem::new(), &MockFiles::new()), + Err(FixtureError::Input(_)) + )); +} + +#[test] +fn fixture_uses_shared_host_container_resources() { + let system = MockSystem::new(); + let files = MockFiles::new(); + files.files.lock().unwrap().insert( + format!("{INPUT_DIR}/config"), + b"config-secret-marker".to_vec(), + ); + files + .files + .lock() + .unwrap() + .insert(format!("{INPUT_DIR}/secret"), b"dummy".to_vec()); + apply(&system, &files).unwrap(); + let resources = files.files.lock().unwrap(); + let quadlet = String::from_utf8( + resources["/etc/containers/systemd/skillet-smoke-fixture.container"].clone(), + ) + .unwrap(); + assert!(quadlet.contains("Secret=skillet-smoke-dummy")); + assert!(resources.contains_key("/var/lib/skillet/containers/skillet-smoke-fixture.applied")); +} diff --git a/ublue/skillet/crates/unifi/Cargo.toml b/ublue/skillet/crates/unifi/Cargo.toml new file mode 100644 index 00000000..68c3f6b1 --- /dev/null +++ b/ublue/skillet/crates/unifi/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skillet_unifi" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +skillet_datadog.workspace = true +skillet_core.workspace = true +skillet_podman.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +skillet_core = { workspace = true, features = ["test-utils"] } diff --git a/ublue/skillet/crates/unifi/examples/acceptance_apply.rs b/ublue/skillet/crates/unifi/examples/acceptance_apply.rs new file mode 100644 index 00000000..dd69cd35 --- /dev/null +++ b/ublue/skillet/crates/unifi/examples/acceptance_apply.rs @@ -0,0 +1,8 @@ +//! Apply the production `UniFi` module alone on an already prepared disposable guest. +//! This test driver does not import backups or configure other host services. +use skillet_core::{files::LocalFileResource, system::LinuxSystemResource}; + +fn main() -> Result<(), Box> { + skillet_unifi::apply(&LinuxSystemResource::new(), &LocalFileResource::new())?; + Ok(()) +} diff --git a/ublue/skillet/crates/unifi/src/lib.rs b/ublue/skillet/crates/unifi/src/lib.rs new file mode 100644 index 00000000..2ca381aa --- /dev/null +++ b/ublue/skillet/crates/unifi/src/lib.rs @@ -0,0 +1,98 @@ +use skillet_core::files::{ + FileError, FileMutationResource, FileReadResource, OwnerIdentity, Ownership, StorageResource, +}; +use skillet_core::system::{ + AccountLookupResource, AccountResource, PodmanSecretResource, ServiceResource, SystemError, +}; +use skillet_podman::{ + self, MountDependency, NetworkAttachment, PodmanConfig, PodmanError, ProcessIdentity, Volume, +}; +use std::{collections::BTreeMap, path::Path}; +use thiserror::Error; +use tracing::info; + +const DATA_PATH: &str = "/var/lib/data/unifi"; + +#[derive(Debug, Error)] +pub enum UnifiError { + #[error("System error: {0}")] + System(#[from] SystemError), + #[error("File error: {0}")] + File(#[from] FileError), + #[error("Podman error: {0}")] + Podman(#[from] PodmanError), +} + +/// Configure the rootful `UniFi Network` container and its persistent state. +/// +/// The rootful Podman service uses host networking so `UniFi` can discover and +/// adopt devices on the LAN. The image's `unifi` user writes as UID/GID 999; +/// match the volume root to those numeric identities without recursively +/// changing existing application data or requiring matching host accounts. +pub fn apply(system: &S, files: &F) -> Result<(), UnifiError> +where + S: AccountLookupResource + AccountResource + PodmanSecretResource + ServiceResource + ?Sized, + F: FileMutationResource + FileReadResource + StorageResource + ?Sized, +{ + info!("Applying UniFi Network container..."); + files.require_btrfs_subvolume_mount(Path::new("/var/lib/data"), Path::new("/var"), "/data")?; + files.ensure_btrfs_subvolume(Path::new(DATA_PATH))?; + let mut extra_config = BTreeMap::new(); + extra_config.insert( + "Container".to_string(), + vec![ + "AutoUpdate=registry".to_string(), + "ContainerName=unifi".to_string(), + skillet_datadog::process("unifi", "ace.jar"), + "Environment=TZ=Europe/Madrid".to_string(), + ], + ); + extra_config.insert("Service".to_string(), vec!["Restart=always".to_string()]); + extra_config.insert( + "Unit".to_string(), + vec![ + "Description=UniFi Network application".to_string(), + "After=network-online.target".to_string(), + "Wants=network-online.target".to_string(), + ], + ); + extra_config.insert( + "Install".to_string(), + vec!["WantedBy=multi-user.target default.target".to_string()], + ); + + skillet_podman::container( + system, + files, + PodmanConfig { + name: "unifi".to_string(), + image: "docker.io/jacobalberty/unifi:latest".to_string(), + network_attachments: vec![NetworkAttachment::Host], + port_publications: Vec::new(), + storage_dependency: Some(MountDependency::shared_service_data()), + process_identity: ProcessIdentity::Named { + user: "unifi".to_string(), + group: None, + }, + namespace_mapping: None, + volumes: vec![Volume { + host_path: DATA_PATH.to_string(), + container_path: "/unifi".to_string(), + options: Some("Z".to_string()), + host_mode: Some(0o750), + host_ownership: Some(Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + }), + }], + secrets: Vec::new(), + config_revisions: Vec::new(), + extra_config, + }, + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/unifi/src/tests.rs b/ublue/skillet/crates/unifi/src/tests.rs new file mode 100644 index 00000000..c704afd5 --- /dev/null +++ b/ublue/skillet/crates/unifi/src/tests.rs @@ -0,0 +1,100 @@ +use super::{apply, DATA_PATH}; +use skillet_core::{ + files::{OwnerIdentity, Ownership}, + test_utils::{MockFiles, MockSystem}, +}; +use std::sync::atomic::Ordering; + +#[test] +fn unifi_uses_host_network_and_persistent_numeric_owned_data() { + let system = MockSystem::new(); + let files = MockFiles::new(); + setup_data_mount(&files); + + apply(&system, &files).unwrap(); + + let quadlets = files.files.lock().unwrap(); + let content = String::from_utf8_lossy( + quadlets + .get("/etc/containers/systemd/unifi.container") + .unwrap(), + ); + assert!(content.contains("Image=docker.io/jacobalberty/unifi:latest")); + assert!(content.contains("Network=host")); + assert!(content.contains("User=unifi")); + assert!(content.contains("Environment=TZ=Europe/Madrid")); + assert!(content.contains("AutoUpdate=registry")); + assert!(content.contains("Volume=/var/lib/data/unifi:/unifi:Z")); + assert!(content.contains("Requires=skillet-data-prepare.service")); + assert!(content.contains("AssertPathIsMountPoint=/var/lib/data")); + drop(quadlets); + + assert_eq!( + files.directory_metadata.lock().unwrap().get(DATA_PATH), + Some(&( + Some(0o750), + Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + } + )) + ); +} + +#[test] +fn unifi_rejects_a_missing_data_mount_before_writing_state() { + let system = MockSystem::new(); + let files = MockFiles::new(); + setup_data_mount(&files); + files.fail_btrfs_mount_check.store(true, Ordering::SeqCst); + let initial_directories = files.directories.lock().unwrap().clone(); + + assert!(apply(&system, &files).is_err()); + assert_eq!(*files.directories.lock().unwrap(), initial_directories); + assert!(files.files.lock().unwrap().is_empty()); +} + +fn setup_data_mount(files: &MockFiles) { + files.record_btrfs_mount(std::path::Path::new("/var"), "/dev/test", "/", "btrfs"); + files.record_btrfs_mount( + std::path::Path::new("/var/lib/data"), + "/dev/test", + "/data", + "btrfs", + ); +} + +#[test] +fn repeated_unifi_apply_does_not_restart_an_unchanged_container() { + let system = MockSystem::new(); + let files = MockFiles::new(); + setup_data_mount(&files); + + apply(&system, &files).unwrap(); + let restart_count = system.restart_count.load(Ordering::SeqCst); + files.files.lock().unwrap().insert( + format!("{DATA_PATH}/existing.db"), + b"preserve existing application data".to_vec(), + ); + apply(&system, &files).unwrap(); + + assert_eq!(system.restart_count.load(Ordering::SeqCst), restart_count); + assert_eq!( + files.directory_metadata.lock().unwrap().get(DATA_PATH), + Some(&( + Some(0o750), + Ownership { + uid: Some(OwnerIdentity::Id(999)), + gid: Some(OwnerIdentity::Id(999)), + } + )) + ); + assert_eq!( + files + .files + .lock() + .unwrap() + .get(&format!("{DATA_PATH}/existing.db")), + Some(&b"preserve existing application data".to_vec()) + ); +} diff --git a/ublue/skillet/crates/vm/Cargo.toml b/ublue/skillet/crates/vm/Cargo.toml new file mode 100644 index 00000000..ecf966dd --- /dev/null +++ b/ublue/skillet/crates/vm/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "skillet_vm" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yml.workspace = true +tempfile.workspace = true +uuid.workspace = true +users.workspace = true +quick-xml.workspace = true +nix = { workspace = true, features = ["signal", "process"] } +zeroize = "1.9" +sha2.workspace = true +hex.workspace = true diff --git a/ublue/skillet/crates/vm/src/artifacts.rs b/ublue/skillet/crates/vm/src/artifacts.rs new file mode 100644 index 00000000..f403ae06 --- /dev/null +++ b/ublue/skillet/crates/vm/src/artifacts.rs @@ -0,0 +1,128 @@ +//! Select binaries only from the successful Cargo invocation's JSON output. +use crate::{Error, Result}; +use serde::Deserialize; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + process::Command, +}; + +pub struct BuildRequest<'a> { + pub workspace: &'a Path, + pub packages: &'a [&'a str], + pub target: &'a str, + pub profile: &'a str, +} + +pub fn build(request: &BuildRequest<'_>) -> Result> { + let mut command = Command::new("cargo"); + command.current_dir(request.workspace).args([ + "build", + "--message-format=json-render-diagnostics", + "--target", + request.target, + "--profile", + request.profile, + ]); + for package in request.packages { + command.args(["--package", package]); + } + let output = crate::process::capture(command, std::time::Duration::from_mins(30))?; + if !output.status.success() { + // Compiler diagnostics contain source details, not credential payloads. + // Return the rendered diagnostic without dumping arbitrary tool output. + let rendered = diagnostics(&output.stdout); + let diagnostic = if rendered.is_empty() { + String::from_utf8_lossy(&output.stderr).into_owned() + } else { + rendered + }; + return Err(Error::Build { + code: output.status.code(), + diagnostic, + }); + } + let artifacts = reported_artifacts(&output.stdout, request.packages, request.workspace)?; + artifacts + .into_iter() + .map(|(name, path)| { + if !path.is_file() { + return Err(Error::Invalid("Cargo-reported executable is absent".into())); + } + Ok((name, std::fs::canonicalize(path)?)) + }) + .collect() +} + +#[derive(Deserialize)] +struct CargoMessage { + reason: String, + target: Option, + executable: Option, +} +#[derive(Deserialize)] +struct CargoTarget { + name: String, + kind: Vec, +} + +pub fn reported_artifacts( + output: &[u8], + packages: &[&str], + workspace: &Path, +) -> Result> { + let mut artifacts = BTreeMap::new(); + for line in output + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + { + let message: CargoMessage = serde_json::from_slice(line)?; + if message.reason != "compiler-artifact" { + continue; + } + if let (Some(target), Some(path)) = (message.target, message.executable) { + if target.kind.iter().any(|kind| kind == "bin") + && packages.contains(&target.name.as_str()) + { + let path = if path.is_absolute() { + path + } else { + workspace.join(path) + }; + if artifacts.insert(target.name, path).is_some() { + return Err(Error::Invalid( + "multiple Cargo executables match one requested binary".into(), + )); + } + } + } + } + if packages + .iter() + .any(|package| !artifacts.contains_key(*package)) + { + return Err(Error::Invalid( + "successful Cargo build did not report every requested executable".into(), + )); + } + Ok(artifacts) +} + +fn diagnostics(output: &[u8]) -> String { + output + .split(|byte| *byte == b'\n') + .filter_map(|line| serde_json::from_slice::(line).ok()) + .filter_map(|message| { + message + .get("message") + .and_then(|message| message.get("rendered")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +#[path = "artifacts/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/artifacts/tests.rs b/ublue/skillet/crates/vm/src/artifacts/tests.rs new file mode 100644 index 00000000..3731b369 --- /dev/null +++ b/ublue/skillet/crates/vm/src/artifacts/tests.rs @@ -0,0 +1,64 @@ +use super::*; + +fn artifact(name: &str, path: &str) -> String { + serde_json::json!({ "reason": "compiler-artifact", "target": { "name": name, "kind": ["bin"] }, "executable": path }).to_string() +} + +#[test] +fn selects_reported_custom_target_and_profile_without_scanning_stale_binaries() { + let output = format!( + "{}\n{}\n{{\"reason\":\"build-finished\",\"success\":true}}\n", + artifact("skillet", "/custom/target/other-profile/skillet"), + artifact( + "skillet-fixture", + "/custom/target/other-profile/skillet-fixture" + ) + ); + let result = reported_artifacts( + output.as_bytes(), + &["skillet", "skillet-fixture"], + Path::new("/workspace"), + ) + .unwrap(); + assert_eq!( + result["skillet"], + Path::new("/custom/target/other-profile/skillet") + ); + assert_eq!( + result["skillet-fixture"], + Path::new("/custom/target/other-profile/skillet-fixture") + ); +} + +#[test] +fn missing_ambiguous_and_malformed_artifacts_fail() { + assert!(reported_artifacts( + b"{\"reason\":\"build-finished\"}\n", + &["skillet"], + Path::new("/workspace") + ) + .is_err()); + let duplicate = format!( + "{}\n{}", + artifact("skillet", "/a/skillet"), + artifact("skillet", "/b/skillet") + ); + assert!( + reported_artifacts(duplicate.as_bytes(), &["skillet"], Path::new("/workspace")).is_err() + ); + assert!(reported_artifacts(b"not JSON", &["skillet"], Path::new("/workspace")).is_err()); +} + +#[test] +fn relative_artifacts_resolve_against_the_cargo_working_directory() { + let result = reported_artifacts( + artifact("skillet", "custom/debug/skillet").as_bytes(), + &["skillet"], + Path::new("/workspace"), + ) + .unwrap(); + assert_eq!( + result["skillet"], + Path::new("/workspace/custom/debug/skillet") + ); +} diff --git a/ublue/skillet/crates/vm/src/backend.rs b/ublue/skillet/crates/vm/src/backend.rs new file mode 100644 index 00000000..3042599f --- /dev/null +++ b/ublue/skillet/crates/vm/src/backend.rs @@ -0,0 +1,659 @@ +//! Focused libvirt adapter. A failed connection is never an absent domain. +use crate::{Backend, Connection, Error, Result, VmRun}; +use serde::Deserialize; +use std::{ + collections::BTreeMap, + ffi::OsString, + fs, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::Command, + time::Duration, +}; +use uuid::Uuid; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DomainSnapshot { + pub name: String, + pub uuid: Uuid, + pub state: String, + pub disks: Vec, + pub boot: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DomainBoot { + pub nvram: PathBuf, + pub tpm_state: PathBuf, + pub secure: bool, + pub sha256: bool, +} + +impl DomainSnapshot { + pub fn validate_owned(&self, run: &VmRun) -> Result<()> { + let mut actual = self.disks.clone(); + actual.sort(); + let mut expected = vec![run.disk.clone(), run.ignition.clone()]; + expected.sort(); + if self.uuid != run.uuid || self.name != run.identity.domain_name() || actual != expected { + return Err(Error::Invalid( + "domain UUID, name or disks do not match owned run".into(), + )); + } + if run.root_profile == crate::install::RootProfile::Tpm { + let expected = DomainBoot { + nvram: crate::install::nvram_path(run)?, + tpm_state: crate::install::tpm_path(run)?, + secure: true, + sha256: true, + }; + if self.boot.as_ref() != Some(&expected) { + return Err(Error::Invalid( + "domain firmware or TPM state does not match the encrypted owned run".into(), + )); + } + } else if self.boot.is_some() { + return Err(Error::Invalid( + "unencrypted BIOS run has unexpected firmware or TPM state".into(), + )); + } + Ok(()) + } +} + +pub trait VmBackend { + fn inspect(&self, run: &VmRun) -> Result>; + fn define(&self, run: &VmRun, xml: &Path) -> Result<()>; + fn start(&self, run: &VmRun) -> Result<()>; + fn reboot(&self, run: &VmRun) -> Result<()>; + fn stop(&self, run: &VmRun) -> Result<()>; + fn undefine(&self, run: &VmRun) -> Result<()>; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VirshInvocation { + pub program: PathBuf, + pub arguments: Vec, + pub environment: BTreeMap, +} + +#[derive(Debug)] +pub struct VirshOutput { + pub success: bool, + pub code: Option, + pub stdout: String, +} + +/// Execution boundary belongs only to the libvirt adapter, not host recipes. +pub trait VirshExecutor { + fn execute(&self, invocation: &VirshInvocation) -> Result; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VirtInstallInvocation { + pub program: PathBuf, + pub arguments: Vec, + pub environment: BTreeMap, +} + +/// Focused execution boundary for Flatpak's one-shot VM definition tool. +pub trait VirtInstallExecutor { + fn execute(&self, invocation: &VirtInstallInvocation) -> Result; +} + +pub struct ProcessExecutor { + pub timeout: Duration, +} + +impl Default for ProcessExecutor { + fn default() -> Self { + Self { + timeout: Duration::from_mins(1), + } + } +} + +impl VirshExecutor for ProcessExecutor { + fn execute(&self, invocation: &VirshInvocation) -> Result { + let mut command = Command::new(&invocation.program); + command + .args(&invocation.arguments) + .envs(&invocation.environment); + let output = crate::process::capture(command, self.timeout)?; + Ok(VirshOutput { + success: output.status.success(), + code: output.status.code(), + stdout: String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid("libvirt output is not UTF8".into()))?, + }) + } +} + +pub struct VirtInstallProcessExecutor { + pub timeout: Duration, +} + +impl Default for VirtInstallProcessExecutor { + fn default() -> Self { + Self { + timeout: Duration::from_mins(1), + } + } +} + +impl VirtInstallExecutor for VirtInstallProcessExecutor { + fn execute(&self, invocation: &VirtInstallInvocation) -> Result { + let mut command = Command::new(&invocation.program); + command + .args(&invocation.arguments) + .envs(&invocation.environment); + let output = crate::process::capture(command, self.timeout)?; + Ok(VirshOutput { + success: output.status.success(), + code: output.status.code(), + stdout: String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid("virt-install output is not UTF8".into()))?, + }) + } +} + +pub struct FlatpakVirtInstall { + program: PathBuf, + runtime_dir: PathBuf, + executor: E, +} + +impl FlatpakVirtInstall { + pub fn for_run(run: &VmRun, program: &Path) -> Result { + Self::new(run, program, VirtInstallProcessExecutor::default()) + } +} + +impl FlatpakVirtInstall { + pub fn new(run: &VmRun, program: &Path, executor: E) -> Result { + if run.connection.backend != Backend::Flatpak + || !program.is_absolute() + || !program.is_file() + || fs::metadata(program)?.permissions().mode() & 0o111 == 0 + { + return Err(Error::Invalid( + "Flatpak virt-install requires an executable absolute wrapper".into(), + )); + } + run.connection.validate(run.owner_uid)?; + Ok(Self { + program: program.into(), + runtime_dir: run.connection.runtime_dir.clone(), + executor, + }) + } + + pub fn version(&self) -> Result { + self.invoke(vec![OsString::from("--version")]) + } + + pub fn define(&self, run: &VmRun) -> Result<()> { + if run.connection.backend != Backend::Flatpak + || run.connection.runtime_dir != self.runtime_dir + { + return Err(Error::Invalid( + "virt-install adapter connection differs from the run manifest".into(), + )); + } + crate::manifest::reject_symlinks(&run.disk)?; + crate::manifest::reject_symlinks(&run.ignition)?; + if !run.disk.is_file() || !run.ignition.is_file() { + return Err(Error::Invalid( + "Flatpak VM disk and Ignition must be regular files".into(), + )); + } + let arguments = vec![ + "--connect".into(), + run.connection.uri.clone().into(), + "--name".into(), + run.identity.domain_name().into(), + "--uuid".into(), + run.uuid.to_string().into(), + "--osinfo".into(), + "linux2024".into(), + "--memory".into(), + "8192".into(), + "--vcpus".into(), + "2".into(), + "--import".into(), + "--disk".into(), + format!("path={},format=qcow2,bus=virtio", run.disk.display()).into(), + "--disk".into(), + format!( + "path={},format=raw,bus=virtio,readonly=on", + run.ignition.display() + ) + .into(), + "--network".into(), + "none".into(), + "--graphics".into(), + "none".into(), + "--console".into(), + "pty,target_type=serial".into(), + "--noautoconsole".into(), + format!( + "--qemu-commandline=-fw_cfg name=opt/com.coreos/config,file={} -netdev user,id=skilletnet,hostfwd=tcp:127.0.0.1:{}-:22 -device virtio-net-pci,netdev=skilletnet,bus=pcie.0,addr=0x1d", + run.ignition.display(), run.ssh.port + ) + .into(), + ]; + self.invoke(arguments).map(|_| ()) + } + + fn invoke(&self, arguments: Vec) -> Result { + let output = self.executor.execute(&VirtInstallInvocation { + program: self.program.clone(), + arguments, + environment: BTreeMap::from([( + "TEST_VM_LIBVIRT_RUNTIME_DIR".into(), + self.runtime_dir.as_os_str().into(), + )]), + })?; + if !output.success { + return Err(Error::Command { + operation: "virt-install".into(), + code: output.code, + }); + } + Ok(output.stdout) + } +} + +pub struct VirshBackend { + connection: Connection, + program: PathBuf, + executor: E, +} + +impl VirshBackend { + pub fn for_run(run: &VmRun, flatpak_wrapper: &Path) -> Result { + Self::new( + run.connection.clone(), + run.owner_uid, + flatpak_wrapper, + ProcessExecutor::default(), + ) + } +} + +impl VirshBackend { + pub fn new( + connection: Connection, + owner_uid: u32, + flatpak_wrapper: &Path, + executor: E, + ) -> Result { + connection.validate(owner_uid)?; + let program = match connection.backend { + Backend::Native => "/usr/bin/virsh".into(), + Backend::Flatpak => flatpak_wrapper.into(), + }; + Ok(Self { + connection, + program, + executor, + }) + } + + fn invoke(&self, arguments: &[&str]) -> Result { + let runtime_key = match self.connection.backend { + Backend::Native => "XDG_RUNTIME_DIR", + Backend::Flatpak => "TEST_VM_LIBVIRT_RUNTIME_DIR", + }; + let invocation = VirshInvocation { + program: self.program.clone(), + arguments: ["-c", self.connection.uri.as_str()] + .into_iter() + .chain(arguments.iter().copied()) + .map(OsString::from) + .collect(), + environment: BTreeMap::from([( + runtime_key.into(), + self.connection.runtime_dir.as_os_str().into(), + )]), + }; + let output = self.executor.execute(&invocation)?; + if !output.success { + return Err(Error::Command { + operation: arguments.first().copied().unwrap_or("inspect").into(), + code: output.code, + }); + } + Ok(output.stdout) + } + + fn action(&self, action: &str, run: &VmRun) -> Result<()> { + self.validate_connection(run)?; + self.invoke(&[action, &run.uuid.to_string()])?; + Ok(()) + } + + fn validate_connection(&self, run: &VmRun) -> Result<()> { + if run.connection != self.connection { + return Err(Error::Invalid( + "adapter connection differs from the run manifest".into(), + )); + } + Ok(()) + } + + /// Resolve the emulator advertised for the architecture used by native + /// test guests. Keep capability parsing beside the selected libvirt + /// adapter so alternate runtimes use their own connection and environment. + pub fn x86_64_emulator(&self, run: &VmRun) -> Result { + self.validate_connection(run)?; + let capabilities = self.invoke(&["capabilities"])?; + let capabilities: CapabilitiesXml = quick_xml::de::from_str(&capabilities)?; + let emulator = capabilities + .guests + .into_iter() + .filter(|guest| guest.os_type == "hvm") + .flat_map(|guest| guest.arches) + .find(|arch| arch.name == "x86_64") + .and_then(|arch| arch.emulator) + .ok_or_else(|| Error::Invalid("libvirt has no x86_64 HVM emulator".into()))?; + let metadata = fs::metadata(&emulator)?; + if !emulator.is_absolute() + || !metadata.is_file() + || metadata.permissions().mode() & 0o111 == 0 + { + return Err(Error::Invalid( + "libvirt x86_64 emulator is not an executable file".into(), + )); + } + Ok(emulator) + } + + pub fn version(&self, run: &VmRun) -> Result { + self.validate_connection(run)?; + self.invoke(&["--version"]) + } + + /// Resolve an unlogged native recovery PTY with ownership checked around inspection. + pub fn recovery_console(&self, run: &VmRun) -> Result { + self.validate_connection(run)?; + if run.connection.backend != Backend::Native { + return Err(Error::Invalid( + "recovery console acceptance currently requires native libvirt".into(), + )); + } + self.inspect(run)? + .ok_or_else(|| Error::Invalid("recovery VM is absent".into()))? + .validate_owned(run)?; + let xml = self.invoke(&["dumpxml", &run.uuid.to_string()])?; + self.inspect(run)? + .ok_or_else(|| Error::Invalid("recovery VM disappeared".into()))? + .validate_owned(run)?; + parse_recovery_console(&xml) + } + + pub fn verify_tpm_support(&self) -> Result<()> { + crate::firmware::verify_capabilities( + &self.invoke(&["domcapabilities", "--machine", "q35"])?, + &self.invoke(&["version", "--daemon"])?, + ) + } + + pub fn probe(&self) -> Result<()> { + self.invoke(&["uri"])?; + Ok(()) + } + + pub fn contains_name(&self, name: &str) -> Result { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(Error::Invalid("invalid libvirt domain name".into())); + } + let names = self.invoke(&["list", "--all", "--name"])?; + Ok(names.lines().any(|line| line.trim() == name)) + } +} + +impl VmBackend for VirshBackend { + fn inspect(&self, run: &VmRun) -> Result> { + self.validate_connection(run)?; + let uuids = self.invoke(&["list", "--all", "--uuid"])?; + let uuid = run.uuid.to_string(); + let name = run.identity.domain_name(); + let target = if uuids.lines().any(|line| line.trim() == uuid) { + uuid.as_str() + } else { + let names = self.invoke(&["list", "--all", "--name"])?; + if !names.lines().any(|line| line.trim() == name) { + return Ok(None); + } + name.as_str() + }; + let xml = self.invoke(&["dumpxml", target])?; + let state = self.invoke(&["domstate", target])?.trim().to_string(); + Ok(Some(parse_domain(&xml, state)?)) + } + + fn define(&self, run: &VmRun, xml: &Path) -> Result<()> { + self.validate_connection(run)?; + crate::manifest::reject_symlinks(xml)?; + let definition = parse_domain(&fs::read_to_string(xml)?, "defined".into())?; + definition.validate_owned(run)?; + let xml = xml + .to_str() + .ok_or_else(|| Error::Invalid("non-UTF8 domain XML path".into()))?; + self.invoke(&["define", xml])?; + Ok(()) + } + fn start(&self, run: &VmRun) -> Result<()> { + self.action("start", run) + } + fn reboot(&self, run: &VmRun) -> Result<()> { + self.action("reboot", run) + } + fn stop(&self, run: &VmRun) -> Result<()> { + self.action("destroy", run) + } + fn undefine(&self, run: &VmRun) -> Result<()> { + if run.root_profile == crate::install::RootProfile::Tpm { + self.validate_connection(run)?; + self.invoke(&["undefine", &run.uuid.to_string(), "--nvram", "--tpm"])?; + Ok(()) + } else { + self.action("undefine", run) + } + } +} + +#[derive(Deserialize)] +struct DomainXml { + name: String, + uuid: Uuid, + devices: DevicesXml, + os: Option, +} + +#[derive(Deserialize)] +struct CapabilitiesXml { + #[serde(rename = "guest", default)] + guests: Vec, +} + +#[derive(Deserialize)] +struct GuestXml { + os_type: String, + #[serde(rename = "arch", default)] + arches: Vec, +} + +#[derive(Deserialize)] +struct ArchitectureXml { + #[serde(rename = "@name")] + name: String, + emulator: Option, +} +#[derive(Deserialize)] +struct DevicesXml { + #[serde(rename = "disk", default)] + disks: Vec, + tpm: Option, + #[serde(rename = "serial", default)] + serials: Vec, +} +#[derive(Deserialize)] +struct SerialXml { + #[serde(rename = "@type")] + kind: String, + source: Option, + log: Option, +} +#[derive(Deserialize)] +struct SerialSourceXml { + #[serde(rename = "@path")] + path: PathBuf, +} +fn parse_recovery_console(xml: &str) -> Result { + let domain: DomainXml = quick_xml::de::from_str(xml)?; + let serials = domain.devices.serials; + let [serial] = serials.as_slice() else { + return Err(Error::Invalid( + "recovery requires a single serial console".into(), + )); + }; + if serial.kind != "pty" || serial.log.is_some() { + return Err(Error::Invalid( + "recovery requires an unlogged PTY console".into(), + )); + } + let path = serial + .source + .as_ref() + .ok_or_else(|| Error::Invalid("recovery PTY source is absent".into()))? + .path + .clone(); + let suffix = path.strip_prefix("/dev/pts").ok().and_then(|p| p.to_str()); + if !suffix.is_some_and(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())) { + return Err(Error::Invalid("invalid recovery PTY path".into())); + } + Ok(path) +} + +#[derive(Deserialize)] +struct DiskXml { + #[serde(rename = "@device")] + kind: String, + source: Option, +} +#[derive(Deserialize)] +struct DiskSourceXml { + #[serde(rename = "@file")] + file: Option, +} + +#[derive(Deserialize)] +struct OsXml { + nvram: Option, + firmware: Option, +} +#[derive(Deserialize)] +struct FirmwareXml { + #[serde(rename = "feature", default)] + features: Vec, +} +#[derive(Deserialize)] +struct FeatureXml { + #[serde(rename = "@name")] + name: String, + #[serde(rename = "@enabled")] + enabled: String, +} +#[derive(Deserialize)] +struct TpmXml { + #[serde(rename = "@model")] + model: String, + backend: TpmBackendXml, +} +#[derive(Deserialize)] +struct TpmBackendXml { + #[serde(rename = "@type")] + kind: String, + #[serde(rename = "@version")] + version: String, + source: Option, + active_pcr_banks: Option, +} +#[derive(Deserialize)] +struct TpmSourceXml { + #[serde(rename = "@type")] + kind: String, + #[serde(rename = "@path")] + path: PathBuf, +} +#[derive(Deserialize)] +struct PcrBanksXml { + sha256: Option<()>, +} + +pub(crate) fn parse_domain(xml: &str, state: String) -> Result { + let domain: DomainXml = quick_xml::de::from_str(xml)?; + let boot = match (domain.os, domain.devices.tpm) { + (Some(os), Some(tpm)) => { + let source = tpm + .backend + .source + .ok_or_else(|| Error::Invalid("TPM state source missing".into()))?; + if tpm.model != "tpm-crb" + || tpm.backend.kind != "emulator" + || tpm.backend.version != "2.0" + || source.kind != "dir" + { + return Err(Error::Invalid("unexpected TPM device definition".into())); + } + Some(DomainBoot { + nvram: os + .nvram + .ok_or_else(|| Error::Invalid("NVRAM source missing".into()))? + .trim() + .into(), + tpm_state: source.path, + secure: os.firmware.is_some_and(|f| { + ["secure-boot", "enrolled-keys"].iter().all(|name| { + f.features + .iter() + .any(|feature| feature.name == *name && feature.enabled == "yes") + }) + }), + sha256: tpm + .backend + .active_pcr_banks + .is_some_and(|banks| banks.sha256.is_some()), + }) + } + (_, None) => None, + _ => return Err(Error::Invalid("TPM definition lacks firmware".into())), + }; + let mut disks = Vec::new(); + for disk in domain.devices.disks { + if disk.kind == "disk" { + let source = disk + .source + .and_then(|source| source.file) + .ok_or_else(|| Error::Invalid("VM has a non-file disk source".into()))?; + disks.push(source); + } + } + Ok(DomainSnapshot { + name: domain.name, + uuid: domain.uuid, + state, + disks, + boot, + }) +} + +#[cfg(test)] +#[path = "backend/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/backend/tests.rs b/ublue/skillet/crates/vm/src/backend/tests.rs new file mode 100644 index 00000000..2756013a --- /dev/null +++ b/ublue/skillet/crates/vm/src/backend/tests.rs @@ -0,0 +1,350 @@ +use super::*; +use crate::manifest::tests::legacy_run; +use std::{cell::RefCell, collections::VecDeque, time::Instant}; + +#[derive(Default)] +struct FakeExecutor { + responses: RefCell>, + calls: RefCell>, +} + +#[derive(Default)] +struct FakeVirtInstallExecutor { + responses: RefCell>, + calls: RefCell>, +} + +impl VirtInstallExecutor for FakeVirtInstallExecutor { + fn execute(&self, invocation: &VirtInstallInvocation) -> Result { + self.calls.borrow_mut().push(invocation.clone()); + Ok(self.responses.borrow_mut().pop_front().unwrap()) + } +} + +impl VirshExecutor for FakeExecutor { + fn execute(&self, invocation: &VirshInvocation) -> Result { + self.calls.borrow_mut().push(invocation.clone()); + Ok(self + .responses + .borrow_mut() + .pop_front() + .expect("unexpected virsh call")) + } +} + +fn output(stdout: &str) -> VirshOutput { + VirshOutput { + success: true, + code: Some(0), + stdout: stdout.into(), + } +} +fn executor(responses: Vec) -> FakeExecutor { + FakeExecutor { + responses: RefCell::new(responses.into()), + calls: RefCell::default(), + } +} +fn xml(run: &VmRun) -> String { + format!("{}{}", run.identity.domain_name(), run.uuid, run.disk.display(), run.ignition.display()) +} + +#[test] +fn inspect_owned_domain_uses_uuid_and_validates_disk_sources() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![ + output(&run.uuid.to_string()), + output(&xml(&run)), + output("running\n"), + ]), + ) + .unwrap(); + let snapshot = backend.inspect(&run).unwrap().unwrap(); + snapshot.validate_owned(&run).unwrap(); + assert_eq!(snapshot.state, "running"); + let calls = backend.executor.calls.borrow(); + assert_eq!(calls[0].program, Path::new("/usr/bin/virsh")); + assert_eq!( + calls[1].arguments.last().unwrap(), + &OsString::from(run.uuid.to_string()) + ); + assert_eq!( + calls[0].environment[&OsString::from("XDG_RUNTIME_DIR")], + run.connection.runtime_dir.as_os_str() + ); +} + +#[test] +fn missing_domain_requires_successful_lists_not_an_error_message() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![output(""), output("another-domain\n")]), + ) + .unwrap(); + assert!(backend.inspect(&run).unwrap().is_none()); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![VirshOutput { + success: false, + code: Some(1), + stdout: String::new(), + }]), + ) + .unwrap(); + assert!(matches!(backend.inspect(&run), Err(Error::Command { .. }))); +} + +#[test] +fn domain_name_preflight_requires_a_successful_complete_name_list() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![ + output("another-domain\nfixture-test-retained-2\n"), + output("another-domain\nfixture-test-retained-2\n"), + ]), + ) + .unwrap(); + assert!(backend.contains_name("fixture-test-retained-2").unwrap()); + assert!(!backend.contains_name("fixture-test-other").unwrap()); + assert!(backend.contains_name("bad;name").is_err()); +} + +#[test] +fn capabilities_select_executable_x86_64_hvm_emulator() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let emulator = dir.path().join("qemu-system-x86_64"); + std::fs::write(&emulator, b"test emulator").unwrap(); + std::fs::set_permissions(&emulator, std::fs::Permissions::from_mode(0o700)).unwrap(); + let capabilities = format!( + "hvm/not/selected{}", + emulator.display() + ); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![output(&capabilities)]), + ) + .unwrap(); + assert_eq!(backend.x86_64_emulator(&run).unwrap(), emulator); +} + +#[test] +fn flatpak_virt_install_preserves_uuid_paths_network_and_recorded_runtime() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.load(&identity).unwrap(); + run.connection.backend = Backend::Flatpak; + run.connection.runtime_dir = format!("/run/user/{}/skvm", run.owner_uid).into(); + fs::write(&run.disk, "disk").unwrap(); + fs::create_dir_all(run.ignition.parent().unwrap()).unwrap(); + fs::write(&run.ignition, "ignition").unwrap(); + let directory = tempfile::tempdir().unwrap(); + let wrapper = directory.path().join("virt-install"); + fs::write(&wrapper, "fixture wrapper").unwrap(); + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o700)).unwrap(); + let executor = FakeVirtInstallExecutor { + responses: RefCell::new(vec![output("virt-install 4.0\n"), output("")].into()), + calls: RefCell::default(), + }; + let creator = FlatpakVirtInstall::new(&run, &wrapper, executor).unwrap(); + assert_eq!(creator.version().unwrap(), "virt-install 4.0\n"); + creator.define(&run).unwrap(); + let calls = creator.executor.calls.borrow(); + let invocation = &calls[1]; + let arguments: Vec<_> = invocation + .arguments + .iter() + .map(|argument| argument.to_str().unwrap()) + .collect(); + let uuid = run.uuid.to_string(); + assert_eq!(invocation.program, wrapper); + assert_eq!( + invocation.environment[&OsString::from("TEST_VM_LIBVIRT_RUNTIME_DIR")], + run.connection.runtime_dir.as_os_str() + ); + assert!(arguments + .windows(2) + .any(|pair| pair == ["--uuid", uuid.as_str()])); + assert!(arguments + .windows(2) + .any(|pair| pair == ["--network", "none"])); + assert!(arguments.iter().any(|argument| { + argument.starts_with("--qemu-commandline=-fw_cfg ") + && argument.contains(&format!("hostfwd=tcp:127.0.0.1:{}-:22", run.ssh.port)) + })); + assert!(arguments.iter().any(|argument| { + argument + == &format!( + "path={},format=raw,bus=virtio,readonly=on", + run.ignition.display() + ) + })); +} + +#[test] +fn reused_name_and_renamed_uuid_are_not_owned() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let mut stranger = run.clone(); + stranger.uuid = Uuid::new_v4(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![ + output(""), + output(&run.identity.domain_name()), + output(&xml(&stranger)), + output("running"), + ]), + ) + .unwrap(); + assert!(backend + .inspect(&run) + .unwrap() + .unwrap() + .validate_owned(&run) + .is_err()); + let renamed = xml(&run).replace(&run.identity.domain_name(), "renamed-domain"); + assert!(parse_domain(&renamed, "running".into()) + .unwrap() + .validate_owned(&run) + .is_err()); + let wrong_disk = xml(&run).replace(&run.disk.display().to_string(), "/unrelated/disk"); + assert!(parse_domain(&wrong_disk, "running".into()) + .unwrap() + .validate_owned(&run) + .is_err()); +} + +#[test] +fn flatpak_adapter_uses_wrapper_and_recorded_runtime_and_uuid_actions() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.load(&identity).unwrap(); + run.connection.backend = Backend::Flatpak; + run.connection.runtime_dir = format!("/run/user/{}/skvm", run.owner_uid).into(); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/source/bin/virsh"), + executor(vec![output(""), output(""), output(""), output("")]), + ) + .unwrap(); + backend.start(&run).unwrap(); + backend.reboot(&run).unwrap(); + backend.stop(&run).unwrap(); + backend.undefine(&run).unwrap(); + for call in backend.executor.calls.borrow().iter() { + assert_eq!(call.program, Path::new("/source/bin/virsh")); + assert_eq!( + call.environment[&OsString::from("TEST_VM_LIBVIRT_RUNTIME_DIR")], + run.connection.runtime_dir.as_os_str() + ); + assert_eq!( + call.arguments.last().unwrap(), + &OsString::from(run.uuid.to_string()) + ); + } +} + +#[test] +fn malformed_or_non_file_disks_fail_closed() { + assert!(parse_domain("not XML", "running".into()).is_err()); + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let block = xml(&run).replace("source file=", "source dev="); + assert!(parse_domain(&block, "running".into()).is_err()); +} + +#[test] +fn subprocess_arguments_are_literal_and_timeout_is_bounded() { + let executor = ProcessExecutor { + timeout: Duration::from_secs(2), + }; + let invocation = VirshInvocation { + program: "/usr/bin/printf".into(), + arguments: vec!["%s".into(), "literal $(not-a-command); words".into()], + environment: BTreeMap::new(), + }; + assert_eq!( + executor.execute(&invocation).unwrap().stdout, + "literal $(not-a-command); words" + ); + let executor = ProcessExecutor { + timeout: Duration::from_millis(30), + }; + let invocation = VirshInvocation { + program: "/usr/bin/sleep".into(), + arguments: vec!["5".into()], + environment: BTreeMap::new(), + }; + let start = Instant::now(); + assert!(matches!(executor.execute(&invocation), Err(Error::Timeout))); + assert!(start.elapsed() < Duration::from_secs(2)); +} + +#[test] +fn recovery_console_rejects_logging_ambiguous_sources_and_non_ptys() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let base = xml(&run); + let serial = ""; + let with = |serial: &str| base.replace("", &format!("{serial}")); + assert_eq!( + parse_recovery_console(&with(serial)).unwrap(), + PathBuf::from("/dev/pts/7") + ); + assert!(parse_recovery_console(&base).is_err()); + assert!(parse_recovery_console(&with(&serial.repeat(2))).is_err()); + assert!(parse_recovery_console(&with( + &serial.replace("", "") + )) + .is_err()); + assert!(parse_recovery_console(&with(&serial.replace("/dev/pts/7", "/tmp/console"))).is_err()); + assert!(parse_recovery_console(&with(&serial.replace("type='pty'", "type='file'"))).is_err()); +} + +#[test] +fn recovery_console_stops_when_ownership_changes_after_inspection() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let good = xml(&run).replace( + "", + "", + ); + let bad = good.replace(&run.disk.display().to_string(), "/unowned/disk"); + let backend = VirshBackend::new( + run.connection.clone(), + run.owner_uid, + Path::new("/wrapper"), + executor(vec![ + output(&run.uuid.to_string()), + output(&good), + output("running"), + output(&good), + output(&run.uuid.to_string()), + output(&bad), + output("running"), + ]), + ) + .unwrap(); + assert!(backend.recovery_console(&run).is_err()); + assert_eq!(backend.executor.calls.borrow().len(), 7); +} diff --git a/ublue/skillet/crates/vm/src/catalog.rs b/ublue/skillet/crates/vm/src/catalog.rs new file mode 100644 index 00000000..c9052bd2 --- /dev/null +++ b/ublue/skillet/crates/vm/src/catalog.rs @@ -0,0 +1,64 @@ +//! Source templates and recorded runs, without importing or mutating them. +use crate::{manifest::reject_symlinks, ManifestStore, Result, RunIdentity}; +use std::{collections::BTreeMap, fs, path::Path}; + +pub fn templates(butane: &Path, workspace: &Path) -> Result> { + let mut hosts = BTreeMap::new(); + for entry in fs::read_dir(butane)? { + let path = entry?.path(); + if path.extension().is_some_and(|extension| extension == "bu") { + if let Some(host) = path.file_stem().and_then(|name| name.to_str()) { + if RunIdentity::new(host, "catalog").is_ok() { + hosts.insert(host.to_string(), false); + } + } + } + } + let crates = workspace.join("crates/hosts"); + if crates.exists() { + for entry in fs::read_dir(&crates)? { + let entry = entry?; + if entry.path().join("Cargo.toml").is_file() { + if let Some(host) = entry.file_name().to_str() { + if RunIdentity::new(host, "catalog").is_ok() { + hosts.insert(host.to_string(), false); + } + } + } + } + } + for (host, available) in &mut hosts { + *available = butane.join(format!("{host}.bu")).is_file() + && crates.join(host).join("Cargo.toml").is_file(); + } + Ok(hosts) +} + +pub fn recorded_runs(store: &ManifestStore, host: &str) -> Result> { + RunIdentity::new(host, "catalog")?; + let root = store.root(); + reject_symlinks(root)?; + if !root.exists() { + return Ok(Vec::new()); + } + let mut runs = Vec::new(); + let prefix = format!("{host}-test-"); + for entry in fs::read_dir(root)? { + let entry = entry?; + if let Some(instance) = entry + .file_name() + .to_str() + .and_then(|name| name.strip_prefix(&prefix)) + { + if let Ok(identity) = RunIdentity::new(host, instance) { + runs.push(identity); + } + } + } + runs.sort_by(|left, right| left.instance().cmp(right.instance())); + Ok(runs) +} + +#[cfg(test)] +#[path = "catalog/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/catalog/tests.rs b/ublue/skillet/crates/vm/src/catalog/tests.rs new file mode 100644 index 00000000..8643a104 --- /dev/null +++ b/ublue/skillet/crates/vm/src/catalog/tests.rs @@ -0,0 +1,53 @@ +use super::*; + +#[test] +fn template_availability_requires_both_sources_and_is_sorted() { + let dir = tempfile::tempdir().unwrap(); + let butane = dir.path().join("butane"); + let workspace = dir.path().join("workspace"); + fs::create_dir(&butane).unwrap(); + fs::create_dir_all(workspace.join("crates/hosts/complete")).unwrap(); + fs::create_dir_all(workspace.join("crates/hosts/crate-only")).unwrap(); + fs::write(butane.join("complete.bu"), "fixture").unwrap(); + fs::write(butane.join("template-only.bu"), "fixture").unwrap(); + fs::write(butane.join("Invalid.bu"), "fixture").unwrap(); + fs::write( + workspace.join("crates/hosts/complete/Cargo.toml"), + "fixture", + ) + .unwrap(); + fs::write( + workspace.join("crates/hosts/crate-only/Cargo.toml"), + "fixture", + ) + .unwrap(); + assert_eq!( + templates(&butane, &workspace).unwrap(), + BTreeMap::from([ + ("complete".into(), true), + ("crate-only".into(), false), + ("template-only".into(), false) + ]) + ); +} + +#[test] +fn runs_are_discovered_without_reading_or_rewriting_partial_manifests() { + let dir = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(dir.path(), users::get_current_uid()).unwrap(); + for name in [ + "fixture-test-2", + "fixture-test-a", + "other-test-a", + "fixture-test-Invalid", + ] { + fs::create_dir(dir.path().join(name)).unwrap(); + } + let runs = recorded_runs(&store, "fixture").unwrap(); + assert_eq!( + runs.iter().map(RunIdentity::instance).collect::>(), + ["2", "a"] + ); + assert!(!store.run_dir(&runs[0]).join("vm.json").exists()); + assert!(recorded_runs(&store, "../escape").is_err()); +} diff --git a/ublue/skillet/crates/vm/src/creation.rs b/ublue/skillet/crates/vm/src/creation.rs new file mode 100644 index 00000000..ba742be8 --- /dev/null +++ b/ublue/skillet/crates/vm/src/creation.rs @@ -0,0 +1,185 @@ +//! Native VM definition and start orchestration with ownership recovery. +use crate::{ + backend::{DomainSnapshot, VmBackend}, + domain_xml, Backend, Error, ManifestStore, Phase, Result, RunIdentity, VmRun, +}; +use std::{ + fs::{self, File}, + io::Write as _, + os::unix::fs::PermissionsExt as _, + path::Path, +}; + +/// Define and start a VM through common libvirt XML, persisting ownership after each successful +/// external transition so a retry can inspect and continue safely. +pub fn create_libvirt( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + emulator: &Path, + tool_versions: &str, +) -> Result { + create_with( + store, + identity, + backend, + store.load(identity)?.connection.backend, + tool_versions, + |run| { + let xml = domain_xml::write_domain_xml(store, run, emulator)?; + backend.define(run, &xml) + }, + ) +} + +/// Native-only compatibility entry point using the shared XML owner. +pub fn create_native( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + emulator: &Path, + tool_versions: &str, +) -> Result { + if store.load(identity)?.connection.backend != Backend::Native { + return Err(Error::Invalid( + "native creation requires a native run".into(), + )); + } + create_libvirt(store, identity, backend, emulator, tool_versions) +} + +/// Create a Flatpak-backed guest through the caller's focused virt-install +/// adapter, then use the same ownership and recovery sequence as native VM +/// creation. `virt-install` may start the guest as part of definition. +pub fn create_flatpak( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + tool_versions: &str, + define: impl FnOnce(&VmRun) -> Result<()>, +) -> Result { + create_with( + store, + identity, + backend, + Backend::Flatpak, + tool_versions, + define, + ) +} + +fn create_with( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + expected_backend: Backend, + tool_versions: &str, + define: impl FnOnce(&VmRun) -> Result<()>, +) -> Result { + let _lock = store.lock(identity)?; + let mut run = store.load(identity)?; + store.validate(&run, identity)?; + if run.connection.backend != expected_backend + || !matches!( + run.phase, + Phase::Preparing | Phase::Defined | Phase::Started | Phase::Ready + ) + { + return Err(Error::Invalid( + "VM creation requires an active run for the selected backend".into(), + )); + } + save_tool_versions(store, &run, tool_versions)?; + + let mut domain = backend.inspect(&run)?; + if let Some(existing) = &domain { + existing.validate_owned(&run)?; + if run.phase == Phase::Preparing { + run = store.mark_defined(identity)?; + } + } else { + if run.phase != Phase::Preparing { + return Err(Error::Invalid( + "recorded native VM domain is absent; refusing to recreate it".into(), + )); + } + define(&run)?; + domain = backend.inspect(&run)?; + let defined = owned_domain(domain.as_ref(), &run)?; + if run.phase == Phase::Preparing { + run = store.mark_defined(identity)?; + } + domain = Some(defined.clone()); + } + + let domain = owned_domain(domain.as_ref(), &run)?; + if domain.state == "shut off" { + backend.start(&run)?; + } else if domain.state != "running" { + return Err(Error::Invalid(format!( + "cannot start VM in libvirt state: {}", + domain.state + ))); + } + + let running = backend + .inspect(&run)? + .ok_or_else(|| Error::Invalid("VM disappeared after start".into()))?; + running.validate_owned(&run)?; + if running.state != "running" { + return Err(Error::Invalid(format!( + "VM start returned in libvirt state: {}", + running.state + ))); + } + save_domain_uuid(store, &run)?; + if matches!(run.phase, Phase::Preparing | Phase::Defined) { + run = store.mark_started(identity)?; + } + Ok(run) +} + +fn owned_domain<'a>(domain: Option<&'a DomainSnapshot>, run: &VmRun) -> Result<&'a DomainSnapshot> { + let domain = + domain.ok_or_else(|| Error::Invalid("VM definition returned without a domain".into()))?; + domain.validate_owned(run)?; + Ok(domain) +} + +fn save_tool_versions(store: &ManifestStore, run: &VmRun, content: &str) -> Result<()> { + if content.trim().is_empty() { + return Err(Error::Invalid("tool version record cannot be empty".into())); + } + save_private_file(store, run, "tool-versions.txt", content.as_bytes()) +} + +fn save_domain_uuid(store: &ManifestStore, run: &VmRun) -> Result<()> { + save_private_file( + store, + run, + "domain.uuid", + format!("{}\n", run.uuid).as_bytes(), + ) +} + +fn save_private_file(store: &ManifestStore, run: &VmRun, name: &str, content: &[u8]) -> Result<()> { + store.validate(run, &run.identity)?; + let dir = store.run_dir(&run.identity); + let path = dir.join(name); + crate::manifest::reject_symlinks(&path)?; + let mut output = tempfile::NamedTempFile::new_in(&dir)?; + output + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + output.write_all(content)?; + output.as_file().sync_all()?; + output + .persist(&path) + .map_err(|error| Error::Io(error.error))?; + File::open(dir)?.sync_all()?; + Ok(()) +} + +#[cfg(test)] +#[path = "creation_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/creation_tests.rs b/ublue/skillet/crates/vm/src/creation_tests.rs new file mode 100644 index 00000000..f6a2611b --- /dev/null +++ b/ublue/skillet/crates/vm/src/creation_tests.rs @@ -0,0 +1,195 @@ +use super::*; +use crate::{backend::DomainSnapshot, Connection}; +use std::{ + cell::{Cell, RefCell}, + fs, + path::{Path, PathBuf}, +}; +use uuid::Uuid; + +struct FakeBackend { + domain: RefCell>, + calls: RefCell>, + fail_start: Cell, +} + +impl VmBackend for FakeBackend { + fn inspect(&self, _: &VmRun) -> Result> { + Ok(self.domain.borrow().clone()) + } + fn define(&self, run: &VmRun, _: &Path) -> Result<()> { + self.calls.borrow_mut().push("define"); + *self.domain.borrow_mut() = Some(snapshot(run, "shut off")); + Ok(()) + } + fn start(&self, _: &VmRun) -> Result<()> { + self.calls.borrow_mut().push("start"); + if self.fail_start.get() { + return Err(Error::Command { + operation: "start".into(), + code: Some(1), + }); + } + self.domain.borrow_mut().as_mut().unwrap().state = "running".into(); + Ok(()) + } + fn reboot(&self, _: &VmRun) -> Result<()> { + unreachable!() + } + fn stop(&self, _: &VmRun) -> Result<()> { + unreachable!() + } + fn undefine(&self, _: &VmRun) -> Result<()> { + unreachable!() + } +} + +fn snapshot(run: &VmRun, state: &str) -> DomainSnapshot { + DomainSnapshot { + boot: None, + name: run.identity.domain_name(), + uuid: run.uuid, + state: state.into(), + disks: vec![run.disk.clone(), run.ignition.clone()], + } +} + +fn fixture() -> (tempfile::TempDir, ManifestStore, RunIdentity, PathBuf) { + fixture_for(Backend::Native) +} + +fn fixture_for(backend: Backend) -> (tempfile::TempDir, ManifestStore, RunIdentity, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(temp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("clamps", "create-test").unwrap(); + let run = store + .prepare_intent( + &identity, + Connection { + backend, + uri: "qemu:///session".into(), + runtime_dir: match backend { + Backend::Native => PathBuf::from(format!("/run/user/{}", crate::current_uid())), + Backend::Flatpak => { + PathBuf::from(format!("/run/user/{}/skvm", crate::current_uid())) + } + }, + }, + 2205, + "source-revision", + ) + .unwrap(); + fs::write(&run.disk, b"disk").unwrap(); + fs::create_dir_all(run.ignition.parent().unwrap()).unwrap(); + fs::write(&run.ignition, b"ignition").unwrap(); + let dir = store.run_dir(&identity); + fs::write(dir.join("skillet.sha256"), format!("{}\n", "a".repeat(64))).unwrap(); + fs::write( + dir.join("skillet-generic.sha256"), + format!("{}\n", "b".repeat(64)), + ) + .unwrap(); + let emulator = temp.path().join("qemu-system-x86_64"); + fs::write(&emulator, b"qemu").unwrap(); + fs::set_permissions(&emulator, fs::Permissions::from_mode(0o700)).unwrap(); + (temp, store, identity, emulator) +} + +#[test] +fn native_creation_defines_records_and_starts_owned_domain() { + let (_temp, store, identity, emulator) = fixture(); + let backend = FakeBackend { + domain: RefCell::new(None), + calls: RefCell::default(), + fail_start: Cell::new(false), + }; + let run = create_native( + &store, + &identity, + &backend, + &emulator, + "virsh\npodman\nyq\n", + ) + .unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["define", "start"]); + assert_eq!( + fs::read_to_string(store.run_dir(&identity).join("domain.uuid")).unwrap(), + format!("{}\n", run.uuid) + ); + assert_eq!( + fs::read_to_string(store.run_dir(&identity).join("tool-versions.txt")).unwrap(), + "virsh\npodman\nyq\n" + ); +} + +#[test] +fn native_creation_recovers_defined_domain_and_is_idempotent_when_running() { + let (_temp, store, identity, emulator) = fixture(); + let preparing = store.load(&identity).unwrap(); + let backend = FakeBackend { + domain: RefCell::new(Some(snapshot(&preparing, "shut off"))), + calls: RefCell::default(), + fail_start: Cell::new(false), + }; + let run = create_native(&store, &identity, &backend, &emulator, "versions\n").unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["start"]); + let run = create_native(&store, &identity, &backend, &emulator, "versions\n").unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["start"]); +} + +#[test] +fn native_creation_rejects_unowned_existing_domain_without_mutating_it() { + let (_temp, store, identity, emulator) = fixture(); + let run = store.load(&identity).unwrap(); + let mut foreign = snapshot(&run, "shut off"); + foreign.uuid = Uuid::new_v4(); + let backend = FakeBackend { + domain: RefCell::new(Some(foreign)), + calls: RefCell::default(), + fail_start: Cell::new(false), + }; + assert!(create_native(&store, &identity, &backend, &emulator, "versions\n").is_err()); + assert!(backend.calls.borrow().is_empty()); + assert_eq!(store.load(&identity).unwrap().phase, Phase::Preparing); +} + +#[test] +fn failed_start_retains_defined_state_for_retry() { + let (_temp, store, identity, emulator) = fixture(); + let backend = FakeBackend { + domain: RefCell::new(None), + calls: RefCell::default(), + fail_start: Cell::new(true), + }; + assert!(create_native(&store, &identity, &backend, &emulator, "versions\n").is_err()); + assert_eq!(store.load(&identity).unwrap().phase, Phase::Defined); + assert!(!store.run_dir(&identity).join("domain.uuid").exists()); + + backend.fail_start.set(false); + let run = create_native(&store, &identity, &backend, &emulator, "versions\n").unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["define", "start", "start"]); +} + +#[test] +fn flatpak_creation_uses_one_shot_definition_then_shared_recovery() { + let (_temp, store, identity, _) = fixture_for(Backend::Flatpak); + let backend = FakeBackend { + domain: RefCell::new(None), + calls: RefCell::default(), + fail_start: Cell::new(false), + }; + let run = create_flatpak( + &store, + &identity, + &backend, + "virt-install\nvirsh\npodman\nyq\n", + |run| backend.define(run, Path::new("/flatpak-creator-test")), + ) + .unwrap(); + assert_eq!(run.phase, Phase::Started); + assert_eq!(*backend.calls.borrow(), ["define", "start"]); +} diff --git a/ublue/skillet/crates/vm/src/credential.rs b/ublue/skillet/crates/vm/src/credential.rs new file mode 100644 index 00000000..d7e1252b --- /dev/null +++ b/ublue/skillet/crates/vm/src/credential.rs @@ -0,0 +1,109 @@ +//! Shared, stdin-only installation of encrypted guest credentials. + +use crate::{ + transport::{GuestCommand, GuestTransport}, + Error, Result, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ActivationPolicy { + StartConsumer, + DeferConsumer, +} + +pub fn install( + transport: &impl GuestTransport, + host: &str, + credential: &str, + consumer_unit: &str, + activation: ActivationPolicy, + payload: &[u8], +) -> Result<()> { + validate_input(host, credential, consumer_unit, payload)?; + + let binary = format!("/var/usrlocal/bin/skillet-{host}"); + let mut arguments = vec!["-n", binary.as_str(), "credential", "install"]; + arguments.push(credential); + arguments.push(consumer_unit); + if activation == ActivationPolicy::DeferConsumer { + arguments.push("--no-start"); + } + let output = transport.execute( + &GuestCommand { + program: "/usr/bin/sudo", + arguments: &arguments, + }, + Some(payload), + )?; + if !output.status.success() { + return Err(Error::Guest { + operation: "credential installation".into(), + code: output.status.code(), + }); + } + Ok(()) +} + +/// Install a related credential set using one activation policy. Every input +/// is validated before any guest mutation; deferred callers can start the +/// consumer only after this operation succeeds. +pub fn install_set( + transport: &impl GuestTransport, + host: &str, + consumer_unit: &str, + activation: ActivationPolicy, + credentials: &[(&str, &[u8])], +) -> Result<()> { + if credentials.is_empty() { + return Err(Error::Invalid( + "guest credential delivery set is empty".into(), + )); + } + for (index, (name, payload)) in credentials.iter().enumerate() { + validate_input(host, name, consumer_unit, payload)?; + if credentials[..index] + .iter() + .any(|(previous, _)| previous == name) + { + return Err(Error::Invalid( + "guest credential delivery set contains duplicate names".into(), + )); + } + } + for (name, payload) in credentials { + install(transport, host, name, consumer_unit, activation, payload)?; + } + Ok(()) +} + +fn validate_input(host: &str, credential: &str, consumer_unit: &str, payload: &[u8]) -> Result<()> { + if !valid_component(host) + || !valid_component(credential) + || !valid_unit(consumer_unit) + || payload.is_empty() + { + return Err(Error::Invalid( + "invalid guest credential delivery input".into(), + )); + } + Ok(()) +} + +fn valid_component(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn valid_unit(value: &str) -> bool { + value.ends_with(".service") + && !value.starts_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'@')) +} + +#[cfg(test)] +#[path = "credential/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/credential/tests.rs b/ublue/skillet/crates/vm/src/credential/tests.rs new file mode 100644 index 00000000..6384d9ba --- /dev/null +++ b/ublue/skillet/crates/vm/src/credential/tests.rs @@ -0,0 +1,148 @@ +use super::{install, install_set, ActivationPolicy}; +use crate::{ + transport::{GuestCommand, GuestTransport}, + Error, Result, +}; +use std::{ + path::Path, + process::{Command, Output}, +}; + +type CapturedCall = (String, Vec, Option>); + +#[derive(Default)] +struct FakeTransport { + calls: std::sync::Mutex>, + fail: bool, +} + +impl GuestTransport for FakeTransport { + fn execute(&self, command: &GuestCommand<'_>, input: Option<&[u8]>) -> Result { + self.calls.lock().unwrap().push(( + command.program.to_string(), + command + .arguments + .iter() + .map(|arg| (*arg).to_string()) + .collect(), + input.map(<[u8]>::to_vec), + )); + Ok(Output { + status: Command::new(if self.fail { "false" } else { "true" }) + .status() + .map_err(Error::Io)?, + stdout: Vec::new(), + stderr: Vec::new(), + }) + } + + fn upload(&self, _source: &Path, _destination: &str) -> Result<()> { + Ok(()) + } +} + +#[test] +fn sends_payload_only_as_stdin_and_defers_activation_when_requested() { + let transport = FakeTransport::default(); + install( + &transport, + "clamps", + "cloudflare_acme_token", + "skillet-caddy-apply.service", + ActivationPolicy::DeferConsumer, + b"secret bytes", + ) + .unwrap(); + let calls = transport.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "/usr/bin/sudo"); + assert_eq!(calls[0].1.last().map(String::as_str), Some("--no-start")); + assert_eq!(calls[0].2.as_deref(), Some(b"secret bytes".as_slice())); + assert!(!calls[0].1.iter().any(|argument| argument == "secret bytes")); +} + +#[test] +fn rejects_invalid_names_and_empty_payload_before_transport() { + let transport = FakeTransport::default(); + for (host, credential, unit, payload) in [ + ("clamps;id", "token", "unit.service", b"secret".as_slice()), + ("clamps", "token;id", "unit.service", b"secret".as_slice()), + ("clamps", "token", "unit", b"secret".as_slice()), + ("clamps", "token", "unit.service", b"".as_slice()), + ] { + assert!(install( + &transport, + host, + credential, + unit, + ActivationPolicy::StartConsumer, + payload, + ) + .is_err()); + } + assert!(transport.calls.lock().unwrap().is_empty()); +} + +#[test] +fn credential_set_validates_all_entries_then_defers_each_install() { + let transport = FakeTransport::default(); + install_set( + &transport, + "clamps", + "skillet-caddy-apply.service", + ActivationPolicy::DeferConsumer, + &[ + ("caddy_sites", b"sites payload"), + ("cloudflare_acme_token", b"token payload"), + ], + ) + .unwrap(); + let calls = transport.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls + .iter() + .all(|call| call.1.last().map(String::as_str) == Some("--no-start"))); + assert_eq!(calls[0].2.as_deref(), Some(b"sites payload".as_slice())); + assert_eq!(calls[1].2.as_deref(), Some(b"token payload".as_slice())); +} + +#[test] +fn credential_set_rejects_invalid_or_duplicate_entries_before_any_mutation() { + let transport = FakeTransport::default(); + for credentials in [ + vec![("caddy_sites", b"sites".as_slice()), ("bad;name", b"token")], + vec![ + ("caddy_sites", b"sites".as_slice()), + ("caddy_sites", b"again"), + ], + ] { + assert!(install_set( + &transport, + "clamps", + "skillet-caddy-apply.service", + ActivationPolicy::DeferConsumer, + &credentials, + ) + .is_err()); + } + assert!(transport.calls.lock().unwrap().is_empty()); +} + +#[test] +fn reports_guest_failure_without_exposing_payload() { + let transport = FakeTransport { + fail: true, + ..FakeTransport::default() + }; + let error = install( + &transport, + "clamps", + "pihole_web_password", + "skillet-full-apply.service", + ActivationPolicy::StartConsumer, + b"private value", + ) + .unwrap_err(); + assert!(error.to_string().contains("credential installation")); + assert!(!error.to_string().contains("private value")); +} diff --git a/ublue/skillet/crates/vm/src/delivery.rs b/ublue/skillet/crates/vm/src/delivery.rs new file mode 100644 index 00000000..966e6800 --- /dev/null +++ b/ublue/skillet/crates/vm/src/delivery.rs @@ -0,0 +1,147 @@ +//! Captured/current binaries share one delivery and verification operation. +use crate::{ + manifest::{reject_symlinks, ArtifactHashes}, + transport::{GuestCommand, GuestTransport}, + Error, Phase, Result, VmRun, +}; +use sha2::{Digest as _, Sha256}; +use std::{fs::File, io::Read as _, path::Path}; + +pub fn deliver( + run: &VmRun, + transport: &impl GuestTransport, + host_artifact: &Path, + generic_artifact: &Path, +) -> Result { + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(Error::Invalid( + "VM is not available for binary delivery; finish creation or disposal first".into(), + )); + } + let hashes = ArtifactHashes { + host: sha256(host_artifact)?, + generic: sha256(generic_artifact)?, + }; + let host_staging = format!("/var/tmp/skillet-{}-update", run.identity.host()); + let generic_staging = "/var/tmp/skillet-generic-update"; + let host_destination = format!("/var/usrlocal/bin/skillet-{}", run.identity.host()); + let generic_destination = "/var/usrlocal/bin/skillet"; + for (artifact, source, destination, expected) in [ + ( + host_artifact, + &host_staging[..], + &host_destination[..], + &hashes.host[..], + ), + ( + generic_artifact, + generic_staging, + generic_destination, + &hashes.generic[..], + ), + ] { + if !matches_installed(transport, destination, expected)? { + transport.upload(artifact, source)?; + checked( + transport, + "sudo", + &["-n", "install", "-m", "0755", source, destination], + )?; + } + } + for (path, expected) in [ + (&host_destination[..], &hashes.host[..]), + (generic_destination, &hashes.generic[..]), + ] { + if !matches_installed(transport, path, expected)? { + return Err(Error::Invalid( + "guest artifact bytes or metadata differ from the selected binary".into(), + )); + } + } + checked(transport, "rm", &["-f", &host_staging, generic_staging])?; + Ok(hashes) +} + +pub(crate) fn matches_installed( + transport: &impl GuestTransport, + path: &str, + expected: &str, +) -> Result { + if test_path(transport, "-L", path)? { + return Err(Error::Invalid("installed binary is a symlink".into())); + } + if !test_path(transport, "-e", path)? { + return Ok(false); + } + if !test_path(transport, "-f", path)? { + return Err(Error::Invalid( + "installed binary is not a regular file".into(), + )); + } + let hash = checked(transport, "sha256sum", &[path])?; + let metadata = checked(transport, "stat", &["--format=%a:%u:%g", path])?; + Ok(std::str::from_utf8(&hash) + .ok() + .and_then(|value| value.split_whitespace().next()) + == Some(expected) + && metadata == b"755:0:0\n") +} + +fn test_path(transport: &impl GuestTransport, condition: &str, path: &str) -> Result { + let output = transport.execute( + &GuestCommand { + program: "test", + arguments: &[condition, path], + }, + None, + )?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + code => Err(Error::Guest { + operation: "inspect installed binary".into(), + code, + }), + } +} + +pub fn sha256(path: &Path) -> Result { + reject_symlinks(path)?; + if !std::fs::metadata(path)?.is_file() { + return Err(Error::Invalid("artifact is not a regular file".into())); + } + let mut file = File::open(path)?; + if !file.metadata()?.is_file() { + return Err(Error::Invalid("artifact is not a regular file".into())); + } + let mut digest = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(hex::encode(digest.finalize())) +} + +pub(crate) fn checked( + transport: &impl GuestTransport, + program: &str, + arguments: &[&str], +) -> Result> { + let output = transport.execute(&GuestCommand { program, arguments }, None)?; + if !output.status.success() { + return Err(Error::Guest { + operation: program.into(), + code: output.status.code(), + }); + } + Ok(output.stdout) +} + +#[cfg(test)] +#[path = "delivery/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/delivery/tests.rs b/ublue/skillet/crates/vm/src/delivery/tests.rs new file mode 100644 index 00000000..0ce2e579 --- /dev/null +++ b/ublue/skillet/crates/vm/src/delivery/tests.rs @@ -0,0 +1,151 @@ +use super::*; +use crate::{manifest::tests::legacy_run, transport::GuestCommand}; +use std::{ + cell::{Cell, RefCell}, + fs, + os::unix::process::ExitStatusExt as _, + process::Output, +}; + +struct FakeTransport { + hashes: ArtifactHashes, + fail_install: Cell, + wrong_hash: Cell, + present: Cell, + calls: RefCell)>>, +} +impl GuestTransport for FakeTransport { + fn execute(&self, command: &GuestCommand<'_>, _: Option<&[u8]>) -> Result { + self.calls.borrow_mut().push(( + command.program.into(), + command + .arguments + .iter() + .map(|argument| (*argument).into()) + .collect(), + )); + let failed = (command.program == "sudo" && self.fail_install.get()) + || (command.program == "test" && (command.arguments[0] == "-L" || !self.present.get())); + if command.program == "sudo" + && !failed + && command.arguments.last().unwrap().ends_with("/skillet") + { + self.present.set(true); + } + let stdout = if command.program == "sha256sum" { + let hash = if self.wrong_hash.get() { + "bad" + } else if command.arguments[0].ends_with("skillet-fixture") { + &self.hashes.host + } else { + &self.hashes.generic + }; + format!("{hash} {}\n", command.arguments[0]).into_bytes() + } else if command.program == "stat" { + b"755:0:0\n".to_vec() + } else { + Vec::new() + }; + Ok(Output { + status: std::process::ExitStatus::from_raw(i32::from(failed) << 8), + stdout, + stderr: Vec::new(), + }) + } + fn upload(&self, _: &Path, destination: &str) -> Result<()> { + self.calls + .borrow_mut() + .push(("upload".into(), vec![destination.into()])); + Ok(()) + } +} + +#[test] +fn delivery_uses_selected_profile_and_verifies_both_binaries_without_changing_capture() { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let host = dir.join("host-artifact"); + let generic = dir.join("generic-artifact"); + fs::write(&host, "new host executable").unwrap(); + fs::write(&generic, "new generic executable").unwrap(); + let run = store.load(&identity).unwrap(); + let original = run.captured.clone(); + let hashes = ArtifactHashes { + host: sha256(&host).unwrap(), + generic: sha256(&generic).unwrap(), + }; + let transport = FakeTransport { + hashes: hashes.clone(), + fail_install: Cell::new(false), + wrong_hash: Cell::new(false), + present: Cell::new(false), + calls: RefCell::default(), + }; + assert_eq!(deliver(&run, &transport, &host, &generic).unwrap(), hashes); + assert_eq!(run.captured, original); + assert!(transport + .calls + .borrow() + .iter() + .any(|(program, args)| program == "sudo" + && args.last().unwrap() == "/var/usrlocal/bin/skillet-fixture")); + assert_eq!( + transport + .calls + .borrow() + .iter() + .filter(|(program, _)| program == "sha256sum") + .count(), + 2 + ); + transport.calls.borrow_mut().clear(); + assert_eq!(deliver(&run, &transport, &host, &generic).unwrap(), hashes); + assert!(!transport + .calls + .borrow() + .iter() + .any(|(program, _)| program == "upload" || program == "sudo")); +} + +#[test] +fn failed_install_or_wrong_hash_does_not_claim_deployment_success() { + let (_tmp, store, identity) = legacy_run(); + let host = store.run_dir(&identity).join("host-artifact"); + fs::write(&host, "fixture").unwrap(); + let run = store.import(&identity).unwrap(); + let hashes = ArtifactHashes { + host: sha256(&host).unwrap(), + generic: sha256(&host).unwrap(), + }; + let transport = FakeTransport { + hashes, + fail_install: Cell::new(true), + wrong_hash: Cell::new(false), + present: Cell::new(false), + calls: RefCell::default(), + }; + assert!(deliver(&run, &transport, &host, &host).is_err()); + assert!(store.load(&identity).unwrap().deployed.is_none()); + transport.fail_install.set(false); + transport.wrong_hash.set(true); + assert!(deliver(&run, &transport, &host, &host).is_err()); + assert!(store.load(&identity).unwrap().deployed.is_none()); + transport.wrong_hash.set(false); + assert!(deliver(&run, &transport, &host, &host).is_ok()); +} + +#[test] +fn disposal_in_progress_refuses_delivery_before_remote_mutation() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.load(&identity).unwrap(); + run.phase = Phase::ExternalCleanupComplete; + let transport = FakeTransport { + hashes: run.captured.clone().unwrap(), + fail_install: Cell::new(false), + wrong_hash: Cell::new(false), + present: Cell::new(false), + calls: RefCell::default(), + }; + assert!(deliver(&run, &transport, Path::new("/absent"), Path::new("/absent")).is_err()); + assert!(transport.calls.borrow().is_empty()); +} diff --git a/ublue/skillet/crates/vm/src/domain_xml.rs b/ublue/skillet/crates/vm/src/domain_xml.rs new file mode 100644 index 00000000..9944e664 --- /dev/null +++ b/ublue/skillet/crates/vm/src/domain_xml.rs @@ -0,0 +1,314 @@ +//! Libvirt domain XML rendering for a recorded test run. +use crate::{Backend, Error, ManifestStore, Phase, Result, VmRun}; +use quick_xml::{ + events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}, + Writer, +}; +use std::{ + fs::{self, File}, + io::Write as _, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, +}; + +/// Write the KVM definition beside its owned disk and Ignition files. +/// The output is atomically replaced and never follows a symlink. +pub fn write_domain_xml(store: &ManifestStore, run: &VmRun, emulator: &Path) -> Result { + write_with_console_log(store, run, emulator, true) +} + +/// Recovery acceptance must disable serial logging before injecting a passphrase. +pub fn write_recovery_domain_xml( + store: &ManifestStore, + run: &VmRun, + emulator: &Path, +) -> Result { + write_with_console_log(store, run, emulator, false) +} + +fn write_with_console_log( + store: &ManifestStore, + run: &VmRun, + emulator: &Path, + console_log: bool, +) -> Result { + store.validate(run, &run.identity)?; + if !matches!(run.phase, Phase::Preparing | Phase::Defined) + || !emulator.is_absolute() + || (run.connection.backend == Backend::Native && !emulator.is_file()) + { + return Err(Error::Invalid( + "domain XML requires a preparing run and absolute emulator".into(), + )); + } + let dir = run + .disk + .parent() + .ok_or_else(|| Error::Invalid("VM disk has no parent directory".into()))?; + for path in [&run.disk, &run.ignition] { + crate::manifest::reject_symlinks(path)?; + if !fs::metadata(path)?.is_file() { + return Err(Error::Invalid( + "VM disk and Ignition must be regular files".into(), + )); + } + } + crate::manifest::reject_symlinks(dir)?; + let path = dir.join("domain.xml"); + crate::manifest::reject_symlinks(&path)?; + if console_log && run.root_profile == crate::install::RootProfile::Tpm { + let serial = dir.join("serial.log"); + crate::manifest::reject_symlinks(&serial)?; + let log = fs::OpenOptions::new() + .create(true) + .append(true) + .open(serial)?; + log.set_permissions(fs::Permissions::from_mode(0o600))?; + } + let xml = render_with_console_log(run, emulator, console_log)?; + let mut output = tempfile::NamedTempFile::new_in(dir)?; + output + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + output.write_all(xml.as_bytes())?; + output.write_all(b"\n")?; + output.as_file().sync_all()?; + output + .persist(&path) + .map_err(|error| Error::Io(error.error))?; + File::open(dir)?.sync_all()?; + Ok(path) +} + +#[cfg(test)] +fn render(run: &VmRun, emulator: &Path) -> Result { + render_with_console_log(run, emulator, true) +} + +fn render_with_console_log(run: &VmRun, emulator: &Path, console_log: bool) -> Result { + let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2); + writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; + start(&mut writer, "domain", &[("type", "kvm".into())])?; + text_element(&mut writer, "name", &run.identity.domain_name())?; + text_element(&mut writer, "uuid", &run.uuid.to_string())?; + text_element_with_attrs(&mut writer, "memory", "8192", &[("unit", "MiB".into())])?; + text_element(&mut writer, "vcpu", "2")?; + + let encrypted = run.root_profile == crate::install::RootProfile::Tpm; + let firmware_attrs = if encrypted { + vec![("firmware", "efi".into())] + } else { + Vec::new() + }; + start(&mut writer, "os", &firmware_attrs)?; + if encrypted { + start(&mut writer, "firmware", &[])?; + for name in ["secure-boot", "enrolled-keys"] { + empty( + &mut writer, + "feature", + &[("enabled", "yes".into()), ("name", name.into())], + )?; + } + end(&mut writer, "firmware")?; + } + text_element_with_attrs( + &mut writer, + "type", + "hvm", + &[("arch", "x86_64".into()), ("machine", "q35".into())], + )?; + if encrypted { + text_element_with_attrs( + &mut writer, + "nvram", + &crate::install::nvram_path(run)?.display().to_string(), + &[("format", "raw".into())], + )?; + } + empty(&mut writer, "boot", &[("dev", "hd".into())])?; + end(&mut writer, "os")?; + + start(&mut writer, "features", &[])?; + empty(&mut writer, "acpi", &[])?; + empty(&mut writer, "apic", &[])?; + if encrypted { + empty(&mut writer, "smm", &[("state", "on".into())])?; + } + end(&mut writer, "features")?; + empty(&mut writer, "cpu", &[("mode", "host-passthrough".into())])?; + + start(&mut writer, "sysinfo", &[("type", "fwcfg".into())])?; + empty( + &mut writer, + "entry", + &[ + ("name", "opt/com.coreos/config".into()), + ("file", run.ignition.display().to_string()), + ], + )?; + end(&mut writer, "sysinfo")?; + + start(&mut writer, "devices", &[])?; + if run.connection.backend == Backend::Native { + text_element(&mut writer, "emulator", &emulator.display().to_string())?; + } + if encrypted { + render_tpm(&mut writer, run)?; + } + disk(&mut writer, &run.disk, "qcow2", "vda", false)?; + disk(&mut writer, &run.ignition, "raw", "vdb", true)?; + start(&mut writer, "interface", &[("type", "user".into())])?; + empty(&mut writer, "backend", &[("type", "passt".into())])?; + start( + &mut writer, + "portForward", + &[("proto", "tcp".into()), ("address", "127.0.0.1".into())], + )?; + empty( + &mut writer, + "range", + &[("start", run.ssh.port.to_string()), ("to", "22".into())], + )?; + end(&mut writer, "portForward")?; + empty(&mut writer, "model", &[("type", "virtio".into())])?; + end(&mut writer, "interface")?; + render_console(&mut writer, run, encrypted && console_log)?; + end(&mut writer, "devices")?; + end(&mut writer, "domain")?; + String::from_utf8(writer.into_inner()) + .map_err(|_| Error::Invalid("generated libvirt XML was not UTF-8".into())) +} + +fn render_console(writer: &mut Writer>, run: &VmRun, encrypted: bool) -> Result<()> { + start(writer, "serial", &[("type", "pty".into())])?; + if encrypted { + empty( + writer, + "log", + &[ + ( + "file", + run.disk + .parent() + .ok_or_else(|| Error::Invalid("disk has no parent".into()))? + .join("serial.log") + .display() + .to_string(), + ), + ("append", "on".into()), + ], + )?; + } + empty(writer, "target", &[("port", "0".into())])?; + end(writer, "serial")?; + start(writer, "console", &[("type", "pty".into())])?; + empty( + writer, + "target", + &[("type", "serial".into()), ("port", "0".into())], + )?; + end(writer, "console")?; + Ok(()) +} + +fn render_tpm(writer: &mut Writer>, run: &VmRun) -> Result<()> { + start(writer, "tpm", &[("model", "tpm-crb".into())])?; + start( + writer, + "backend", + &[("type", "emulator".into()), ("version", "2.0".into())], + )?; + empty( + writer, + "source", + &[ + ("type", "dir".into()), + ("path", crate::install::tpm_path(run)?.display().to_string()), + ], + )?; + start(writer, "active_pcr_banks", &[])?; + empty(writer, "sha256", &[])?; + end(writer, "active_pcr_banks")?; + end(writer, "backend")?; + end(writer, "tpm")?; + Ok(()) +} + +fn disk( + writer: &mut Writer>, + path: &Path, + format: &str, + target: &str, + readonly: bool, +) -> Result<()> { + start( + writer, + "disk", + &[("type", "file".into()), ("device", "disk".into())], + )?; + empty( + writer, + "driver", + &[("name", "qemu".into()), ("type", format.into())], + )?; + empty(writer, "source", &[("file", path.display().to_string())])?; + empty( + writer, + "target", + &[("dev", target.into()), ("bus", "virtio".into())], + )?; + if readonly { + empty(writer, "readonly", &[])?; + } + end(writer, "disk") +} + +fn start(writer: &mut Writer>, name: &str, attrs: &[(&str, String)]) -> Result<()> { + let mut element = BytesStart::new(name); + for (key, value) in attrs { + element.push_attribute((*key, value.as_str())); + } + writer.write_event(Event::Start(element))?; + Ok(()) +} + +fn empty(writer: &mut Writer>, name: &str, attrs: &[(&str, String)]) -> Result<()> { + let mut element = BytesStart::new(name); + for (key, value) in attrs { + element.push_attribute((*key, value.as_str())); + } + writer.write_event(Event::Empty(element))?; + Ok(()) +} + +fn text(writer: &mut Writer>, value: &str) -> Result<()> { + writer.write_event(Event::Text(BytesText::new(value)))?; + Ok(()) +} + +fn text_element(writer: &mut Writer>, name: &str, value: &str) -> Result<()> { + start(writer, name, &[])?; + text(writer, value)?; + end(writer, name) +} + +fn text_element_with_attrs( + writer: &mut Writer>, + name: &str, + value: &str, + attrs: &[(&str, String)], +) -> Result<()> { + start(writer, name, attrs)?; + text(writer, value)?; + end(writer, name) +} + +fn end(writer: &mut Writer>, name: &str) -> Result<()> { + writer.write_event(Event::End(BytesEnd::new(name)))?; + Ok(()) +} + +#[cfg(test)] +#[path = "domain_xml_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/domain_xml_tests.rs b/ublue/skillet/crates/vm/src/domain_xml_tests.rs new file mode 100644 index 00000000..054ccaa3 --- /dev/null +++ b/ublue/skillet/crates/vm/src/domain_xml_tests.rs @@ -0,0 +1,117 @@ +use super::*; +use crate::{backend::parse_domain, Backend, Connection, RunIdentity}; +use std::path::PathBuf; + +#[test] +fn native_xml_keeps_the_existing_boot_network_and_owned_disk_contract() { + let tmp = tempfile::Builder::new() + .prefix("vm&xml-") + .tempdir() + .unwrap(); + let store = ManifestStore::new(tmp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("fixture", "xml").unwrap(); + let run = store + .prepare_intent( + &identity, + Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir: PathBuf::from(format!("/run/user/{}", crate::current_uid())), + }, + 2207, + "fixture-revision", + ) + .unwrap(); + fs::write(&run.disk, "disk").unwrap(); + fs::create_dir_all(run.ignition.parent().unwrap()).unwrap(); + fs::write(&run.ignition, "ignition").unwrap(); + + let path = write_domain_xml(&store, &run, Path::new("/usr/bin/true")).unwrap(); + let xml = fs::read_to_string(path).unwrap(); + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("")); + assert!(xml.contains("vm&xml"), "{xml}"); + parse_domain(&xml, "defined".into()) + .unwrap() + .validate_owned(&run) + .unwrap(); +} + +#[test] +fn encrypted_xml_owns_firmware_and_tpm_and_refuses_other_state() { + let tmp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(tmp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("fixture", "encrypted").unwrap(); + let run = store + .prepare_intent_with_profile( + &identity, + Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir: format!("/run/user/{}", crate::current_uid()).into(), + }, + 2208, + "fixture-revision", + crate::install::RootProfile::Tpm, + ) + .unwrap(); + fs::write(&run.disk, "disk").unwrap(); + fs::create_dir_all(run.ignition.parent().unwrap()).unwrap(); + fs::write(&run.ignition, "ignition").unwrap(); + let path = write_domain_xml(&store, &run, Path::new("/usr/bin/true")).unwrap(); + let xml = fs::read_to_string(path).unwrap(); + let snapshot = parse_domain(&xml, "defined".into()).unwrap(); + snapshot.validate_owned(&run).unwrap(); + assert!(xml.contains("name=\"enrolled-keys\"")); + assert!(xml.contains("")); + assert!(xml.contains("serial.log")); + assert_eq!( + fs::metadata(store.run_dir(&identity).join("serial.log")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let recovery_path = + write_recovery_domain_xml(&store, &run, Path::new("/usr/bin/true")).unwrap(); + let recovery_xml = fs::read_to_string(recovery_path).unwrap(); + assert!(!recovery_xml.contains("")); + parse_domain(&xml, "defined".into()) + .unwrap() + .validate_owned(&flatpak) + .unwrap(); + let modified = xml.replace("/tpm\"", "/other-tpm\""); + assert!(parse_domain(&modified, "defined".into()) + .unwrap() + .validate_owned(&run) + .is_err()); + let mut old_json = serde_json::to_value(&run).unwrap(); + old_json.as_object_mut().unwrap().remove("root_profile"); + let legacy: crate::VmRun = serde_json::from_value(old_json).unwrap(); + assert_eq!( + legacy.root_profile, + crate::install::RootProfile::Unencrypted + ); +} + +#[test] +fn recovery_artifacts_cannot_follow_symlinks() { + let (_tmp, store, identity) = crate::manifest::tests::legacy_run(); + let mut run = store.import(&identity).unwrap(); + run.root_profile = crate::install::RootProfile::Tpm; + let recovery = store.run_dir(&identity).join("recovery"); + std::os::unix::fs::symlink("/tmp", recovery).unwrap(); + assert!(store.validate(&run, &identity).is_err()); +} diff --git a/ublue/skillet/crates/vm/src/firmware.rs b/ublue/skillet/crates/vm/src/firmware.rs new file mode 100644 index 00000000..a076652c --- /dev/null +++ b/ublue/skillet/crates/vm/src/firmware.rs @@ -0,0 +1,77 @@ +//! Capability parsing for explicitly requested UEFI/TPM root installations. +use crate::{Error, Result}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Caps { + os: Os, + devices: Devices, +} +#[derive(Deserialize)] +struct Os { + #[serde(rename = "enum", default)] + enums: Vec, + loader: Loader, +} +#[derive(Deserialize)] +struct Loader { + #[serde(rename = "enum", default)] + enums: Vec, +} +#[derive(Deserialize)] +struct Devices { + tpm: Tpm, +} +#[derive(Deserialize)] +struct Tpm { + #[serde(rename = "@supported")] + supported: String, + #[serde(rename = "enum", default)] + enums: Vec, +} +#[derive(Deserialize)] +struct Enumeration { + #[serde(rename = "@name")] + name: String, + #[serde(rename = "value", default)] + values: Vec, +} +fn has(values: &[Enumeration], name: &str, value: &str) -> bool { + values + .iter() + .any(|e| e.name == name && e.values.iter().any(|v| v == value)) +} +pub fn verify_capabilities(xml: &str, version: &str) -> Result<()> { + let caps: Caps = quick_xml::de::from_str(xml)?; + if !has(&caps.os.enums, "firmware", "efi") + || !has(&caps.os.loader.enums, "secure", "yes") + || caps.devices.tpm.supported != "yes" + || !has(&caps.devices.tpm.enums, "backendModel", "emulator") + || !has(&caps.devices.tpm.enums, "backendVersion", "2.0") + { + return Err(Error::Invalid( + "TPM profile requires q35 UEFI Secure Boot and emulated TPM2 support".into(), + )); + } + let daemon = version + .lines() + .find_map(|line| line.strip_prefix("Running against daemon: ")) + .ok_or_else(|| { + Error::Invalid("cannot establish libvirt daemon version for owned TPM state".into()) + })?; + let numbers = daemon + .trim() + .split('.') + .map(str::parse::) + .collect::, _>>() + .map_err(|_| Error::Invalid("invalid libvirt daemon version".into()))?; + if numbers.len() < 2 || (numbers[0], numbers[1]) < (10, 10) { + return Err(Error::Invalid( + "owned TPM state requires libvirt daemon 10.10 or newer".into(), + )); + } + Ok(()) +} +#[cfg(test)] +#[path = "firmware/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/firmware/tests.rs b/ublue/skillet/crates/vm/src/firmware/tests.rs new file mode 100644 index 00000000..57e77f46 --- /dev/null +++ b/ublue/skillet/crates/vm/src/firmware/tests.rs @@ -0,0 +1,9 @@ +use super::*; +#[test] +fn missing_tpm_never_falls_back() { + let xml="efiyes"; + assert!(verify_capabilities(xml, "Running against daemon: 11.10.0").is_err()); + let xml=xml.replace("supported='no'", "supported='yes'").replace("", "emulator2.0"); + assert!(verify_capabilities(&xml, "Running against daemon: 11.10.0").is_ok()); + assert!(verify_capabilities(&xml, "Running against daemon: 10.9.0").is_err()); +} diff --git a/ublue/skillet/crates/vm/src/install.rs b/ublue/skillet/crates/vm/src/install.rs new file mode 100644 index 00000000..d355cd0e --- /dev/null +++ b/ublue/skillet/crates/vm/src/install.rs @@ -0,0 +1,170 @@ +//! Explicit root installation policy, independent of host and VM identity. +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; +use serde_yml::Value; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RootProfile { + #[default] + Unencrypted, + Tpm, +} + +/// Transform a fresh install definition, never a mounted or populated disk. +pub fn configure_root(config: &mut Value, profile: RootProfile) -> Result<()> { + if profile == RootProfile::Unencrypted { + return Ok(()); + } + let storage = config + .get_mut("storage") + .and_then(Value::as_mapping_mut) + .ok_or_else(|| Error::Invalid("TPM installation requires storage configuration".into()))?; + if storage.contains_key(Value::String("luks".into())) { + return Err(Error::Invalid( + "TPM profile conflicts with existing LUKS configuration".into(), + )); + } + let filesystems = storage + .get_mut(Value::String("filesystems".into())) + .and_then(Value::as_sequence_mut) + .ok_or_else(|| { + Error::Invalid("TPM installation requires an explicit root filesystem".into()) + })?; + let raw_root_count = filesystems + .iter() + .filter(|entry| { + entry.get("device").and_then(Value::as_str) == Some("/dev/disk/by-partlabel/root") + }) + .count(); + if raw_root_count != 1 + || filesystems + .iter() + .any(|entry| entry.get("device").and_then(Value::as_str) == Some("/dev/mapper/root")) + { + return Err(Error::Invalid( + "conflicting root filesystem formatting declarations".into(), + )); + } + let mut roots = filesystems + .iter_mut() + .filter(|entry| entry.get("label").and_then(Value::as_str) == Some("root")); + let root = roots + .next() + .ok_or_else(|| Error::Invalid("root filesystem is absent".into()))?; + if root.get("device").and_then(Value::as_str) != Some("/dev/disk/by-partlabel/root") + || root.get("format").and_then(Value::as_str) != Some("btrfs") + { + return Err(Error::Invalid( + "TPM profile requires the declared Btrfs root partition".into(), + )); + } + let map = root + .as_mapping_mut() + .ok_or_else(|| Error::Invalid("invalid root filesystem".into()))?; + map.insert( + Value::String("device".into()), + Value::String("/dev/mapper/root".into()), + ); + if roots.next().is_some() { + return Err(Error::Invalid("multiple root filesystems declared".into())); + } + let luks = serde_yml::to_value(serde_json::json!([{ + "name": "root", "label": "luks-root", + "device": "/dev/disk/by-partlabel/root", "wipe_volume": true, + "clevis": {"custom": { + "needs_network": false, "pin": "tpm2", + "config": serde_json::json!({"pcr_bank": "sha256", "pcr_ids": "7"}).to_string() + }} + }])) + .map_err(|e| Error::Invalid(format!("invalid built-in TPM profile: {e}")))?; + storage.insert(Value::String("luks".into()), luks); + Ok(()) +} + +/// Butane requires file modes to be numeric. `serde_yml` parses YAML values such +/// as `0644` as strings, so convert octal mode strings before writing staged +/// configs back out. +pub fn normalize_butane_modes(value: &mut Value) -> Result<()> { + match value { + Value::Mapping(mapping) => { + for (key, value) in mapping.iter_mut() { + if key.as_str() == Some("mode") { + if let Some(mode) = value.as_str() { + let mode = u64::from_str_radix(mode, 8).map_err(|_| { + Error::Invalid(format!("invalid octal Butane file mode: {mode}")) + })?; + *value = Value::Number(mode.into()); + } + } else { + normalize_butane_modes(value)?; + } + } + } + Value::Sequence(values) => { + for value in values { + normalize_butane_modes(value)?; + } + } + _ => {} + } + Ok(()) +} + +pub fn nvram_path(run: &crate::VmRun) -> Result { + Ok(run + .disk + .parent() + .ok_or_else(|| Error::Invalid("disk has no parent".into()))? + .join("nvram.fd")) +} +pub fn tpm_path(run: &crate::VmRun) -> Result { + Ok(run + .disk + .parent() + .ok_or_else(|| Error::Invalid("disk has no parent".into()))? + .join("tpm")) +} + +#[cfg(test)] +#[path = "install/tests.rs"] +mod tests; + +/// Select the whole disk for a fresh physical installation. VM templates remain unchanged. +pub fn configure_disk(config: &mut Value, disk: &str) -> Result<()> { + validate_physical_disk(disk)?; + let disks = config + .get_mut("storage") + .and_then(|v| v.get_mut("disks")) + .and_then(Value::as_sequence_mut) + .ok_or_else(|| { + Error::Invalid("installation requires an explicit disk declaration".into()) + })?; + if disks.len() != 1 || disks[0].get("device").and_then(Value::as_str) != Some("/dev/vda") { + return Err(Error::Invalid( + "expected one VM template disk; refusing ambiguous disk selection".into(), + )); + } + disks[0]["device"] = Value::String(disk.into()); + Ok(()) +} + +/// Require a stable whole-disk identifier, not an ambient workstation block device. +pub fn validate_physical_disk(disk: &str) -> Result<()> { + let Some(name) = disk.strip_prefix("/dev/disk/by-id/") else { + return Err(Error::Invalid( + "physical disk must use /dev/disk/by-id/".into(), + )); + }; + if name.is_empty() + || name.contains("-part") + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b)) + || name == "." + || name == ".." + { + return Err(Error::Invalid("invalid whole-disk identifier".into())); + } + Ok(()) +} diff --git a/ublue/skillet/crates/vm/src/install/tests.rs b/ublue/skillet/crates/vm/src/install/tests.rs new file mode 100644 index 00000000..b7bb8ffa --- /dev/null +++ b/ublue/skillet/crates/vm/src/install/tests.rs @@ -0,0 +1,45 @@ +use super::*; +fn source() -> Value { + serde_yml::from_str("storage:\n filesystems:\n - device: /dev/disk/by-partlabel/root\n format: btrfs\n label: root\n wipe_filesystem: true\n").unwrap() +} +#[test] +fn unencrypted_is_preserved_and_tpm_replaces_raw_root() { + let mut value = source(); + let original = value.clone(); + configure_root(&mut value, RootProfile::Unencrypted).unwrap(); + assert_eq!(value, original); + configure_root(&mut value, RootProfile::Tpm).unwrap(); + assert_eq!( + value["storage"]["filesystems"][0]["device"].as_str(), + Some("/dev/mapper/root") + ); + assert_eq!( + value["storage"]["luks"][0]["clevis"]["custom"]["config"].as_str(), + Some("{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"7\"}") + ); + assert!(configure_root(&mut value, RootProfile::Tpm).is_err()); +} +#[test] +fn missing_root_is_an_error_not_unencrypted_fallback() { + let mut value = serde_yml::from_str("storage: {}").unwrap(); + assert!(configure_root(&mut value, RootProfile::Tpm).is_err()); +} + +#[test] +fn conflicting_filesystems_and_preexisting_encryption_are_rejected() { + for extra in [ + "device: /dev/disk/by-partlabel/root\nlabel: other\nformat: xfs", + "device: /dev/mapper/root\nlabel: other\nformat: btrfs", + "device: /dev/other\nlabel: root\nformat: btrfs", + ] { + let mut value = source(); + value["storage"]["filesystems"] + .as_sequence_mut() + .unwrap() + .push(serde_yml::from_str(extra).unwrap()); + assert!(configure_root(&mut value, RootProfile::Tpm).is_err()); + } + let mut value = source(); + value["storage"]["luks"] = Value::Sequence(Vec::new()); + assert!(configure_root(&mut value, RootProfile::Tpm).is_err()); +} diff --git a/ublue/skillet/crates/vm/src/lib.rs b/ublue/skillet/crates/vm/src/lib.rs new file mode 100644 index 00000000..a335f8a7 --- /dev/null +++ b/ublue/skillet/crates/vm/src/lib.rs @@ -0,0 +1,78 @@ +//! Workstation VM identity and durable ownership. No vault or guest resources. + +pub mod artifacts; +pub mod backend; +pub mod catalog; +pub mod creation; +pub mod credential; +pub mod delivery; +pub mod domain_xml; +pub mod firmware; +pub mod install; +pub mod lifecycle; +pub mod manifest; +mod process; +pub mod provisioning; +pub mod readiness; +pub mod runtime; +pub mod staging; +pub mod transport; + +pub use manifest::{ + Backend, Connection, Environment, ManifestStore, Phase, RunIdentity, SshTarget, VmRun, +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid VM state: {0}")] + Invalid(String), + #[error("VM artifact I/O: {0}")] + Io(#[from] std::io::Error), + #[error("VM manifest JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("invalid libvirt XML: {0}")] + Xml(#[from] quick_xml::DeError), + #[error("libvirt XML rendering failed: {0}")] + XmlWrite(#[from] quick_xml::Error), + #[error("libvirt command {operation} failed (exit {code:?}); inspect the selected runtime")] + Command { + operation: String, + code: Option, + }, + #[error("VM tooling command exceeded its timeout")] + Timeout, + #[error("VM artifact preparation failed: {0}")] + Preparation(String), + #[error("external VM cleanup failed: {0}")] + ExternalCleanup(String), + #[error("VM run is busy; retry after its current operation finishes")] + Busy, + #[error("Cargo build failed (exit {code:?}): {diagnostic}")] + Build { + code: Option, + diagnostic: String, + }, + #[error( + "guest operation {operation} failed (exit {code:?}); inspect retained guest diagnostics" + )] + Guest { + operation: String, + code: Option, + }, +} + +pub type Result = std::result::Result; + +pub use process::capture_version; + +/// Capture a bounded host tool invocation for workstation provisioning. +pub fn capture_command( + command: std::process::Command, + timeout: std::time::Duration, +) -> Result { + process::capture(command, timeout) +} + +pub fn current_uid() -> u32 { + users::get_current_uid() +} diff --git a/ublue/skillet/crates/vm/src/lifecycle.rs b/ublue/skillet/crates/vm/src/lifecycle.rs new file mode 100644 index 00000000..1ebb0a82 --- /dev/null +++ b/ublue/skillet/crates/vm/src/lifecycle.rs @@ -0,0 +1,98 @@ +use crate::{backend::VmBackend, Error, ManifestStore, Phase, Result, RunIdentity, VmRun}; +use std::fs::{self, File}; + +/// Dispose external resources, the UUID-verified domain, then local artifacts. +/// The guest need not exist; runtime errors and cleanup failures retain state. +pub fn destroy( + store: &ManifestStore, + identity: &RunIdentity, + backend: &impl VmBackend, + mut external_cleanup: impl FnMut(&VmRun) -> Result<()>, +) -> Result<()> { + store.load(identity)?; + let dir = store.run_dir(identity); + let _lock = store.lock(identity)?; + let run = store.load(identity)?; + if let Some(domain) = backend.inspect(&run)? { + domain.validate_owned(&run)?; + } + let mut run = store.import(identity)?; + if !matches!( + run.phase, + Phase::ExternalCleanupComplete | Phase::DomainRemoved + ) { + external_cleanup(&run)?; + run.phase = Phase::ExternalCleanupComplete; + store.save(&run)?; + } + // Recheck after potentially long provider calls. UUID addressing prevents a + // name reused by another domain from being destroyed by a stale observation. + if let Some(domain) = backend.inspect(&run)? { + domain.validate_owned(&run)?; + if domain.state != "shut off" { + backend.stop(&run)?; + } + if let Some(domain) = backend.inspect(&run)? { + domain.validate_owned(&run)?; + backend.undefine(&run)?; + } + } + if backend.inspect(&run)?.is_some() { + return Err(Error::Invalid("domain still exists after disposal".into())); + } + run.phase = Phase::DomainRemoved; + store.save(&run)?; + // Validate once more before deleting only the owned run directory. + store.load(identity)?; + remove_artifacts(&dir, remove_entry)?; + // Only recovery records remain. Application artifacts have all been removed + // successfully before deleting the authoritative manifest. + fs::remove_dir_all(&dir)?; + if let Some(parent) = dir.parent() { + File::open(parent)?.sync_all()?; + } + Ok(()) +} + +fn remove_artifacts( + dir: &std::path::Path, + mut remove: impl FnMut(&std::path::Path) -> Result<()>, +) -> Result<()> { + let mut entries = fs::read_dir(dir)?.collect::>>()?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + if matches!( + entry.file_name().to_str(), + Some( + "vm.json" + | "run.conf" + | "domain.uuid" + | ".vm.lock" + | "skillet.sha256" + | "skillet-generic.sha256" + | "deployed-skillet.sha256" + | "deployed-skillet-generic.sha256" + | "tailscale-pending" + | "tailscale.json" + | "cloudflare.json" + ) + ) { + continue; + } + remove(&entry.path())?; + } + Ok(()) +} + +fn remove_entry(path: &std::path::Path) -> Result<()> { + if fs::symlink_metadata(path)?.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "lifecycle/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/lifecycle/tests.rs b/ublue/skillet/crates/vm/src/lifecycle/tests.rs new file mode 100644 index 00000000..d7c34c7f --- /dev/null +++ b/ublue/skillet/crates/vm/src/lifecycle/tests.rs @@ -0,0 +1,239 @@ +use super::*; +use crate::{backend::DomainSnapshot, manifest::tests::legacy_run}; +use nix::fcntl::{Flock, FlockArg}; +use std::{ + cell::{Cell, RefCell}, + fs::OpenOptions, +}; + +struct FakeBackend { + domain: RefCell>, + unavailable: Cell, + fail_undefine: Cell, + calls: RefCell>, +} +impl FakeBackend { + fn new(run: &VmRun, present: bool) -> Self { + Self { + domain: RefCell::new(present.then(|| DomainSnapshot { + boot: None, + name: run.identity.domain_name(), + uuid: run.uuid, + state: "running".into(), + disks: vec![run.disk.clone(), run.ignition.clone()], + })), + unavailable: Cell::new(false), + fail_undefine: Cell::new(false), + calls: RefCell::default(), + } + } +} +impl VmBackend for FakeBackend { + fn inspect(&self, _: &VmRun) -> Result> { + if self.unavailable.get() { + return Err(Error::Command { + operation: "list".into(), + code: Some(1), + }); + } + Ok(self.domain.borrow().clone()) + } + fn define(&self, _: &VmRun, _: &std::path::Path) -> Result<()> { + unreachable!() + } + fn start(&self, _: &VmRun) -> Result<()> { + unreachable!() + } + fn reboot(&self, _: &VmRun) -> Result<()> { + unreachable!() + } + fn stop(&self, run: &VmRun) -> Result<()> { + self.domain.borrow().as_ref().unwrap().validate_owned(run)?; + self.calls.borrow_mut().push("stop"); + self.domain.borrow_mut().as_mut().unwrap().state = "shut off".into(); + Ok(()) + } + fn undefine(&self, run: &VmRun) -> Result<()> { + self.domain.borrow().as_ref().unwrap().validate_owned(run)?; + if self.fail_undefine.get() { + return Err(Error::Command { + operation: "undefine".into(), + code: Some(1), + }); + } + self.calls.borrow_mut().push("undefine"); + *self.domain.borrow_mut() = None; + Ok(()) + } +} + +#[test] +fn destruction_orders_external_domain_then_owned_artifacts() { + let (tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + let unrelated = tmp.path().join("unrelated"); + fs::write(&unrelated, "keep").unwrap(); + destroy(&store, &identity, &backend, |_| { + assert!(backend.calls.borrow().is_empty()); + assert!(store.run_dir(&identity).exists()); + Ok(()) + }) + .unwrap(); + assert_eq!(*backend.calls.borrow(), ["stop", "undefine"]); + assert!(!store.run_dir(&identity).exists()); + assert_eq!(fs::read_to_string(unrelated).unwrap(), "keep"); +} + +#[test] +fn already_absent_domain_still_runs_external_cleanup() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, false); + let cleaned = Cell::new(false); + destroy(&store, &identity, &backend, |_| { + cleaned.set(true); + Ok(()) + }) + .unwrap(); + assert!(cleaned.get()); + assert!(backend.calls.borrow().is_empty()); + assert!(!store.run_dir(&identity).exists()); +} + +#[test] +fn failed_external_cleanup_preserves_state_and_retry_converges() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + let journal = store.run_dir(&identity).join("tailscale-pending"); + fs::write(&journal, "pending").unwrap(); + assert!(destroy(&store, &identity, &backend, |_| Err( + Error::ExternalCleanup("fixture failure".into()) + )) + .is_err()); + assert_eq!(store.load(&identity).unwrap().phase, Phase::Started); + assert!(journal.exists()); + assert!(backend.calls.borrow().is_empty()); + destroy(&store, &identity, &backend, |_| { + fs::remove_file(&journal)?; + Ok(()) + }) + .unwrap(); + assert!(!store.run_dir(&identity).exists()); +} + +#[test] +fn local_cleanup_retry_does_not_repeat_completed_external_cleanup() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + backend.fail_undefine.set(true); + let cleanup_calls = Cell::new(0); + assert!(destroy(&store, &identity, &backend, |_| { + cleanup_calls.set(cleanup_calls.get() + 1); + Ok(()) + }) + .is_err()); + assert_eq!( + store.load(&identity).unwrap().phase, + Phase::ExternalCleanupComplete + ); + assert_eq!(cleanup_calls.get(), 1); + backend.fail_undefine.set(false); + destroy(&store, &identity, &backend, |_| { + cleanup_calls.set(cleanup_calls.get() + 1); + Ok(()) + }) + .unwrap(); + assert_eq!(cleanup_calls.get(), 1); + assert!(!store.run_dir(&identity).exists()); +} + +#[test] +fn inaccessible_or_mismatched_domain_refuses_external_and_local_cleanup() { + for mismatch in [false, true] { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + if mismatch { + backend.domain.borrow_mut().as_mut().unwrap().uuid = uuid::Uuid::new_v4(); + } else { + backend.unavailable.set(true); + } + assert!(destroy(&store, &identity, &backend, |_| panic!( + "must not clean unverified ownership" + )) + .is_err()); + assert!(store.run_dir(&identity).join("run.conf").exists()); + assert!(!store.run_dir(&identity).join("vm.json").exists()); + assert!(backend.calls.borrow().is_empty()); + } +} + +#[test] +fn changed_ownership_after_external_cleanup_is_refused() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + assert!(destroy(&store, &identity, &backend, |_| { + backend.domain.borrow_mut().as_mut().unwrap().uuid = uuid::Uuid::new_v4(); + Ok(()) + }) + .is_err()); + assert_eq!( + store.load(&identity).unwrap().phase, + Phase::ExternalCleanupComplete + ); + assert!(backend.calls.borrow().is_empty()); +} + +#[test] +fn concurrent_disposal_is_refused_before_effects() { + let (_tmp, store, identity) = legacy_run(); + let run = store.load(&identity).unwrap(); + let backend = FakeBackend::new(&run, true); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(store.run_dir(&identity).join(".vm.lock")) + .unwrap(); + let _lock = Flock::lock(file, FlockArg::LockExclusiveNonblock).unwrap(); + assert!(matches!( + destroy(&store, &identity, &backend, |_| panic!("busy")), + Err(Error::Busy) + )); + assert!(backend.calls.borrow().is_empty()); +} + +#[test] +fn partial_artifact_removal_preserves_authoritative_and_legacy_recovery_records() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.import(&identity).unwrap(); + run.phase = Phase::DomainRemoved; + store.save(&run).unwrap(); + let dir = store.run_dir(&identity); + fs::write(dir.join("aa-artifact"), "owned data").unwrap(); + fs::write(dir.join("zz-artifact"), "owned data").unwrap(); + let result = remove_artifacts(&dir, |path| { + if path.file_name().unwrap() == "zz-artifact" { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected removal failure", + ) + .into()); + } + remove_entry(path) + }); + assert!(result.is_err()); + assert!(!dir.join("aa-artifact").exists()); + assert!(dir.join("zz-artifact").exists()); + assert_eq!(store.load(&identity).unwrap().phase, Phase::DomainRemoved); + assert!(dir.join("run.conf").exists()); + assert!(dir.join("domain.uuid").exists()); + assert!(dir.join("skillet.sha256").exists()); + remove_artifacts(&dir, remove_entry).unwrap(); + assert!(dir.join("vm.json").exists()); +} diff --git a/ublue/skillet/crates/vm/src/manifest.rs b/ublue/skillet/crates/vm/src/manifest.rs new file mode 100644 index 00000000..a3a4769b --- /dev/null +++ b/ublue/skillet/crates/vm/src/manifest.rs @@ -0,0 +1,666 @@ +use crate::{Error, Result}; +use nix::fcntl::{Flock, FlockArg}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::Write as _, + os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, +}; +use uuid::Uuid; + +pub const MANIFEST_VERSION: u32 = 1; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RunIdentity { + host: String, + instance: String, +} + +impl RunIdentity { + pub fn new(host: &str, instance: &str) -> Result { + if !valid_label(host, false) || !valid_label(instance, true) { + return Err(Error::Invalid( + "host or instance contains invalid characters".into(), + )); + } + Ok(Self { + host: host.into(), + instance: instance.into(), + }) + } + + pub fn host(&self) -> &str { + &self.host + } + pub fn instance(&self) -> &str { + &self.instance + } + pub fn domain_name(&self) -> String { + format!("{}-test-{}", self.host, self.instance) + } +} + +fn valid_label(value: &str, digit_first: bool) -> bool { + value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || (digit_first && byte.is_ascii_digit())) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Backend { + Native, + Flatpak, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Environment { + Test, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Connection { + pub backend: Backend, + pub uri: String, + pub runtime_dir: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SshTarget { + pub user: String, + pub address: String, + pub port: u16, + pub identity: PathBuf, + pub known_hosts: PathBuf, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Preparing, + Defined, + Started, + Ready, + ExternalCleanupComplete, + DomainRemoved, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactHashes { + pub host: String, + pub generic: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VmRun { + pub version: u32, + pub owner_uid: u32, + pub identity: RunIdentity, + pub environment: Environment, + #[serde(default)] + pub root_profile: crate::install::RootProfile, + pub guest_hostname: String, + pub uuid: Uuid, + pub connection: Connection, + pub ssh: SshTarget, + pub disk: PathBuf, + pub ignition: PathBuf, + pub source_commit: String, + pub captured: Option, + pub deployed: Option, + pub phase: Phase, +} + +/// The manifest contains only ownership/configuration, never secret payloads. +/// External provider journals remain separate and are preserved during import. +pub struct ManifestStore { + root: PathBuf, + owner_uid: u32, +} + +/// Held across an entire lifecycle mutation, including its external calls. +pub struct RunLock { + _lock: Flock, +} + +impl ManifestStore { + pub fn new(root: &Path, owner_uid: u32) -> Result { + if !root.is_absolute() + || root + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err(Error::Invalid("artifact root must be absolute".into())); + } + reject_symlinks(root)?; + Ok(Self { + root: root.into(), + owner_uid, + }) + } + + pub fn run_dir(&self, identity: &RunIdentity) -> PathBuf { + self.root.join(identity.domain_name()) + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// Create or resume a creation intent before any guest resources are + /// defined. Matching Preparing and Defined states can be recovered; + /// ambiguous or progressed runs must be inspected explicitly. + pub fn prepare_intent( + &self, + identity: &RunIdentity, + connection: Connection, + ssh_port: u16, + source_commit: &str, + ) -> Result { + self.prepare_intent_with_profile( + identity, + connection, + ssh_port, + source_commit, + crate::install::RootProfile::Unencrypted, + ) + } + + pub fn prepare_intent_with_profile( + &self, + identity: &RunIdentity, + connection: Connection, + ssh_port: u16, + source_commit: &str, + root_profile: crate::install::RootProfile, + ) -> Result { + if !(2200..=2299).contains(&ssh_port) || source_commit.trim().is_empty() { + return Err(Error::Invalid( + "invalid SSH port or empty source revision for VM creation".into(), + )); + } + connection.validate(self.owner_uid)?; + let dir = self.run_dir(identity); + reject_symlinks(&self.root)?; + if !self.root.exists() { + fs::create_dir_all(&self.root)?; + } + reject_symlinks(&self.root)?; + let root_metadata = fs::metadata(&self.root)?; + if !root_metadata.is_dir() || root_metadata.uid() != self.owner_uid { + return Err(Error::Invalid( + "VM artifact root is not a directory owned by the current user".into(), + )); + } + if dir.exists() { + let existing = self.load(identity)?; + if matches!(existing.phase, Phase::Preparing | Phase::Defined) + && existing.connection == connection + && existing.ssh.port == ssh_port + && existing.source_commit == source_commit + && existing.root_profile == root_profile + { + return Ok(existing); + } + return Err(Error::Invalid( + "VM run already exists and is not a matching recoverable creation; inspect or dispose it before creating".into(), + )); + } + fs::create_dir(&dir)?; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; + let run = VmRun { + version: MANIFEST_VERSION, + owner_uid: self.owner_uid, + identity: identity.clone(), + environment: Environment::Test, + root_profile, + guest_hostname: identity.domain_name(), + uuid: Uuid::new_v4(), + connection, + ssh: SshTarget { + user: "giacomo".into(), + address: "127.0.0.1".into(), + port: ssh_port, + identity: dir.join("ssh/id_ed25519"), + known_hosts: dir.join("ssh/known_hosts"), + }, + disk: dir.join(format!("{}.qcow2", identity.domain_name())), + ignition: dir + .join("ignition") + .join(format!("{}.ign", identity.host())), + source_commit: source_commit.into(), + captured: None, + deployed: None, + phase: Phase::Preparing, + }; + self.write(&run, false)?; + Ok(run) + } + + /// Record successful VM definition/start only after callers validate the + /// runtime UUID and disk ownership. Hashes are read from the captured files. + pub fn mark_started(&self, identity: &RunIdentity) -> Result { + let mut run = self.load(identity)?; + if !matches!(run.phase, Phase::Preparing | Phase::Defined) { + return Err(Error::Invalid( + "only a preparing or defined VM can be recorded as started".into(), + )); + } + let dir = self.run_dir(identity); + run.captured = Some(ArtifactHashes { + host: read_hash(&dir.join("skillet.sha256"))?, + generic: read_hash(&dir.join("skillet-generic.sha256"))?, + }); + run.phase = Phase::Started; + self.save(&run)?; + Ok(run) + } + + pub fn mark_defined(&self, identity: &RunIdentity) -> Result { + let mut run = self.load(identity)?; + if run.phase != Phase::Preparing { + return Err(Error::Invalid( + "only a preparing VM can be recorded as defined".into(), + )); + } + let dir = self.run_dir(identity); + run.captured = Some(ArtifactHashes { + host: read_hash(&dir.join("skillet.sha256"))?, + generic: read_hash(&dir.join("skillet-generic.sha256"))?, + }); + run.phase = Phase::Defined; + self.save(&run)?; + Ok(run) + } + + /// Reboot invalidates the previously accepted readiness result before the + /// backend action, so interruption cannot leave stale Ready state behind. + pub fn mark_reboot_pending(&self, identity: &RunIdentity) -> Result { + let mut run = self.load(identity)?; + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(Error::Invalid( + "only a started or ready VM can be rebooted".into(), + )); + } + run.phase = Phase::Started; + self.save(&run)?; + Ok(run) + } + + /// Compatibility entry points obtain identity from recorded fields, never + /// by splitting a potentially ambiguous composite directory name. + pub fn load_directory(&self, dir: &Path) -> Result { + if dir.parent() != Some(self.root.as_path()) { + return Err(Error::Invalid( + "run is outside the recorded artifact root".into(), + )); + } + self.validate_directory(dir)?; + let json = dir.join("vm.json"); + reject_symlinks(&json)?; + let identity = if json.exists() { + let run: VmRun = serde_json::from_str(&read_file(&json)?)?; + run.identity + } else { + let config = parse_legacy(&read_file(&dir.join("run.conf"))?)?; + let host = config + .get("host") + .ok_or_else(|| Error::Invalid("legacy manifest lacks host".into()))?; + let vm = config + .get("vm") + .ok_or_else(|| Error::Invalid("legacy manifest lacks vm".into()))?; + let instance = vm + .strip_prefix(&format!("{host}-test-")) + .ok_or_else(|| Error::Invalid("legacy VM does not match its profile".into()))?; + RunIdentity::new(host, instance)? + }; + if self.run_dir(&identity) != dir { + return Err(Error::Invalid( + "recorded identity differs from run directory".into(), + )); + } + self.load(&identity) + } + + pub fn lock(&self, identity: &RunIdentity) -> Result { + let dir = self.run_dir(identity); + self.validate_directory(&dir)?; + self.lock_path(&dir.join(".vm.lock")) + } + + /// Serialize the complete create orchestration separately from the shorter + /// per-step manifest mutations it invokes. + pub fn lock_create(&self, identity: &RunIdentity) -> Result { + let dir = self.run_dir(identity); + self.validate_directory(&dir)?; + self.lock_path(&dir.join(".vm-create.lock")) + } + + fn lock_path(&self, path: &Path) -> Result { + reject_symlinks(path)?; + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(path)?; + if !file.metadata()?.is_file() || file.metadata()?.uid() != self.owner_uid { + return Err(Error::Invalid( + "invalid VM lock file ownership or type".into(), + )); + } + let lock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, error)| { + if error == nix::errno::Errno::EWOULDBLOCK { + Error::Busy + } else { + Error::Io(std::io::Error::from_raw_os_error(error as i32)) + } + })?; + Ok(RunLock { _lock: lock }) + } + + /// Read without migrating or contacting a runtime; safe for retained runs. + pub fn load(&self, identity: &RunIdentity) -> Result { + let dir = self.run_dir(identity); + self.validate_directory(&dir)?; + let json = dir.join("vm.json"); + reject_symlinks(&json)?; + let run = if json.exists() { + serde_json::from_str(&read_file(&json)?)? + } else { + self.read_legacy(identity, &dir)? + }; + self.validate(&run, identity)?; + Ok(run) + } + + /// Explicit, atomic import. Keep all legacy files and provider journals. + pub fn import(&self, identity: &RunIdentity) -> Result { + let run = self.load(identity)?; + let path = self.run_dir(identity).join("vm.json"); + if !path.exists() { + match self.write(&run, false) { + Ok(()) => (), + Err(Error::Io(error)) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return self.load(identity); + } + Err(error) => return Err(error), + } + } + Ok(run) + } + + pub fn save(&self, run: &VmRun) -> Result<()> { + self.write(run, true) + } + + fn write(&self, run: &VmRun, replace: bool) -> Result<()> { + self.validate(run, &run.identity)?; + let dir = self.run_dir(&run.identity); + self.validate_directory(&dir)?; + let path = dir.join("vm.json"); + reject_symlinks(&path)?; + let mut file = tempfile::NamedTempFile::new_in(&dir)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(&serde_json::to_vec_pretty(run)?)?; + file.write_all(b"\n")?; + file.as_file().sync_all()?; + if replace { + file.persist(&path) + .map_err(|error| Error::Io(error.error))?; + } else { + file.persist_noclobber(&path) + .map_err(|error| Error::Io(error.error))?; + } + File::open(dir)?.sync_all()?; + Ok(()) + } + + fn validate_directory(&self, dir: &Path) -> Result<()> { + reject_symlinks(dir)?; + let metadata = fs::metadata(dir)?; + if !metadata.is_dir() || metadata.uid() != self.owner_uid { + return Err(Error::Invalid( + "run directory is not owned by the current user".into(), + )); + } + Ok(()) + } + + pub fn validate(&self, run: &VmRun, identity: &RunIdentity) -> Result<()> { + RunIdentity::new(run.identity.host(), run.identity.instance())?; + if run.version != MANIFEST_VERSION + || run.owner_uid != self.owner_uid + || &run.identity != identity + || run.guest_hostname != identity.domain_name() + || run.uuid.is_nil() + { + return Err(Error::Invalid( + "manifest version, identity or owner mismatch".into(), + )); + } + let dir = self.run_dir(identity); + if run.disk != dir.join(format!("{}.qcow2", identity.domain_name())) + || run.ignition + != dir + .join("ignition") + .join(format!("{}.ign", identity.host())) + || run.ssh.identity != dir.join("ssh/id_ed25519") + || run.ssh.known_hosts != dir.join("ssh/known_hosts") + || run.ssh.user != "giacomo" + || run.ssh.address != "127.0.0.1" + || !(2200..=2299).contains(&run.ssh.port) + { + return Err(Error::Invalid( + "manifest artifact paths or SSH target mismatch".into(), + )); + } + run.connection.validate(self.owner_uid)?; + if run.phase != Phase::Preparing && run.captured.is_none() { + return Err(Error::Invalid( + "prepared runs require captured artifact hashes".into(), + )); + } + for path in [ + &run.disk, + &run.ignition, + &run.ssh.identity, + &run.ssh.known_hosts, + ] { + reject_symlinks(path)?; + } + if run.root_profile == crate::install::RootProfile::Tpm { + reject_symlinks(&crate::install::nvram_path(run)?)?; + reject_symlinks(&crate::install::tpm_path(run)?)?; + reject_symlinks(&dir.join("serial.log"))?; + for name in [ + "recovery", + "recovery/root-recovery.key", + "recovery/root-luks.header", + ] { + reject_symlinks(&dir.join(name))?; + } + } + for hashes in [&run.captured, &run.deployed].into_iter().flatten() { + if !valid_hash(&hashes.host) || !valid_hash(&hashes.generic) { + return Err(Error::Invalid("artifact hashes must be SHA256".into())); + } + } + Ok(()) + } + + fn read_legacy(&self, identity: &RunIdentity, dir: &Path) -> Result { + let config = parse_legacy(&read_file(&dir.join("run.conf"))?)?; + let field = |name: &str| { + config + .get(name) + .cloned() + .ok_or_else(|| Error::Invalid(format!("legacy manifest lacks {name}"))) + }; + if field("vm")? != identity.domain_name() || field("host")? != identity.host() { + return Err(Error::Invalid("legacy manifest identity mismatch".into())); + } + let backend = match config.get("backend").map(String::as_str) { + None | Some("" | "native") => Backend::Native, + Some("flatpak") => Backend::Flatpak, + _ => return Err(Error::Invalid("unknown legacy VM backend".into())), + }; + let uuid = Uuid::parse_str(read_file(&dir.join("domain.uuid"))?.trim()).map_err(|_| { + Error::Invalid( + "legacy UUID is absent or invalid; inspect partial creation before adoption".into(), + ) + })?; + let captured = Some(ArtifactHashes { + host: read_hash(&dir.join("skillet.sha256"))?, + generic: read_hash(&dir.join("skillet-generic.sha256"))?, + }); + let host_deployed = dir.join("deployed-skillet.sha256"); + let generic_deployed = dir.join("deployed-skillet-generic.sha256"); + let deployed = match (host_deployed.exists(), generic_deployed.exists()) { + (false, false) => None, + (true, true) => Some(ArtifactHashes { + host: read_hash(&host_deployed)?, + generic: read_hash(&generic_deployed)?, + }), + _ => return Err(Error::Invalid("incomplete deployed artifact hashes".into())), + }; + Ok(VmRun { + version: MANIFEST_VERSION, + owner_uid: self.owner_uid, + identity: identity.clone(), + environment: Environment::Test, + root_profile: crate::install::RootProfile::Unencrypted, + guest_hostname: identity.domain_name(), + uuid, + connection: Connection { + backend, + uri: field("uri")?, + runtime_dir: field("runtime_dir")?.into(), + }, + ssh: SshTarget { + user: "giacomo".into(), + address: "127.0.0.1".into(), + port: field("ssh_port")? + .parse() + .map_err(|_| Error::Invalid("invalid legacy SSH port".into()))?, + identity: dir.join("ssh/id_ed25519"), + known_hosts: dir.join("ssh/known_hosts"), + }, + disk: dir.join(format!("{}.qcow2", identity.domain_name())), + ignition: dir + .join("ignition") + .join(format!("{}.ign", identity.host())), + source_commit: field("source_commit")?, + captured, + deployed, + phase: Phase::Started, + }) + } +} + +impl Connection { + pub fn validate(&self, owner_uid: u32) -> Result<()> { + let base = PathBuf::from(format!("/run/user/{owner_uid}")); + if self.uri != "qemu:///session" + || !self.runtime_dir.is_absolute() + || self.runtime_dir.components().any(|part| { + matches!( + part, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + || (self.backend == Backend::Native && !self.runtime_dir.starts_with(&base)) + || (self.backend == Backend::Flatpak && self.runtime_dir != base.join("skvm")) + { + return Err(Error::Invalid( + "unsupported libvirt connection or runtime path".into(), + )); + } + reject_symlinks(&self.runtime_dir) + } +} + +fn parse_legacy(contents: &str) -> Result> { + let mut values = BTreeMap::new(); + for line in contents.lines() { + let (key, value) = line + .split_once('=') + .ok_or_else(|| Error::Invalid("malformed legacy manifest line".into()))?; + if !matches!( + key, + "vm" | "host" + | "uri" + | "ssh_port" + | "image" + | "artifact" + | "runtime_dir" + | "backend" + | "source_commit" + ) || values.insert(key.into(), value.into()).is_some() + { + return Err(Error::Invalid( + "unknown or duplicate legacy manifest field".into(), + )); + } + } + Ok(values) +} + +fn valid_hash(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} +fn read_hash(path: &Path) -> Result { + let content = read_file(path)?; + let hash = content + .split_whitespace() + .next() + .ok_or_else(|| Error::Invalid("empty artifact hash file".into()))?; + if !valid_hash(hash) { + return Err(Error::Invalid("invalid artifact hash file".into())); + } + Ok(hash.to_ascii_lowercase()) +} +fn read_file(path: &Path) -> Result { + reject_symlinks(path)?; + if !fs::metadata(path)?.is_file() { + return Err(Error::Invalid("VM metadata must be a regular file".into())); + } + Ok(fs::read_to_string(path)?) +} + +/// Refuse links in ancestors as well as the final component; missing leaves are +/// allowed for preparation and interrupted cleanup, but never adopted as owned. +pub(crate) fn reject_symlinks(path: &Path) -> Result<()> { + for ancestor in path.ancestors() { + match fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(Error::Invalid( + "symlinked VM artifact or runtime path".into(), + )) + } + Ok(_) => (), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (), + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "manifest/tests.rs"] +pub(crate) mod tests; diff --git a/ublue/skillet/crates/vm/src/manifest/tests.rs b/ublue/skillet/crates/vm/src/manifest/tests.rs new file mode 100644 index 00000000..7b9c3a3d --- /dev/null +++ b/ublue/skillet/crates/vm/src/manifest/tests.rs @@ -0,0 +1,308 @@ +use super::*; +use std::{os::unix::fs::symlink, path::PathBuf}; + +pub(crate) fn legacy_run() -> (tempfile::TempDir, ManifestStore, RunIdentity) { + let tmp = tempfile::tempdir().unwrap(); + let identity = RunIdentity::new("fixture", "retained-2").unwrap(); + let store = ManifestStore::new(tmp.path(), users::get_current_uid()).unwrap(); + let dir = store.run_dir(&identity); + fs::create_dir(&dir).unwrap(); + fs::write(dir.join("run.conf"), format!( + "vm={}\nhost=fixture\nuri=qemu:///session\nssh_port=2207\nimage=/original/image\nartifact=/original/binary\nruntime_dir=/run/user/{}\nbackend=native\nsource_commit=original-revision\n", + identity.domain_name(), users::get_current_uid() + )).unwrap(); + fs::write( + dir.join("domain.uuid"), + "188ecb9b-3606-4f0f-9868-abcf366c824b\n", + ) + .unwrap(); + fs::write( + dir.join("skillet.sha256"), + format!("{} /original/host\n", "a".repeat(64)), + ) + .unwrap(); + fs::write( + dir.join("skillet-generic.sha256"), + format!("{} /original/generic\n", "b".repeat(64)), + ) + .unwrap(); + (tmp, store, identity) +} + +#[test] +fn identity_separates_profile_and_instance_and_rejects_path_input() { + let identity = RunIdentity::new("fixture", "2-smoke").unwrap(); + assert_eq!(identity.host(), "fixture"); + assert_eq!(identity.instance(), "2-smoke"); + assert_eq!(identity.domain_name(), "fixture-test-2-smoke"); + for invalid in ["", "../escape", "two words", "UPPER", "-bad", "a\n"] { + assert!(RunIdentity::new(invalid, "smoke").is_err()); + assert!(RunIdentity::new("fixture", invalid).is_err()); + } +} + +#[test] +fn preparation_intent_is_durable_repeatable_and_can_be_advanced_after_capture() { + let tmp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(tmp.path(), users::get_current_uid()).unwrap(); + let identity = RunIdentity::new("fixture", "intent").unwrap(); + let connection = Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir: PathBuf::from(format!("/run/user/{}", users::get_current_uid())), + }; + let first = store + .prepare_intent(&identity, connection.clone(), 2205, "fixture-revision") + .unwrap(); + assert_eq!(first.phase, Phase::Preparing); + assert!(first.captured.is_none()); + assert_eq!( + fs::metadata(store.run_dir(&identity)).unwrap().mode() & 0o777, + 0o700 + ); + let repeated = store + .prepare_intent(&identity, connection.clone(), 2205, "fixture-revision") + .unwrap(); + assert_eq!(repeated.uuid, first.uuid); + assert!(store + .prepare_intent(&identity, connection.clone(), 2206, "fixture-revision") + .is_err()); + + let dir = store.run_dir(&identity); + fs::write( + dir.join("skillet.sha256"), + format!("{} host\n", "a".repeat(64)), + ) + .unwrap(); + fs::write( + dir.join("skillet-generic.sha256"), + format!("{} generic\n", "b".repeat(64)), + ) + .unwrap(); + let defined = store.mark_defined(&identity).unwrap(); + assert_eq!(defined.phase, Phase::Defined); + assert_eq!( + store + .prepare_intent(&identity, connection.clone(), 2205, "fixture-revision") + .unwrap() + .uuid, + first.uuid + ); + let started = store.mark_started(&identity).unwrap(); + assert_eq!(started.phase, Phase::Started); + assert_eq!(started.captured.unwrap().host, "a".repeat(64)); + assert!(store + .prepare_intent(&identity, connection.clone(), 2205, "fixture-revision") + .is_err()); +} + +#[test] +fn legacy_read_is_non_mutating_and_preserves_original_artifacts() { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let before = fs::read(dir.join("run.conf")).unwrap(); + let run = store.load(&identity).unwrap(); + assert_eq!(run.ssh.port, 2207); + assert_eq!(run.source_commit, "original-revision"); + assert_eq!(run.captured.unwrap().host, "a".repeat(64)); + assert!(!dir.join("vm.json").exists()); + assert_eq!(fs::read(dir.join("run.conf")).unwrap(), before); +} + +#[test] +fn directory_compatibility_uses_recorded_identity_without_import_or_name_splitting() { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let run = store.load_directory(&dir).unwrap(); + assert_eq!(run.identity, identity); + assert!(!dir.join("vm.json").exists()); + store.import(&identity).unwrap(); + assert_eq!(store.load_directory(&dir).unwrap(), run); + assert!(store.load_directory(dir.parent().unwrap()).is_err()); + let other = RunIdentity::new("fixture-test-region", "retained-2").unwrap(); + let other_dir = store.run_dir(&other); + fs::rename(&dir, &other_dir).unwrap(); + fs::remove_file(other_dir.join("vm.json")).unwrap(); + let config = fs::read_to_string(other_dir.join("run.conf")) + .unwrap() + .replace("host=fixture\n", "host=fixture-test-region\n") + .replace(&identity.domain_name(), &other.domain_name()); + fs::write(other_dir.join("run.conf"), config).unwrap(); + assert_eq!(store.load_directory(&other_dir).unwrap().identity, other); +} + +#[test] +fn explicit_import_is_private_repeatable_and_preserves_external_journals() { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + fs::write(dir.join("cloudflare.json"), "opaque external ownership").unwrap(); + fs::write(dir.join("tailscale-pending"), "pending").unwrap(); + let first = store.import(&identity).unwrap(); + assert_eq!(store.import(&identity).unwrap(), first); + assert_eq!(store.load(&identity).unwrap(), first); + assert_eq!( + fs::metadata(dir.join("vm.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::read_to_string(dir.join("cloudflare.json")).unwrap(), + "opaque external ownership" + ); + assert!(dir.join("tailscale-pending").exists()); + assert!(dir.join("run.conf").exists()); +} + +#[test] +fn legacy_import_rejects_ambiguous_or_partial_ownership_without_writing() { + for corrupt in [ + "duplicate", + "uuid", + "identity", + "port", + "hash", + "backend", + "deployed", + ] { + let (_tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let config_path = dir.join("run.conf"); + let config = fs::read_to_string(&config_path).unwrap(); + match corrupt { + "duplicate" => fs::write(&config_path, format!("{config}ssh_port=2201\n")).unwrap(), + "uuid" => fs::remove_file(dir.join("domain.uuid")).unwrap(), + "identity" => { + fs::write(&config_path, config.replace("host=fixture", "host=other")).unwrap(); + } + "port" => { + fs::write(&config_path, config.replace("ssh_port=2207", "ssh_port=22")).unwrap(); + } + "hash" => fs::write(dir.join("skillet.sha256"), "bad hash").unwrap(), + "backend" => fs::write( + &config_path, + config.replace("backend=native", "backend=unknown"), + ) + .unwrap(), + "deployed" => fs::write(dir.join("deployed-skillet.sha256"), "c".repeat(64)).unwrap(), + _ => unreachable!(), + } + assert!(store.import(&identity).is_err(), "accepted {corrupt}"); + assert!(!dir.join("vm.json").exists()); + } +} + +#[test] +fn json_manifest_rejects_owner_identity_version_and_paths() { + let (_tmp, store, identity) = legacy_run(); + let original = store.load(&identity).unwrap(); + for field in [ + "version", "owner", "identity", "disk", "ssh", "runtime", "nil", "hash", + ] { + let mut run = original.clone(); + match field { + "version" => run.version = 99, + "owner" => run.owner_uid = run.owner_uid.wrapping_add(1), + "identity" => run.identity = RunIdentity::new("other", "retained-2").unwrap(), + "disk" => run.disk = "/outside/data.qcow2".into(), + "ssh" => run.ssh.known_hosts = "/outside/known_hosts".into(), + "runtime" => run.connection.runtime_dir = "/run/user/999999/../other".into(), + "nil" => run.uuid = Uuid::nil(), + "hash" => run.captured.as_mut().unwrap().host = "bad".into(), + _ => unreachable!(), + } + assert!(store.validate(&run, &identity).is_err(), "accepted {field}"); + } +} + +#[test] +fn symlinks_in_manifest_and_artifacts_are_refused() { + for component in ["run.conf", "domain.uuid", "vm.json", "disk", "ancestor"] { + let (tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity); + let target = tmp.path().join("unrelated"); + fs::write(&target, "not owned").unwrap(); + let path = match component { + "disk" => dir.join(format!("{}.qcow2", identity.domain_name())), + "ancestor" => dir.join("ssh"), + _ => dir.join(component), + }; + if path.exists() { + fs::remove_file(&path).unwrap(); + } + symlink(&target, &path).unwrap(); + assert!(store.load(&identity).is_err(), "accepted link {component}"); + assert_eq!(fs::read_to_string(target).unwrap(), "not owned"); + } +} + +#[test] +fn partial_cleanup_can_load_when_owned_artifacts_are_already_absent() { + let (_tmp, store, identity) = legacy_run(); + let mut run = store.import(&identity).unwrap(); + run.phase = Phase::DomainRemoved; + store.save(&run).unwrap(); + assert_eq!(store.load(&identity).unwrap().phase, Phase::DomainRemoved); +} + +#[test] +fn reboot_invalidates_readiness_before_the_backend_operation() { + let (_tmp, store, identity) = legacy_run(); + let mut ready = store.import(&identity).unwrap(); + ready.phase = Phase::Ready; + store.save(&ready).unwrap(); + let ready = store.load(&identity).unwrap(); + assert_eq!(ready.phase, Phase::Ready); + let rebooting = store.mark_reboot_pending(&identity).unwrap(); + assert_eq!(rebooting.phase, Phase::Started); + assert_eq!(store.load(&identity).unwrap().phase, Phase::Started); +} + +#[test] +fn flatpak_and_native_connections_have_distinct_validated_runtime_paths() { + let uid = users::get_current_uid(); + let mut connection = Connection { + backend: Backend::Flatpak, + uri: "qemu:///session".into(), + runtime_dir: format!("/run/user/{uid}/skvm").into(), + }; + assert!(connection.validate(uid).is_ok()); + connection.runtime_dir = format!("/run/user/{uid}/other").into(); + assert!(connection.validate(uid).is_err()); + connection.backend = Backend::Native; + assert!(connection.validate(uid).is_ok()); + connection.uri = "qemu:///system".into(); + assert!(connection.validate(uid).is_err()); +} + +#[test] +fn metadata_fifo_is_rejected_without_blocking() { + let (_tmp, store, identity) = legacy_run(); + let path = store.run_dir(&identity).join("vm.json"); + nix::unistd::mkfifo( + &path, + nix::sys::stat::Mode::S_IRUSR | nix::sys::stat::Mode::S_IWUSR, + ) + .unwrap(); + assert!(store.load(&identity).is_err()); +} + +#[test] +fn shared_run_lock_is_nonblocking_and_refuses_linked_files() { + let (tmp, store, identity) = legacy_run(); + let first = store.lock(&identity).unwrap(); + assert!(matches!(store.lock(&identity), Err(Error::Busy))); + let create = store.lock_create(&identity).unwrap(); + assert!(matches!(store.lock_create(&identity), Err(Error::Busy))); + drop(create); + drop(first); + let lock_path = store.run_dir(&identity).join(".vm.lock"); + fs::remove_file(&lock_path).unwrap(); + let target = tmp.path().join("other-lock"); + fs::write(&target, "unrelated").unwrap(); + symlink(&target, &lock_path).unwrap(); + assert!(store.lock(&identity).is_err()); + assert_eq!(fs::read_to_string(target).unwrap(), "unrelated"); +} diff --git a/ublue/skillet/crates/vm/src/process.rs b/ublue/skillet/crates/vm/src/process.rs new file mode 100644 index 00000000..3c8dca1a --- /dev/null +++ b/ublue/skillet/crates/vm/src/process.rs @@ -0,0 +1,160 @@ +//! Bounded direct subprocess capture. Payloads and output stay in memory. +use crate::{Error, Result}; +use std::{ + io::{Read, Write as _}, + os::unix::process::CommandExt as _, + process::{Child, Command, Output, Stdio}, + sync::mpsc::{self, Receiver}, + time::{Duration, Instant}, +}; + +const OUTPUT_LIMIT: u64 = 64 * 1024 * 1024; + +pub(crate) fn capture(command: Command, timeout: Duration) -> Result { + capture_with_input(command, timeout, None) +} + +/// Capture a local tool's version with the same output and time bounds used +/// for lifecycle subprocesses. +pub fn capture_version(program: &str) -> Result { + let mut command = Command::new(program); + command.arg("--version"); + let output = capture(command, Duration::from_secs(30))?; + if !output.status.success() { + return Err(Error::Command { + operation: format!("{program} --version"), + code: output.status.code(), + }); + } + String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid(format!("{program} version output is not UTF8"))) +} + +pub(crate) fn capture_with_input( + mut command: Command, + timeout: Duration, + input: Option<&[u8]>, +) -> Result { + let child = command + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0) + .spawn()?; + let mut managed = ManagedChild { + child, + complete: false, + }; + let stdout = read_stream( + managed + .child + .stdout + .take() + .ok_or_else(|| Error::Invalid("tool stdout unavailable".into()))?, + ); + let stderr = read_stream( + managed + .child + .stderr + .take() + .ok_or_else(|| Error::Invalid("tool stderr unavailable".into()))?, + ); + let writer = if let Some(input) = input { + let mut pipe = managed + .child + .stdin + .take() + .ok_or_else(|| Error::Invalid("tool stdin unavailable".into()))?; + let bytes = zeroize::Zeroizing::new(input.to_vec()); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(pipe.write_all(&bytes)); + }); + Some(receiver) + } else { + None + }; + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = managed.child.try_wait()? { + break status; + } + if Instant::now() >= deadline { + return Err(Error::Timeout); + } + std::thread::sleep(Duration::from_millis(20)); + }; + let stdout = receive(&stdout, deadline)??; + let stderr = receive(&stderr, deadline)??; + if let Some(writer) = writer { + let written = receive(&writer, deadline)?; + if status.success() { + written?; + } + } + managed.complete = true; + Ok(Output { + status, + stdout, + stderr, + }) +} + +fn read_stream(stream: impl Read + Send + 'static) -> Receiver>> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let result = (|| { + let mut bytes = Vec::new(); + stream.take(OUTPUT_LIMIT + 1).read_to_end(&mut bytes)?; + if u64::try_from(bytes.len()) + .map_err(|_| Error::Invalid("tool output size overflow".into()))? + > OUTPUT_LIMIT + { + return Err(Error::Invalid( + "tool output exceeded the memory limit".into(), + )); + } + Ok(bytes) + })(); + let _ = sender.send(result); + }); + receiver +} + +fn receive(receiver: &Receiver, deadline: Instant) -> Result { + receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => Error::Timeout, + mpsc::RecvTimeoutError::Disconnected => { + Error::Invalid("tool stream worker stopped".into()) + } + }) +} + +struct ManagedChild { + child: Child, + complete: bool, +} +impl Drop for ManagedChild { + fn drop(&mut self) { + if !self.complete { + if let Ok(id) = i32::try_from(self.child.id()) { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(id), + nix::sys::signal::Signal::SIGKILL, + ); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +#[cfg(test)] +#[path = "process/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/process/tests.rs b/ublue/skillet/crates/vm/src/process/tests.rs new file mode 100644 index 00000000..027b6a80 --- /dev/null +++ b/ublue/skillet/crates/vm/src/process/tests.rs @@ -0,0 +1,40 @@ +use super::*; + +#[test] +fn stdin_and_large_output_are_memory_backed_and_do_not_deadlock() { + let input = vec![b'x'; 1024 * 1024]; + let output = capture_with_input( + Command::new("/usr/bin/cat"), + Duration::from_secs(5), + Some(&input), + ) + .unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout, input); + assert_eq!(output.stderr, Vec::::new()); +} + +#[test] +fn blocked_stdin_writer_and_noninteractive_child_are_bounded() { + let mut command = Command::new("/usr/bin/sleep"); + command.arg("5"); + let input = vec![b'x'; 1024 * 1024]; + let start = Instant::now(); + assert!(matches!( + capture_with_input(command, Duration::from_millis(30), Some(&input)), + Err(Error::Timeout) + )); + assert!(start.elapsed() < Duration::from_secs(2)); +} + +#[test] +fn nonzero_exit_is_preserved_when_the_child_does_not_consume_stdin() { + let output = capture_with_input( + Command::new("/usr/bin/false"), + Duration::from_secs(2), + Some(&vec![b'x'; 1024 * 1024]), + ) + .unwrap(); + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(1)); +} diff --git a/ublue/skillet/crates/vm/src/provisioning.rs b/ublue/skillet/crates/vm/src/provisioning.rs new file mode 100644 index 00000000..599049f5 --- /dev/null +++ b/ublue/skillet/crates/vm/src/provisioning.rs @@ -0,0 +1,375 @@ +//! Local artifacts owned by a disposable VM creation run. +use crate::{manifest::reject_symlinks, Error, ManifestStore, Phase, Result, RunIdentity, VmRun}; +use sha2::{Digest, Sha256}; +use std::{ + ffi::OsString, + fs::{self, File, OpenOptions}, + io::{Read, Write as _}, + net::TcpListener, + os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + process::Command, + time::Duration, +}; + +/// Fail early when another local listener already owns the VM's loopback SSH +/// forwarding port. +pub fn validate_ssh_port(port: u16) -> Result<()> { + validate_ssh_port_with(port, |port| { + TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).map(drop) + }) +} + +fn validate_ssh_port_with(port: u16, bind: impl FnOnce(u16) -> std::io::Result<()>) -> Result<()> { + if !(2200..=2299).contains(&port) { + return Err(Error::Invalid( + "VM SSH port must be between 2200 and 2299".into(), + )); + } + bind(port) + .map_err(|error| Error::Preparation(format!("VM SSH port {port} is unavailable: {error}"))) +} + +/// Resolve one supported Fedora `CoreOS` QEMU image, downloading it through the +/// existing `CoreOS` installer container only when none is already present. +pub fn resolve_coreos_image(images_dir: &Path, requested: Option<&Path>) -> Result { + if let Some(image) = requested { + reject_symlinks(image)?; + if image.is_file() { + return fs::canonicalize(image).map_err(Into::into); + } + return Err(Error::Invalid( + "requested VM image is not a regular file".into(), + )); + } + let mut candidates = coreos_images(images_dir)?; + if candidates.is_empty() { + download_coreos_image(images_dir)?; + candidates = coreos_images(images_dir)?; + } + match candidates.as_slice() { + [image] => fs::canonicalize(image).map_err(Into::into), + [] => Err(Error::Invalid( + "CoreOS installer completed without producing a QEMU image".into(), + )), + _ => Err(Error::Invalid(format!( + "multiple Fedora CoreOS QEMU images exist under {}; pass --image to choose one", + images_dir.display() + ))), + } +} + +fn coreos_images(images_dir: &Path) -> Result> { + reject_symlinks(images_dir)?; + if !images_dir.is_dir() { + return Ok(Vec::new()); + } + let mut candidates = Vec::new(); + for entry in fs::read_dir(images_dir)? { + let path = entry?.path(); + let matches_name = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with("fedora-coreos-") && name.ends_with("-qemu.x86_64.qcow2") + }); + if !matches_name { + continue; + } + reject_symlinks(&path)?; + if fs::metadata(&path)?.is_file() { + candidates.push(path); + } + } + candidates.sort(); + Ok(candidates) +} + +fn download_coreos_image(images_dir: &Path) -> Result<()> { + if !images_dir.is_absolute() + || images_dir + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err(Error::Invalid( + "image directory must be an absolute safe path".into(), + )); + } + reject_symlinks(images_dir)?; + fs::create_dir_all(images_dir)?; + let mount_source = images_dir + .to_str() + .filter(|path| !path.contains(',') && !path.contains('\n')) + .ok_or_else(|| { + Error::Invalid("image directory is not valid for Podman mount syntax".into()) + })?; + let mut command = Command::new("podman"); + command.args([ + "run", + "--pull=always", + "--rm", + "--security-opt", + "label=disable", + "--volume", + ]); + command.arg(format!("{mount_source}:/data")); + command.args([ + "--workdir", + "/data", + "quay.io/coreos/coreos-installer:release", + "download", + "-s", + "stable", + "-p", + "qemu", + "-f", + "qcow2.xz", + "--decompress", + ]); + let output = crate::process::capture(command, Duration::from_mins(30))?; + if !output.status.success() { + return Err(Error::Command { + operation: "download Fedora CoreOS QEMU image".into(), + code: output.status.code(), + }); + } + Ok(()) +} + +/// Return the recorded source revision used to identify a resumable run. +pub fn source_revision(workspace: &Path) -> Result { + let mut command = Command::new("git"); + command.args([OsString::from("-C"), workspace.as_os_str().to_owned()]); + command.args(["rev-parse", "--verify", "HEAD"]); + let output = crate::process::capture(command, Duration::from_secs(15))?; + if !output.status.success() { + return Err(Error::Preparation( + "git could not identify the source revision for VM creation".into(), + )); + } + let revision = String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid("git source revision is not UTF8".into()))? + .trim() + .to_owned(); + if revision.is_empty() || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::Invalid( + "git returned an invalid source revision".into(), + )); + } + Ok(revision) +} + +/// Prepare the private SSH key and VM disk for a recorded, not-yet-defined run. +/// Repeated calls reuse a matching key and image, and repair a changed disk. +pub fn prepare_local_artifacts( + store: &ManifestStore, + identity: &RunIdentity, + image: &Path, +) -> Result { + let _lock = store.lock(identity)?; + let run = store.load(identity)?; + store.validate(&run, identity)?; + if run.phase != Phase::Preparing { + return Err(Error::Invalid( + "local VM artifacts can only be prepared for a preparing run".into(), + )); + } + prepare_disk(store, &run, image)?; + prepare_key(store, &run) +} + +fn prepare_disk(store: &ManifestStore, run: &VmRun, image: &Path) -> Result<()> { + reject_symlinks(image)?; + let mut source = OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(image)?; + if !source.metadata()?.is_file() { + return Err(Error::Invalid("VM image is not a regular file".into())); + } + let dir = store.run_dir(&run.identity); + reject_symlinks(&dir)?; + reject_symlinks(&run.disk)?; + let (source_hash, source_size) = hash_file(&mut source)?; + let sidecar = dir.join("fcos-image.sha256"); + let disk_matches = if run.disk.is_file() { + let mut disk = OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(&run.disk)?; + disk.metadata()?.is_file() + && disk.metadata()?.len() == source_size + && hash_file(&mut disk)?.0 == source_hash + } else { + false + }; + if !disk_matches { + let mut source = OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(image)?; + let mut staged = tempfile::NamedTempFile::new_in(&dir)?; + staged + .as_file() + .set_permissions(fs::Permissions::from_mode(0o644))?; + let mut hasher = Sha256::new(); + let mut copied = 0_u64; + let mut buffer = [0_u8; 16 * 1024]; + loop { + let count = source.read(&mut buffer)?; + if count == 0 { + break; + } + staged.write_all(&buffer[..count])?; + hasher.update(&buffer[..count]); + copied = + copied + .checked_add(u64::try_from(count).map_err(|_| { + Error::Invalid("VM image size exceeds supported range".into()) + })?) + .ok_or_else(|| Error::Invalid("VM image size overflow".into()))?; + } + if copied != source_size || hex::encode(hasher.finalize()) != source_hash { + return Err(Error::Invalid( + "VM image changed while it was being copied".into(), + )); + } + staged.as_file().sync_all()?; + staged + .persist(&run.disk) + .map_err(|error| Error::Io(error.error))?; + } + let source_name = image + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Error::Invalid("VM image filename is invalid".into()))?; + write_atomic( + &dir, + &sidecar, + format!("{source_hash} {source_name}\n").as_bytes(), + 0o600, + )?; + File::open(dir)?.sync_all()?; + Ok(()) +} + +fn prepare_key(store: &ManifestStore, run: &VmRun) -> Result { + let key = &run.ssh.identity; + let public = key.with_extension("pub"); + let dir = key + .parent() + .ok_or_else(|| Error::Invalid("SSH key path has no parent".into()))?; + reject_symlinks(dir)?; + if !dir.exists() { + fs::create_dir(dir)?; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + } + store.validate(run, &run.identity)?; + reject_symlinks(dir)?; + reject_symlinks(key)?; + reject_symlinks(&public)?; + match (key.exists(), public.exists()) { + (false, false) => { + let mut command = Command::new("ssh-keygen"); + command.args(["-q", "-t", "ed25519", "-N", ""]); + command.arg("-f").arg(key); + let output = match crate::process::capture(command, Duration::from_secs(30)) { + Ok(output) => output, + Err(error) => { + remove_incomplete_key(key, &public)?; + return Err(error); + } + }; + if !output.status.success() { + remove_incomplete_key(key, &public)?; + return Err(Error::Preparation("ssh-keygen failed".into())); + } + } + (true, true) => (), + _ => { + return Err(Error::Invalid( + "prepared SSH key pair is incomplete; inspect the run before retrying".into(), + )); + } + } + validated_public_key(run) +} + +pub(crate) fn validated_public_key(run: &VmRun) -> Result { + let key = &run.ssh.identity; + let public = key.with_extension("pub"); + reject_symlinks(key)?; + reject_symlinks(&public)?; + let key_metadata = fs::metadata(key)?; + let public_metadata = fs::metadata(&public)?; + if !key_metadata.is_file() || !public_metadata.is_file() { + return Err(Error::Invalid("SSH key pair must be regular files".into())); + } + fs::set_permissions(key, fs::Permissions::from_mode(0o600))?; + let mut command = Command::new("ssh-keygen"); + command.arg("-y").arg("-f").arg(key); + let output = crate::process::capture(command, Duration::from_secs(30))?; + if !output.status.success() { + return Err(Error::Preparation( + "SSH private key could not be validated".into(), + )); + } + let derived = String::from_utf8(output.stdout) + .map_err(|_| Error::Invalid("ssh-keygen returned an invalid public key".into()))?; + let saved = fs::read_to_string(public)?; + let derived = derived.trim(); + let saved = saved.trim(); + if derived.is_empty() || derived != saved { + return Err(Error::Invalid( + "prepared SSH key pair is inconsistent".into(), + )); + } + Ok(derived.to_owned()) +} + +fn remove_incomplete_key(key: &Path, public: &Path) -> Result<()> { + reject_symlinks(key)?; + reject_symlinks(public)?; + if key.exists() { + fs::remove_file(key)?; + } + if public.exists() { + fs::remove_file(public)?; + } + Ok(()) +} + +fn hash_file(file: &mut File) -> Result<(String, u64)> { + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 16 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + size = size + .checked_add( + u64::try_from(count) + .map_err(|_| Error::Invalid("VM image size exceeds supported range".into()))?, + ) + .ok_or_else(|| Error::Invalid("VM image size overflow".into()))?; + } + Ok((hex::encode(hasher.finalize()), size)) +} + +fn write_atomic(dir: &Path, path: &Path, bytes: &[u8], mode: u32) -> Result<()> { + reject_symlinks(path)?; + let mut file = tempfile::NamedTempFile::new_in(dir)?; + file.as_file() + .set_permissions(fs::Permissions::from_mode(mode))?; + file.write_all(bytes)?; + file.as_file().sync_all()?; + file.persist(path).map_err(|error| Error::Io(error.error))?; + Ok(()) +} + +#[cfg(test)] +#[path = "provisioning_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/provisioning_tests.rs b/ublue/skillet/crates/vm/src/provisioning_tests.rs new file mode 100644 index 00000000..93189027 --- /dev/null +++ b/ublue/skillet/crates/vm/src/provisioning_tests.rs @@ -0,0 +1,147 @@ +use super::*; +use crate::{Backend, Connection}; +use std::{fs, os::unix::fs::symlink, path::PathBuf}; + +#[test] +fn ssh_forward_port_preflight_rejects_invalid_and_occupied_ports() { + assert!(validate_ssh_port(2199).is_err()); + assert!(validate_ssh_port(2300).is_err()); + assert!(validate_ssh_port_with(2200, |_| Ok(())).is_ok()); + assert!(validate_ssh_port_with(2200, |_| { + Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "simulated occupied port", + )) + }) + .is_err()); + assert!(validate_ssh_port_with(2199, |_| panic!("invalid port must not bind")).is_err()); +} + +#[test] +fn image_resolution_requires_one_regular_image_or_an_explicit_selection() { + let temp = tempfile::tempdir().unwrap(); + let images = temp.path().join("images"); + fs::create_dir(&images).unwrap(); + let first = images.join("fedora-coreos-44.1-qemu.x86_64.qcow2"); + fs::write(&first, "image").unwrap(); + assert_eq!(resolve_coreos_image(&images, None).unwrap(), first); + + let second = images.join("fedora-coreos-44.2-qemu.x86_64.qcow2"); + fs::write(&second, "image").unwrap(); + assert!(resolve_coreos_image(&images, None).is_err()); + assert_eq!( + resolve_coreos_image(&images, Some(&second)).unwrap(), + second + ); +} + +#[test] +fn image_resolution_rejects_a_matching_symlink() { + let temp = tempfile::tempdir().unwrap(); + let images = temp.path().join("images"); + fs::create_dir(&images).unwrap(); + let target = temp.path().join("image.qcow2"); + fs::write(&target, "image").unwrap(); + symlink(&target, images.join("fedora-coreos-44.1-qemu.x86_64.qcow2")).unwrap(); + assert!(resolve_coreos_image(&images, None).is_err()); +} + +fn preparing_run() -> (tempfile::TempDir, ManifestStore, RunIdentity, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(temp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("fixture", "provisioning").unwrap(); + let runtime_dir = PathBuf::from(format!("/run/user/{}", crate::current_uid())); + store + .prepare_intent( + &identity, + Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir, + }, + 2204, + "test-revision", + ) + .unwrap(); + let image = temp.path().join("source.qcow2"); + fs::write(&image, b"disposable image bytes").unwrap(); + (temp, store, identity, image) +} + +#[test] +fn local_artifacts_are_created_and_matching_retries_are_idempotent() { + let (_temp, store, identity, image) = preparing_run(); + let run = store.load(&identity).unwrap(); + let key = prepare_local_artifacts(&store, &identity, &image).unwrap(); + assert!(key.starts_with("ssh-ed25519 ")); + assert_eq!(fs::read(&run.disk).unwrap(), b"disposable image bytes"); + assert_eq!( + fs::metadata(&run.ssh.identity) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::read_to_string(run.ssh.identity.with_extension("pub")) + .unwrap() + .trim(), + key + ); + assert_eq!( + fs::read_to_string(store.run_dir(&identity).join("fcos-image.sha256")) + .unwrap() + .split_whitespace() + .next() + .unwrap() + .len(), + 64 + ); + let repeated_key = prepare_local_artifacts(&store, &identity, &image).unwrap(); + assert_eq!(repeated_key, key); + assert_eq!(fs::read(&run.disk).unwrap(), b"disposable image bytes"); +} + +#[test] +fn changed_image_replaces_only_the_preparing_run_disk() { + let (_temp, store, identity, image) = preparing_run(); + let run = store.load(&identity).unwrap(); + prepare_local_artifacts(&store, &identity, &image).unwrap(); + fs::write(&image, b"replacement image").unwrap(); + prepare_local_artifacts(&store, &identity, &image).unwrap(); + assert_eq!(fs::read(run.disk).unwrap(), b"replacement image"); +} + +#[test] +fn incomplete_key_pair_and_symlinked_images_are_refused() { + let (_temp, store, identity, image) = preparing_run(); + let run = store.load(&identity).unwrap(); + fs::create_dir(run.ssh.identity.parent().unwrap()).unwrap(); + fs::write(&run.ssh.identity, b"partial").unwrap(); + assert!(prepare_local_artifacts(&store, &identity, &image).is_err()); + + let link = image.with_file_name("linked.qcow2"); + symlink(&image, &link).unwrap(); + assert!(prepare_disk(&store, &run, &link).is_err()); +} + +#[test] +fn artifact_preparation_refuses_progressed_runs() { + let (_temp, store, identity, image) = preparing_run(); + let run = store.load(&identity).unwrap(); + let dir = store.run_dir(&identity); + fs::create_dir_all(run.ssh.identity.parent().unwrap()).unwrap(); + fs::write( + dir.join("skillet.sha256"), + format!("{} host\n", "a".repeat(64)), + ) + .unwrap(); + fs::write( + dir.join("skillet-generic.sha256"), + format!("{} generic\n", "b".repeat(64)), + ) + .unwrap(); + store.mark_started(&identity).unwrap(); + assert!(prepare_local_artifacts(&store, &identity, &image).is_err()); +} diff --git a/ublue/skillet/crates/vm/src/readiness.rs b/ublue/skillet/crates/vm/src/readiness.rs new file mode 100644 index 00000000..620c4910 --- /dev/null +++ b/ublue/skillet/crates/vm/src/readiness.rs @@ -0,0 +1,533 @@ +//! Signed boot and base/user-environment acceptance for retained guest captures. +use crate::{ + delivery::{checked, deliver, sha256}, + manifest::reject_symlinks, + transport::{GuestCommand, GuestTransport, OwnershipCheckedTransport}, + Error, ManifestStore, Phase, Result, VmRun, +}; +use std::{ + fs, + io::Write as _, + os::unix::fs::PermissionsExt as _, + path::Path, + time::{Duration, Instant}, +}; + +/// Expectations belong to the selected profile, not the VM's mutable hostname. +pub struct ReadinessPolicy { + pub signed_image: String, + pub resolver_target: String, + pub masked_units: Vec, + pub phase_timeout: Duration, +} + +pub trait WaitClock { + fn elapsed(&self) -> Duration; + fn sleep(&self, duration: Duration); +} + +pub struct MonotonicClock(Instant); +impl Default for MonotonicClock { + fn default() -> Self { + Self(Instant::now()) + } +} +impl WaitClock for MonotonicClock { + fn elapsed(&self) -> Duration { + self.0.elapsed() + } + fn sleep(&self, duration: Duration) { + std::thread::sleep(duration); + } +} + +/// Probe transport uses a short deadline; installation may take several minutes. +pub struct ReadinessIo<'a, T> { + pub probe: &'a T, + pub operations: &'a T, + pub ownership: &'a dyn Fn() -> Result<()>, +} + +pub fn ready( + run: &mut VmRun, + store: &ManifestStore, + run_dir: &Path, + policy: &ReadinessPolicy, + io: &ReadinessIo<'_, impl GuestTransport>, + clock: &impl WaitClock, + report: &mut impl FnMut(&str), +) -> Result<()> { + if !matches!(run.phase, Phase::Started | Phase::Ready) { + return Err(Error::Invalid("VM is not available for readiness".into())); + } + if policy.phase_timeout.is_zero() { + return Err(Error::Invalid("readiness timeout must be positive".into())); + } + let host = run_dir.join(format!("source/files/skillet-{}", run.identity.host())); + let generic = run_dir.join("source/files/skillet"); + let capture = run + .captured + .as_ref() + .ok_or_else(|| Error::Invalid("creation artifact hashes are absent".into()))?; + if sha256(&host)? != capture.host || sha256(&generic)? != capture.generic { + return Err(Error::Invalid( + "captured artifact bytes have changed".into(), + )); + } + (io.ownership)()?; + // Readiness can replace delivered executables and restart services. Mark + // the old success stale before the first guest operation so a failed retry + // cannot leave a Ready manifest behind. + run.phase = Phase::Started; + store.save(run)?; + let probe = OwnershipCheckedTransport::new(io.probe, io.ownership); + let operations = OwnershipCheckedTransport::new(io.operations, io.ownership); + let result = (|| { + wait(run, policy, &probe, io.ownership, clock, report, false)?; + (io.ownership)()?; + report("Installing captured Skillet binaries"); + deliver(run, &operations, &host, &generic)?; + wait(run, policy, &probe, io.ownership, clock, report, true)?; + (io.ownership)()?; + expect( + &operations, + "cat", + &["/etc/skillet/host"], + run.identity.host(), + )?; + let status = checked( + &operations, + "sudo", + &["-n", "rpm-ostree", "status", "--json"], + )?; + verify_signed_origin(&status, &policy.signed_image)?; + report("Applying the base profile and user environment"); + let transport = &operations; + for arguments in [ + vec!["reset-failed", "skillet-apply.service"], + vec!["restart", "skillet-apply.service"], + vec![ + "reset-failed", + "brew-install.service", + "dotfiles-install.service", + ], + vec!["start", "brew-install.service"], + vec!["start", "dotfiles-install.service"], + ] { + let mut command = vec!["-n", "systemctl"]; + command.extend(arguments); + checked(transport, "sudo", &command)?; + } + verify(run, run_dir, policy, transport)?; + verify_root_profile(run, transport) + })(); + if result.is_err() && (io.ownership)().is_ok() { + // Diagnostics are best effort; never replace the original failure. + let _ = diagnostics(&probe, run_dir); + } + result?; + (io.ownership)()?; + run.phase = Phase::Ready; + run.deployed.clone_from(&run.captured); + store.save(run) +} + +fn verify_root_profile(run: &VmRun, transport: &impl GuestTransport) -> Result<()> { + if run.root_profile == crate::install::RootProfile::Unencrypted { + return Ok(()); + } + let secure = checked(transport, "sudo", &["-n", "mokutil", "--sb-state"])?; + let status = checked(transport, "sudo", &["-n", "cryptsetup", "status", "root"])?; + let source = checked(transport, "findmnt", &["-T", "/var", "-n", "-o", "SOURCE"])?; + let binding = checked( + transport, + "sudo", + &[ + "-n", + "clevis", + "luks", + "list", + "-d", + "/dev/disk/by-partlabel/root", + ], + )?; + let secure = String::from_utf8_lossy(&secure); + let status = String::from_utf8_lossy(&status); + let source = String::from_utf8_lossy(&source); + let binding = String::from_utf8_lossy(&binding); + if !secure.lines().any(|line| line == "SecureBoot enabled") + || !status.contains("LUKS2") + || !source.trim().starts_with("/dev/mapper/root") + { + return Err(Error::Invalid( + "encrypted readiness requires active Secure Boot and mapper-backed LUKS2 root".into(), + )); + } + let entries: Vec<_> = binding + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + if entries.len() != 1 { + return Err(Error::Invalid( + "root must have exactly one Clevis TPM binding".into(), + )); + } + let (_, policy) = entries[0] + .split_once("tpm2 ") + .ok_or_else(|| Error::Invalid("unexpected root unlock pin".into()))?; + let policy: serde_json::Value = serde_json::from_str(policy.trim().trim_matches('\''))?; + if policy["pcr_bank"] != "sha256" || policy["pcr_ids"] != "7" { + return Err(Error::Invalid( + "root unlock policy is not the selected SHA256 PCR7 policy".into(), + )); + } + Ok(()) +} + +fn wait( + run: &VmRun, + policy: &ReadinessPolicy, + transport: &impl GuestTransport, + ownership: &dyn Fn() -> Result<()>, + clock: &impl WaitClock, + report: &mut impl FnMut(&str), + signed: bool, +) -> Result<()> { + let phase = if signed { "signed boot" } else { "SSH" }; + report(&format!( + "Waiting for {phase} on {}", + run.identity.domain_name() + )); + let start = clock.elapsed(); + let mut next_report = Duration::from_mins(1); + loop { + // The owner may have disappeared even when a failed SSH probe was an + // expected condition. Recheck before another probe or a backoff sleep. + ownership()?; + let elapsed = clock.elapsed().saturating_sub(start); + if elapsed >= policy.phase_timeout { + return Err(Error::Invalid(format!("timed out waiting for {phase}"))); + } + let arguments: &[&str] = if signed { + &["-n", "test", "-e", "/run/ucore-bootstrap-ready"] + } else { + &["-n", "true"] + }; + if succeeds(transport, "sudo", arguments) { + return Ok(()); + } + if signed + && succeeds( + transport, + "sudo", + &[ + "-n", + "systemctl", + "is-failed", + "--quiet", + "ucore-bootstrap.service", + ], + ) + { + return Err(Error::Invalid("signed bootstrap service failed".into())); + } + if elapsed >= next_report { + report(&format!( + "Still waiting for {phase}; {} seconds elapsed", + elapsed.as_secs() + )); + next_report = elapsed + Duration::from_mins(1); + } + clock.sleep( + Duration::from_secs(5).min( + policy + .phase_timeout + .saturating_sub(clock.elapsed().saturating_sub(start)), + ), + ); + } +} + +fn succeeds(transport: &impl GuestTransport, program: &str, arguments: &[&str]) -> bool { + transport + .execute(&GuestCommand { program, arguments }, None) + .is_ok_and(|output| output.status.success()) +} + +fn expect( + transport: &impl GuestTransport, + program: &str, + args: &[&str], + expected: &str, +) -> Result<()> { + let output = checked(transport, program, args)?; + if std::str::from_utf8(&output).ok().map(str::trim) != Some(expected) { + return Err(Error::Invalid(format!( + "guest {program} readiness assertion failed" + ))); + } + Ok(()) +} + +fn verify( + run: &VmRun, + run_dir: &Path, + policy: &ReadinessPolicy, + transport: &impl GuestTransport, +) -> Result<()> { + let status = checked(transport, "sudo", &["-n", "rpm-ostree", "status", "--json"])?; + save(run_dir, "final-status.json", &status)?; + verify_signed_origin(&status, &policy.signed_image)?; + let captured = run + .captured + .as_ref() + .ok_or_else(|| Error::Invalid("creation artifact hashes are absent".into()))?; + for (path, hash) in [ + ( + format!("/var/usrlocal/bin/skillet-{}", run.identity.host()), + &captured.host, + ), + ("/var/usrlocal/bin/skillet".into(), &captured.generic), + ] { + if !crate::delivery::matches_installed(transport, &path, hash)? { + return Err(Error::Invalid( + "installed creation artifact differs after apply".into(), + )); + } + } + for (name, program, args) in [ + ( + "final-boot-id", + "cat", + vec!["/proc/sys/kernel/random/boot_id"], + ), + ("guest-skillet.sha256", "sha256sum", vec![]), + ( + "guest-skillet-generic.sha256", + "sha256sum", + vec!["/var/usrlocal/bin/skillet"], + ), + ] { + let host_path = format!("/var/usrlocal/bin/skillet-{}", run.identity.host()); + let args = if args.is_empty() { + vec![host_path.as_str()] + } else { + args + }; + save(run_dir, name, &checked(transport, program, &args)?)?; + } + expect( + transport, + "cat", + &["/etc/skillet/host"], + run.identity.host(), + )?; + expect(transport, "getenforce", &[], "Enforcing")?; + expect( + transport, + "readlink", + &["-f", "/etc/resolv.conf"], + &policy.resolver_target, + )?; + checked(transport, "getent", &["hosts", "ghcr.io"])?; + for unit in [ + "skillet-apply.service", + "brew-install.service", + "dotfiles-install.service", + ] { + expect( + transport, + "systemctl", + &["show", "-p", "Result", "--value", unit], + "success", + )?; + expect( + transport, + "systemctl", + &["show", "-p", "ExecMainStatus", "--value", unit], + "0", + )?; + } + let definition = checked(transport, "systemctl", &["cat", "skillet-apply.service"])?; + if !String::from_utf8_lossy(&definition).lines().any(|line| { + line == "ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base" + }) { + return Err(Error::Invalid("base unit does not use the stable host profile".into())); + } + for unit in &policy.masked_units { + // systemctl is-enabled returns nonzero for a correctly masked unit. + let output = transport.execute( + &GuestCommand { + program: "systemctl", + arguments: &["is-enabled", unit], + }, + None, + )?; + if String::from_utf8_lossy(&output.stdout).trim() != "masked" { + return Err(Error::Invalid("required unit is not masked".into())); + } + } + verify_user_environment(transport)?; + diagnostics(transport, run_dir)?; + Ok(()) +} + +fn verify_user_environment(transport: &impl GuestTransport) -> Result<()> { + for path in [ + "/var/lib/ucore-bootstrap/brew-installed", + "/var/lib/ucore-bootstrap/dotfiles-installed", + ] { + checked(transport, "test", &["-e", path])?; + } + for (link, source) in [ + ( + "/home/giacomo/.config/Brewfile", + "/home/giacomo/.local/src/dotfiles/brew/Brewfile.core", + ), + ( + "/home/giacomo/.ssh/authorized_keys", + "/home/giacomo/.local/src/dotfiles/ssh/authorized_keys", + ), + ] { + let expected = checked(transport, "readlink", &["-f", source])?; + let actual = checked(transport, "readlink", &["-f", link])?; + if expected != actual || expected.is_empty() { + return Err(Error::Invalid( + "user environment link target differs".into(), + )); + } + } + checked( + transport, + "/home/linuxbrew/.linuxbrew/bin/brew", + &[ + "bundle", + "check", + "--file", + "/home/giacomo/.local/src/dotfiles/brew/Brewfile.core", + ], + )?; + Ok(()) +} + +pub fn verify_signed_origin(status: &[u8], expected: &str) -> Result<()> { + let status: serde_json::Value = serde_json::from_slice(status)?; + let deployments = status["deployments"] + .as_array() + .ok_or_else(|| Error::Invalid("rpm-ostree deployments are absent".into()))?; + let booted: Vec<_> = deployments + .iter() + .filter(|entry| entry["booted"] == true) + .collect(); + if booted.len() != 1 { + return Err(Error::Invalid( + "expected exactly one booted deployment".into(), + )); + } + let origin = booted[0]["container-image-reference"] + .as_str() + .ok_or_else(|| Error::Invalid("booted image reference is absent".into()))?; + let signed = format!("ostree-image-signed:docker://{expected}"); + let digest = origin.strip_prefix(&format!("{signed}@sha256:")); + if origin != format!("{signed}:latest") + && !digest.is_some_and(|hash| { + hash.len() == 64 + && hash + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + }) + { + return Err(Error::Invalid( + "booted deployment is not the expected signed origin".into(), + )); + } + Ok(()) +} + +fn diagnostics(transport: &impl GuestTransport, run_dir: &Path) -> Result<()> { + let mut log = Vec::new(); + for arguments in [ + vec!["-n", "rpm-ostree", "status"], + vec![ + "-n", + "journalctl", + "-b", + "-u", + "ucore-bootstrap.service", + "-u", + "skillet-apply.service", + "-u", + "brew-install.service", + "-u", + "dotfiles-install.service", + "--no-pager", + "-n", + "200", + ], + ] { + capture_diagnostic(transport, "sudo", &arguments, &mut log); + } + for (program, arguments) in [ + ("getenforce", vec![]), + ("readlink", vec!["-f", "/etc/resolv.conf"]), + ("cat", vec!["/etc/resolv.conf"]), + ("getent", vec!["hosts", "ghcr.io"]), + ( + "systemctl", + vec!["status", "--no-pager", "skillet-apply.service"], + ), + ] { + capture_diagnostic(transport, program, &arguments, &mut log); + } + save(run_dir, "readiness.log", &log)?; + let mut user_log = Vec::new(); + for unit in ["brew-install.service", "dotfiles-install.service"] { + for property in ["Result", "ExecMainStatus"] { + capture_diagnostic( + transport, + "systemctl", + &["show", "-p", property, "--value", unit], + &mut user_log, + ); + } + } + save(run_dir, "user-environment.log", &user_log) +} + +fn capture_diagnostic( + transport: &impl GuestTransport, + program: &str, + args: &[&str], + log: &mut Vec, +) { + log.extend(format!("{program} {args:?}\n").bytes()); + match transport.execute( + &GuestCommand { + program, + arguments: args, + }, + None, + ) { + Ok(output) => { + log.extend(output.stdout); + log.extend(output.stderr); + } + Err(_) => log.extend(b"Guest diagnostic command unavailable\n"), + } +} + +fn save(run_dir: &Path, name: &str, bytes: &[u8]) -> Result<()> { + let path = run_dir.join(name); + reject_symlinks(&path)?; + let mut tmp = tempfile::NamedTempFile::new_in(run_dir)?; + tmp.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + tmp.write_all(bytes)?; + tmp.as_file().sync_all()?; + tmp.persist(path).map_err(|error| Error::Io(error.error))?; + Ok(()) +} + +#[cfg(test)] +#[path = "readiness/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/readiness/tests.rs b/ublue/skillet/crates/vm/src/readiness/tests.rs new file mode 100644 index 00000000..0b2ac3d7 --- /dev/null +++ b/ublue/skillet/crates/vm/src/readiness/tests.rs @@ -0,0 +1,478 @@ +use super::*; +use crate::manifest::{tests::legacy_run, ArtifactHashes}; +use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + os::unix::process::ExitStatusExt as _, + process::{ExitStatus, Output}, +}; + +#[derive(Default)] +struct FakeClock(Cell); +impl WaitClock for FakeClock { + fn elapsed(&self) -> Duration { + self.0.get() + } + fn sleep(&self, duration: Duration) { + self.0.set(self.0.get() + duration); + } +} + +#[allow(clippy::struct_excessive_bools)] // Independent failures exercised at each stage. +struct FakeGuest { + files: RefCell>, + calls: RefCell>, + no_ssh: bool, + no_signed: bool, + failed_bootstrap: bool, + fail_unit: bool, + profile: &'static str, + origin: String, + fault: &'static str, +} +impl Default for FakeGuest { + fn default() -> Self { + Self { + files: RefCell::default(), + calls: RefCell::default(), + no_ssh: false, + no_signed: false, + failed_bootstrap: false, + fail_unit: false, + profile: "fixture", + origin: "ostree-image-signed:docker://registry/fixture:latest".into(), + fault: "none", + } + } +} + +fn output(code: i32, stdout: impl Into>) -> Output { + Output { + status: ExitStatus::from_raw(code << 8), + stdout: stdout.into(), + stderr: Vec::new(), + } +} + +impl GuestTransport for FakeGuest { + fn execute(&self, command: &GuestCommand<'_>, _: Option<&[u8]>) -> Result { + self.calls + .borrow_mut() + .push(format!("{} {:?}", command.program, command.arguments)); + let (program, args) = if command.program == "sudo" { + if self.no_ssh { + return Ok(output(255, "")); + } + (&command.arguments[1], &command.arguments[2..]) + } else { + (&command.program, command.arguments) + }; + match (self.fault, *program, args) { + ("selinux", "getenforce", []) => return Ok(output(0, "Permissive\n")), + ("resolver", "readlink", ["-f", "/etc/resolv.conf"]) => { + return Ok(output(0, "/wrong/resolver\n")) + } + ("result", "systemctl", ["show", "-p", "Result", ..]) => { + return Ok(output(0, "exit-code\n")) + } + ("mask", "systemctl", ["is-enabled", ..]) => return Ok(output(0, "enabled\n")), + ("brew", "/home/linuxbrew/.linuxbrew/bin/brew", _) => return Ok(output(1, "")), + ("link", "readlink", ["-f", "/home/giacomo/.config/Brewfile"]) => { + return Ok(output(0, "/wrong/Brewfile\n")) + } + ("artifact", "systemctl", ["restart", "skillet-apply.service"]) => { + self.files + .borrow_mut() + .insert("/var/usrlocal/bin/skillet".into(), "bad".into()); + } + _ => {} + } + let stdout = match (*program, args) { + ("test", ["-e", "/run/ucore-bootstrap-ready"]) => return Ok(output(i32::from(self.no_signed), "")), + ("systemctl", ["is-failed", "--quiet", "ucore-bootstrap.service"]) => return Ok(output(i32::from(!self.failed_bootstrap), "")), + ("test", ["-L", _]) => return Ok(output(1, "")), + ("test", ["-e" | "-f", path]) if path.starts_with("/var/usrlocal/") => { + return Ok(output(i32::from(!self.files.borrow().contains_key(*path)), "")); + } + ("test", ["-e", path]) if path.starts_with("/var/lib/ucore-bootstrap/") => String::new(), + ("install", ["-m", "0755", source, destination]) => { + let hash = self.files.borrow().get(*source).cloned().ok_or_else(|| Error::Invalid("unstaged fake file".into()))?; + self.files.borrow_mut().insert((*destination).into(), hash); + String::new() + } + ("sha256sum", [path]) => { + let hash = self.files.borrow().get(*path).cloned().ok_or_else(|| Error::Invalid("missing fake file".into()))?; + format!("{hash} {path}\n") + } + ("stat", ["--format=%a:%u:%g", path]) if self.files.borrow().contains_key(*path) => "755:0:0\n".into(), + ("rm", ["-f", paths @ ..]) => { for path in paths { self.files.borrow_mut().remove(*path); } String::new() } + ("systemctl", ["restart" | "start" | "reset-failed", _rest @ ..]) => { + return Ok(output(i32::from(self.fail_unit), "")); + } + ("mokutil", ["--sb-state"]) => if self.fault == "secure-boot" { "SecureBoot disabled\n" } else { "SecureBoot enabled\n" }.into(), + ("cryptsetup", ["status", "root"]) => "type: LUKS2\n".into(), + ("findmnt", ["-T", "/var", "-n", "-o", "SOURCE"]) => "/dev/mapper/root[/ostree/deploy/default/var]\n".into(), + ("clevis", ["luks", "list", "-d", "/dev/disk/by-partlabel/root"]) => if self.fault == "pcr" { "0: tpm2 '{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"0\"}'\n" } else { "0: tpm2 '{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"7\"}'\n" }.into(), + ("rpm-ostree", ["status", "--json"]) => serde_json::json!({"deployments":[{"booted":true,"container-image-reference":self.origin}]}).to_string(), + ("rpm-ostree", ["status"]) => "Signed deployment\n".into(), + ("journalctl", _) => "Sanitized fixture journal\n".into(), + ("cat", ["/proc/sys/kernel/random/boot_id"]) => "fixture-boot\n".into(), + ("cat", ["/etc/skillet/host"]) => format!("{}\n", self.profile), + ("getenforce", []) => "Enforcing\n".into(), + ("readlink", ["-f", "/etc/resolv.conf"]) => "/run/NetworkManager/resolv.conf\n".into(), + ("readlink", ["-f", path]) if path.contains("Brewfile") => "/home/giacomo/.local/src/dotfiles/brew/Brewfile.core\n".into(), + ("readlink", ["-f", path]) if path.contains("authorized_keys") => "/home/giacomo/.local/src/dotfiles/ssh/authorized_keys\n".into(), + ("getent", ["hosts", "ghcr.io"]) => "192.0.2.1 ghcr.io\n".into(), + ("systemctl", ["show", "-p", "Result", "--value", _]) => "success\n".into(), + ("systemctl", ["show", "-p", "ExecMainStatus", "--value", _]) => "0\n".into(), + ("systemctl", ["cat", "skillet-apply.service"]) => "ExecStart=/var/usrlocal/bin/skillet apply --host-file /etc/skillet/host --phase base\n".into(), + ("systemctl", ["status", "--no-pager", "skillet-apply.service"]) => "Active: active (exited)\n".into(), + ("cat", ["/etc/resolv.conf"]) => "nameserver 192.0.2.53\n".into(), + ("systemctl", ["is-enabled", "fixture-masked.service"]) => return Ok(output(1, "masked\n")), + ("true", []) | ("/home/linuxbrew/.linuxbrew/bin/brew", ["bundle", "check", "--file", _]) => String::new(), + _ => return Err(Error::Invalid(format!("unsupported fake operation: {program} {args:?}"))), + }; + Ok(output(0, stdout)) + } + fn upload(&self, source: &Path, destination: &str) -> Result<()> { + self.files + .borrow_mut() + .insert(destination.into(), sha256(source)?); + self.calls.borrow_mut().push("upload".into()); + Ok(()) + } +} + +fn fixture() -> ( + tempfile::TempDir, + crate::ManifestStore, + VmRun, + ReadinessPolicy, +) { + let (tmp, store, identity) = legacy_run(); + let dir = store.run_dir(&identity).join("source/files"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("skillet-fixture"), "captured host").unwrap(); + fs::write(dir.join("skillet"), "captured generic").unwrap(); + let mut run = store.import(&identity).unwrap(); + run.captured = Some(ArtifactHashes { + host: sha256(&dir.join("skillet-fixture")).unwrap(), + generic: sha256(&dir.join("skillet")).unwrap(), + }); + store.save(&run).unwrap(); + let policy = ReadinessPolicy { + signed_image: "registry/fixture".into(), + resolver_target: "/run/NetworkManager/resolv.conf".into(), + masked_units: vec!["fixture-masked.service".into()], + phase_timeout: Duration::from_secs(20), + }; + (tmp, store, run, policy) +} + +#[test] +fn selected_profile_passes_signed_boot_delivery_and_user_environment_checks() { + let (_tmp, store, mut run, policy) = fixture(); + let guest = FakeGuest::default(); + let dir = store.run_dir(&run.identity); + let clock = FakeClock::default(); + let mut messages = Vec::new(); + ready( + &mut run, + &store, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &clock, + &mut |message| messages.push(message.to_owned()), + ) + .unwrap(); + assert_eq!(run.phase, Phase::Ready); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Ready); + assert!(messages + .iter() + .any(|message| message.contains("fixture-test"))); + assert_eq!( + fs::metadata(dir.join("readiness.log")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert!(dir.join("final-status.json").is_file()); + let calls = guest.calls.borrow(); + assert!(calls + .iter() + .any(|call| call.contains("brew") && call.contains("bundle"))); + assert!(!calls.iter().any(|call| call.contains("--wait"))); + drop(calls); + guest.calls.borrow_mut().clear(); + ready( + &mut run, + &store, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &clock, + &mut |_| {}, + ) + .unwrap(); + assert!(!guest.calls.borrow().iter().any(|call| call == "upload")); +} + +#[test] +fn ssh_and_signed_boot_timeouts_are_bounded_and_leave_private_diagnostics() { + for signed in [false, true] { + let (_tmp, store, mut run, policy) = fixture(); + run.phase = Phase::Ready; + store.save(&run).unwrap(); + let guest = FakeGuest { + no_ssh: !signed, + no_signed: signed, + ..FakeGuest::default() + }; + let clock = FakeClock::default(); + let dir = store.run_dir(&run.identity); + assert!(ready( + &mut run, + &store, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &clock, + &mut |_| {} + ) + .is_err()); + assert_eq!(clock.elapsed(), policy.phase_timeout); + assert!(dir.join("readiness.log").is_file()); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Started); + } +} + +#[test] +fn bootstrap_apply_or_profile_failure_never_claims_ready() { + for guest in [ + FakeGuest { + no_signed: true, + failed_bootstrap: true, + ..FakeGuest::default() + }, + FakeGuest { + fail_unit: true, + ..FakeGuest::default() + }, + FakeGuest { + profile: "unrelated", + ..FakeGuest::default() + }, + ] { + let (_tmp, store, mut run, policy) = fixture(); + run.phase = Phase::Ready; + store.save(&run).unwrap(); + let dir = store.run_dir(&run.identity); + assert!(ready( + &mut run, + &store, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &FakeClock::default(), + &mut |_| {} + ) + .is_err()); + assert!(dir.join("readiness.log").is_file()); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Started); + } +} + +#[test] +fn changed_capture_or_cleanup_phase_refuses_all_guest_effects() { + for closing in [false, true] { + let (_tmp, store, mut run, policy) = fixture(); + if closing { + run.phase = Phase::DomainRemoved; + } else { + fs::write( + store.run_dir(&run.identity).join("source/files/skillet"), + "tampered", + ) + .unwrap(); + } + let guest = FakeGuest::default(); + let dir = store.run_dir(&run.identity); + assert!(ready( + &mut run, + &store, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &FakeClock::default(), + &mut |_| {} + ) + .is_err()); + assert!(guest.calls.borrow().is_empty()); + } +} + +#[test] +fn signed_origin_requires_one_booted_expected_signed_image_or_digest() { + let expected = "registry/fixture"; + for origin in [ + "ostree-image-signed:docker://registry/fixture:latest".into(), + format!( + "ostree-image-signed:docker://registry/fixture@sha256:{}", + "a".repeat(64) + ), + ] { + let status = + serde_json::json!({"deployments":[{"booted":true,"container-image-reference":origin}]}) + .to_string(); + verify_signed_origin(status.as_bytes(), expected).unwrap(); + } + for status in [ + serde_json::json!({"deployments":[]}), + serde_json::json!({"deployments":[{"booted":true,"container-image-reference":"ostree-unverified-registry:registry/fixture:latest"}]}), + serde_json::json!({"deployments":[{"booted":true,"container-image-reference":"ostree-image-signed:docker://registry/other:latest"}]}), + serde_json::json!({"deployments":[{"booted":true},{"booted":true}]}), + ] { + assert!(verify_signed_origin(status.to_string().as_bytes(), expected).is_err()); + } +} + +#[test] +fn guest_security_service_links_brew_and_artifact_assertions_fail_closed() { + for fault in [ + "selinux", "resolver", "result", "mask", "brew", "link", "artifact", + ] { + let (_tmp, store, mut run, policy) = fixture(); + let guest = FakeGuest { + fault, + ..FakeGuest::default() + }; + let dir = store.run_dir(&run.identity); + assert!( + ready( + &mut run, + &store, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &|| Ok(()), + }, + &FakeClock::default(), + &mut |_| {} + ) + .is_err(), + "{fault} must fail" + ); + assert!(dir.join("readiness.log").is_file()); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Started); + } +} + +#[test] +fn ownership_loss_after_wait_refuses_delivery_and_contact_for_diagnostics() { + let (_tmp, store, mut run, policy) = fixture(); + let guest = FakeGuest::default(); + let checks = Cell::new(0); + let ownership = || { + checks.set(checks.get() + 1); + if checks.get() <= 3 { + Ok(()) + } else { + Err(Error::Invalid("domain changed".into())) + } + }; + let dir = store.run_dir(&run.identity); + assert!(ready( + &mut run, + &store, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &ownership, + }, + &FakeClock::default(), + &mut |_| {} + ) + .is_err()); + assert_eq!(guest.calls.borrow().len(), 1); // Only the initial SSH probe. + assert!(!dir.join("readiness.log").exists()); +} + +#[test] +fn ownership_loss_after_long_brew_operation_stops_before_dotfiles_start() { + let (_tmp, store, mut run, policy) = fixture(); + let guest = FakeGuest::default(); + let ownership = || { + let brew_started = guest.calls.borrow().iter().any(|call| { + call == "sudo [\"-n\", \"systemctl\", \"start\", \"brew-install.service\"]" + }); + if brew_started { + Err(Error::Invalid( + "owned domain changed during Brew install".into(), + )) + } else { + Ok(()) + } + }; + let dir = store.run_dir(&run.identity); + assert!(ready( + &mut run, + &store, + &dir, + &policy, + &ReadinessIo { + probe: &guest, + operations: &guest, + ownership: &ownership, + }, + &FakeClock::default(), + &mut |_| {}, + ) + .is_err()); + assert!(guest.calls.borrow().iter().any(|call| { + call == "sudo [\"-n\", \"systemctl\", \"start\", \"brew-install.service\"]" + })); + assert!(!guest.calls.borrow().iter().any(|call| { + call == "sudo [\"-n\", \"systemctl\", \"start\", \"dotfiles-install.service\"]" + })); + assert_eq!(store.load(&run.identity).unwrap().phase, Phase::Started); +} + +#[test] +fn encrypted_profile_requires_secure_boot_and_selected_pcr_policy() { + for fault in ["none", "secure-boot", "pcr"] { + let (_tmp, _store, mut run, _policy) = fixture(); + run.root_profile = crate::install::RootProfile::Tpm; + let guest = FakeGuest { + fault, + ..FakeGuest::default() + }; + assert_eq!(verify_root_profile(&run, &guest).is_ok(), fault == "none"); + } +} diff --git a/ublue/skillet/crates/vm/src/runtime.rs b/ublue/skillet/crates/vm/src/runtime.rs new file mode 100644 index 00000000..67bf9950 --- /dev/null +++ b/ublue/skillet/crates/vm/src/runtime.rs @@ -0,0 +1,371 @@ +//! Workstation-side selection and startup of the local libvirt runtime. +use crate::{ + backend::{ProcessExecutor, VirshBackend}, + Backend, Connection, Error, Result, +}; +use std::{ + env, fs, + fs::OpenOptions, + os::unix::{ + fs::{FileTypeExt as _, MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}, + process::CommandExt as _, + }, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +const URI: &str = "qemu:///session"; +const READY_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug)] +pub struct SelectedRuntime { + pub connection: Connection, + pub virsh_wrapper: PathBuf, + pub virt_install_wrapper: PathBuf, +} + +/// Select a supported backend and make its user-session daemons available. +/// Native libvirt is preferred when its focused tools are present; otherwise +/// use the already installed Flatpak virt-manager and its QEMU extension. +pub fn prepare(butane_dir: &Path, owner_uid: u32) -> Result { + validate_kvm_access()?; + let user_runtime = standard_runtime_dir(owner_uid); + validate_user_runtime_dir(&user_runtime, owner_uid)?; + let virsh_wrapper = butane_dir.join("bin/virsh"); + let virt_install_wrapper = butane_dir.join("bin/virt-install"); + if native_tools_available() { + let standard = native_connection(standard_runtime_dir(owner_uid)); + if ensure_connection(butane_dir, owner_uid, &standard).is_ok() { + return Ok(SelectedRuntime { + connection: standard, + virsh_wrapper, + virt_install_wrapper, + }); + } + + let isolated_dir = standard_runtime_dir(owner_uid).join("skillet-test-libvirt"); + let isolated = native_connection(isolated_dir); + ensure_connection(butane_dir, owner_uid, &isolated)?; + return Ok(SelectedRuntime { + connection: isolated, + virsh_wrapper, + virt_install_wrapper, + }); + } + + let runtime_dir = standard_runtime_dir(owner_uid).join("skvm"); + let connection = Connection { + backend: Backend::Flatpak, + uri: URI.into(), + runtime_dir: runtime_dir.clone(), + }; + ensure_connection(butane_dir, owner_uid, &connection)?; + Ok(SelectedRuntime { + connection, + virsh_wrapper, + virt_install_wrapper, + }) +} + +/// Check the same host virtualization prerequisite before image downloads or +/// artifact builds begin. +pub fn validate_kvm_access() -> Result<()> { + OpenOptions::new() + .read(true) + .write(true) + .open("/dev/kvm") + .map_err(|error| Error::Invalid(format!("/dev/kvm is unavailable: {error}")))?; + Ok(()) +} + +/// Start and verify the recorded backend for an existing run without changing +/// its selected URI or runtime path. +pub fn ensure_connection(butane_dir: &Path, owner_uid: u32, connection: &Connection) -> Result<()> { + validate_user_runtime_dir(&standard_runtime_dir(owner_uid), owner_uid)?; + connection.validate(owner_uid)?; + let virsh_wrapper = butane_dir.join("bin/virsh"); + let backend = VirshBackend::new( + connection.clone(), + owner_uid, + &virsh_wrapper, + ProcessExecutor::default(), + )?; + match connection.backend { + Backend::Native => { + if !native_tools_available() { + return Err(Error::Invalid( + "native libvirt tools are unavailable".into(), + )); + } + let standard = standard_runtime_dir(owner_uid); + let isolated = standard.join("skillet-test-libvirt"); + if connection.runtime_dir == standard { + return backend.probe(); + } + if connection.runtime_dir != isolated { + return Err(Error::Invalid( + "native VM uses an unsupported runtime directory".into(), + )); + } + ensure_private_runtime_dir(&isolated, owner_uid)?; + start_native_daemons(&isolated)?; + backend.probe() + } + Backend::Flatpak => { + ensure_flatpak_installed()?; + let runtime_dir = standard_runtime_dir(owner_uid).join("skvm"); + if connection.runtime_dir != runtime_dir { + return Err(Error::Invalid( + "Flatpak VM uses an unsupported runtime directory".into(), + )); + } + ensure_private_runtime_dir(&runtime_dir, owner_uid)?; + let flatpak_runner = butane_dir.join("bin/flatpak-virt"); + let qemu_socket = runtime_socket(&runtime_dir, "virtqemud-sock"); + let storage_socket = runtime_socket(&runtime_dir, "virtstoraged-sock"); + let qemu_ready = + fs::metadata(qemu_socket).is_ok_and(|metadata| metadata.file_type().is_socket()); + let storage_ready = + fs::metadata(storage_socket).is_ok_and(|metadata| metadata.file_type().is_socket()); + match (qemu_ready, storage_ready) { + (true, true) => backend.probe()?, + (false, false) => { + start_flatpak_daemons(&runtime_dir, &flatpak_runner)?; + backend.probe()?; + } + _ => { + return Err(Error::Invalid( + "Flatpak libvirt runtime has only some expected sockets; inspect its retained log" + .into(), + )); + } + } + Ok(()) + } + } +} + +fn standard_runtime_dir(owner_uid: u32) -> PathBuf { + PathBuf::from(format!("/run/user/{owner_uid}")) +} + +fn native_connection(runtime_dir: PathBuf) -> Connection { + Connection { + backend: Backend::Native, + uri: URI.into(), + runtime_dir, + } +} + +fn native_tools_available() -> bool { + fs::metadata("/usr/bin/virsh") + .is_ok_and(|metadata| metadata.is_file() && metadata.mode() & 0o111 != 0) + && ["virtqemud", "virtstoraged", "passt"] + .iter() + .all(|tool| find_executable(tool).is_some()) +} + +fn find_executable(name: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|directory| directory.join(name)) + .find(|candidate| { + fs::metadata(candidate) + .is_ok_and(|metadata| metadata.is_file() && metadata.mode() & 0o111 != 0) + }) +} + +fn ensure_flatpak_installed() -> Result<()> { + for app in [ + "org.virt_manager.virt-manager", + "org.virt_manager.virt_manager.Extension.Qemu", + ] { + let mut command = Command::new("flatpak"); + command.args(["info", app]); + let output = crate::process::capture(command, Duration::from_secs(20))?; + if !output.status.success() { + return Err(Error::Invalid(format!( + "required Flatpak application is unavailable: {app}" + ))); + } + } + Ok(()) +} + +fn ensure_private_runtime_dir(path: &Path, owner_uid: u32) -> Result<()> { + if !path.is_absolute() + || path + .components() + .any(|part| part == std::path::Component::ParentDir) + { + return Err(Error::Invalid("invalid libvirt runtime directory".into())); + } + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.uid() != owner_uid + { + return Err(Error::Invalid( + "libvirt runtime directory is not a user-owned directory".into(), + )); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Err(error) => return Err(error.into()), + } + Ok(()) +} + +fn validate_user_runtime_dir(path: &Path, owner_uid: u32) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || metadata.uid() != owner_uid + || metadata.permissions().mode() & 0o077 != 0 + { + return Err(Error::Invalid( + "user runtime directory is not a private directory owned by this user".into(), + )); + } + Ok(()) +} + +fn start_native_daemons(runtime_dir: &Path) -> Result<()> { + let sockets = [ + runtime_socket(runtime_dir, "virtqemud-sock"), + runtime_socket(runtime_dir, "virtstoraged-sock"), + ]; + let qemu_socket = + fs::metadata(&sockets[0]).is_ok_and(|metadata| metadata.file_type().is_socket()); + let storage_socket = + fs::metadata(&sockets[1]).is_ok_and(|metadata| metadata.file_type().is_socket()); + match (qemu_socket, storage_socket) { + (true, true) => return Ok(()), + (false, false) => (), + _ => { + return Err(Error::Invalid( + "native libvirt runtime has only some expected sockets".into(), + )); + } + } + for daemon in ["virtqemud", "virtstoraged"] { + let program = find_executable(daemon) + .ok_or_else(|| Error::Invalid(format!("required VM tool is unavailable: {daemon}")))?; + let mut command = Command::new(program); + command + .args(["--daemon", "--timeout", "0"]) + .env("XDG_RUNTIME_DIR", runtime_dir); + let output = crate::process::capture(command, Duration::from_secs(20))?; + if !output.status.success() { + return Err(Error::Command { + operation: daemon.into(), + code: output.status.code(), + }); + } + } + let mut no_children: [Child; 0] = []; + wait_for_sockets(&sockets, &mut no_children, runtime_dir) +} + +fn start_flatpak_daemons(runtime_dir: &Path, flatpak_runner: &Path) -> Result<()> { + if !flatpak_runner.is_file() || fs::metadata(flatpak_runner)?.mode() & 0o111 == 0 { + return Err(Error::Invalid( + "Flatpak libvirt wrapper is missing or not executable".into(), + )); + } + let log_path = runtime_dir.join("flatpak.log"); + if fs::symlink_metadata(&log_path).is_ok_and(|metadata| metadata.file_type().is_symlink()) { + return Err(Error::Invalid("Flatpak runtime log is a symlink".into())); + } + let log = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(&log_path)?; + let mut children = Vec::new(); + for daemon in ["virtlogd", "virtstoraged", "virtqemud"] { + let mut command = Command::new(flatpak_runner); + command + .args([daemon, "--timeout", "0"]) + .env("TEST_VM_LIBVIRT_RUNTIME_DIR", runtime_dir) + .stdin(Stdio::null()) + .stdout(Stdio::from(log.try_clone()?)) + .stderr(Stdio::from(log.try_clone()?)) + .process_group(0); + match command.spawn() { + Ok(child) => children.push(child), + Err(error) => { + stop_children(&mut children); + return Err(error.into()); + } + } + } + let sockets = [ + runtime_socket(runtime_dir, "virtqemud-sock"), + runtime_socket(runtime_dir, "virtstoraged-sock"), + ]; + wait_for_sockets(&sockets, &mut children, runtime_dir) +} + +fn wait_for_sockets(sockets: &[PathBuf], children: &mut [Child], log_dir: &Path) -> Result<()> { + let deadline = Instant::now() + READY_TIMEOUT; + loop { + if sockets + .iter() + .all(|path| fs::metadata(path).is_ok_and(|metadata| metadata.file_type().is_socket())) + { + return Ok(()); + } + let mut exited_early = false; + for child in children.iter_mut() { + if let Some(status) = child.try_wait()? { + if !status.success() { + exited_early = true; + } + } + } + if exited_early { + stop_children(children); + return Err(Error::Invalid(format!( + "Flatpak libvirt daemon exited early; inspect {}", + log_dir.join("flatpak.log").display() + ))); + } + if Instant::now() >= deadline { + stop_children(children); + return Err(Error::Timeout); + } + thread::sleep(Duration::from_millis(250)); + } +} + +fn stop_children(children: &mut [Child]) { + for child in children.iter_mut() { + let alive = child.try_wait().ok().flatten().is_none(); + if alive { + if let Ok(process_group) = i32::try_from(child.id()) { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(process_group), + nix::sys::signal::Signal::SIGTERM, + ); + } + let _ = child.kill(); + } + let _ = child.wait(); + } +} + +fn runtime_socket(runtime_dir: &Path, name: &str) -> PathBuf { + runtime_dir.join("libvirt").join(name) +} + +#[cfg(test)] +#[path = "runtime_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/runtime_tests.rs b/ublue/skillet/crates/vm/src/runtime_tests.rs new file mode 100644 index 00000000..f66805cf --- /dev/null +++ b/ublue/skillet/crates/vm/src/runtime_tests.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn runtime_paths_keep_native_and_flatpak_sessions_distinct() { + let uid = 1234; + let standard = standard_runtime_dir(uid); + assert_eq!(standard, PathBuf::from("/run/user/1234")); + assert_eq!( + runtime_socket(&standard.join("skillet-test-libvirt"), "virtqemud-sock"), + PathBuf::from("/run/user/1234/skillet-test-libvirt/libvirt/virtqemud-sock") + ); + assert_eq!( + runtime_socket(&standard.join("skvm"), "virtstoraged-sock"), + PathBuf::from("/run/user/1234/skvm/libvirt/virtstoraged-sock") + ); +} + +#[test] +fn runtime_selection_preserves_the_qemu_session_uri() { + for backend in [Backend::Native, Backend::Flatpak] { + let connection = Connection { + backend, + uri: URI.into(), + runtime_dir: match backend { + Backend::Native => standard_runtime_dir(1234), + Backend::Flatpak => standard_runtime_dir(1234).join("skvm"), + }, + }; + assert!(connection.validate(1234).is_ok()); + assert_eq!(connection.uri, "qemu:///session"); + } +} diff --git a/ublue/skillet/crates/vm/src/staging.rs b/ublue/skillet/crates/vm/src/staging.rs new file mode 100644 index 00000000..cb8aeb5b --- /dev/null +++ b/ublue/skillet/crates/vm/src/staging.rs @@ -0,0 +1,411 @@ +//! Stage a host's Butane inputs and captured binaries for one disposable run. +use crate::{manifest::reject_symlinks, Error, ManifestStore, Phase, Result, VmRun}; +use serde_yml::{Mapping, Value}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File}, + io::{Read as _, Write as _}, + os::unix::fs::PermissionsExt as _, + path::Path, +}; + +/// Copy and specialize the source Butane tree under the recorded run directory. +/// Repeated staging writes only changed bytes and never mutates source files. +pub fn stage_butane_source( + store: &ManifestStore, + run: &VmRun, + config: &Path, + includes: &Path, + host_binary: &Path, + generic_binary: &Path, + image: &Path, +) -> Result<()> { + let _lock = store.lock(&run.identity)?; + let current = store.load(&run.identity)?; + store.validate(¤t, &run.identity)?; + if current.phase != Phase::Preparing { + return Err(Error::Invalid( + "Butane staging is allowed only for a preparing VM".into(), + )); + } + let run = ¤t; + for path in [config, includes, host_binary, generic_binary, image] { + reject_symlinks(path)?; + } + if !config.is_file() + || !includes.is_dir() + || !host_binary.is_file() + || !generic_binary.is_file() + || !image.is_file() + { + return Err(Error::Invalid( + "Butane staging inputs must be regular files and an include directory".into(), + )); + } + let run_dir = store.run_dir(&run.identity); + reject_symlinks(&run_dir)?; + let source_dir = run_dir.join("source"); + let include_dir = source_dir.join("includes"); + let files_dir = source_dir.join("files"); + create_private_dir(&source_dir)?; + create_private_dir(&include_dir)?; + create_private_dir(&files_dir)?; + stage_config(run, config, includes, &source_dir, &include_dir)?; + stage_artifacts( + run, + host_binary, + generic_binary, + image, + &files_dir, + &run_dir, + )?; + File::open(&run_dir)?.sync_all()?; + Ok(()) +} + +fn stage_config( + run: &VmRun, + config: &Path, + includes: &Path, + source_dir: &Path, + include_dir: &Path, +) -> Result<()> { + let config_target = source_dir.join(format!("{}.bu", run.identity.host())); + let mut host_config = parse_yaml(config)?; + set_inline( + find_file(&mut host_config, "/etc/hostname")?, + &run.guest_hostname, + )?; + crate::install::configure_root(&mut host_config, run.root_profile)?; + write_yaml_if_changed(&config_target, &host_config)?; + + for entry in fs::read_dir(includes)? { + let entry = entry?; + let source = entry.path(); + if source + .extension() + .is_some_and(|extension| extension == "bu") + { + reject_symlinks(&source)?; + if !entry.file_type()?.is_file() { + return Err(Error::Invalid( + "Butane include must be a regular file".into(), + )); + } + let target = include_dir.join(entry.file_name()); + copy_if_changed(&source, &target, 0o644)?; + } + } + + let shared_include = include_dir.join("skillet.bu"); + let mut shared = parse_yaml(&shared_include)?; + remove_file(&mut shared, "/var/usrlocal/bin/skillet")?; + write_yaml_if_changed(&shared_include, &shared)?; + + let host_include = include_dir.join(format!("skillet-{}.bu", run.identity.host())); + let mut host_specific = parse_yaml(&host_include)?; + remove_file( + &mut host_specific, + &format!("/var/usrlocal/bin/skillet-{}", run.identity.host()), + )?; + write_yaml_if_changed(&host_include, &host_specific)?; + + let passwd_include = include_dir.join("passwd.bu"); + let mut passwd = parse_yaml(&passwd_include)?; + add_authorized_key( + &mut passwd, + &crate::provisioning::validated_public_key(run)?, + )?; + remove_file(&mut passwd, "/usr/local/bin/force_pw_change.sh")?; + remove_unit(&mut passwd, "force-pw-change.service")?; + add_vm_sudoers(&mut passwd)?; + write_yaml_if_changed(&passwd_include, &passwd)?; + Ok(()) +} + +fn stage_artifacts( + run: &VmRun, + host_binary: &Path, + generic_binary: &Path, + image: &Path, + files_dir: &Path, + run_dir: &Path, +) -> Result<()> { + let host_staged = files_dir.join(format!("skillet-{}", run.identity.host())); + let generic_staged = files_dir.join("skillet"); + copy_if_changed(host_binary, &host_staged, 0o755)?; + copy_if_changed(generic_binary, &generic_staged, 0o755)?; + let host_hash = hash_path(&host_staged)?; + let generic_hash = hash_path(&generic_staged)?; + let image_name = image + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Error::Invalid("VM image filename is invalid".into()))?; + let image_hash = hash_path(image)?; + let image_sidecar = run_dir.join("fcos-image.sha256"); + write_if_changed( + &image_sidecar, + format!("{image_hash} {image_name}\n").as_bytes(), + 0o600, + )?; + write_if_changed( + &run_dir.join("skillet.sha256"), + format!("{host_hash} {}\n", host_staged.display()).as_bytes(), + 0o600, + )?; + write_if_changed( + &run_dir.join("skillet-generic.sha256"), + format!("{generic_hash} {}\n", generic_staged.display()).as_bytes(), + 0o600, + )?; + write_if_changed( + &run_dir.join("run.conf"), + legacy_config(run, image, host_binary)?.as_bytes(), + 0o600, + )?; + Ok(()) +} + +fn legacy_config(run: &VmRun, image: &Path, host_binary: &Path) -> Result { + let fields = [ + ("vm", run.identity.domain_name()), + ("host", run.identity.host().to_owned()), + ("uri", run.connection.uri.clone()), + ("ssh_port", run.ssh.port.to_string()), + ("image", path_value(image)?), + ("artifact", path_value(host_binary)?), + ("runtime_dir", path_value(&run.connection.runtime_dir)?), + ( + "backend", + match run.connection.backend { + crate::Backend::Native => "native", + crate::Backend::Flatpak => "flatpak", + } + .to_owned(), + ), + ("source_commit", run.source_commit.clone()), + ]; + if fields.iter().any(|(_, value)| value.contains(['\n', '\r'])) { + return Err(Error::Invalid( + "newline in legacy VM configuration value".into(), + )); + } + let mut output = String::new(); + for (key, value) in fields { + output.push_str(key); + output.push('='); + output.push_str(&value); + output.push('\n'); + } + Ok(output) +} + +fn path_value(path: &Path) -> Result { + path.to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| Error::Invalid("VM artifact path is not UTF-8".into())) +} + +fn parse_yaml(path: &Path) -> Result { + reject_symlinks(path)?; + let contents = fs::read_to_string(path)?; + let mut value: Value = + serde_yml::from_str(&contents).map_err(|error| Error::Preparation(error.to_string()))?; + crate::install::normalize_butane_modes(&mut value)?; + Ok(value) +} + +fn write_yaml_if_changed(path: &Path, value: &Value) -> Result<()> { + let contents = + serde_yml::to_string(value).map_err(|error| Error::Preparation(error.to_string()))?; + write_if_changed(path, contents.as_bytes(), 0o644) +} + +fn create_private_dir(path: &Path) -> Result<()> { + reject_symlinks(path)?; + if !path.exists() { + fs::create_dir(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + if !fs::metadata(path)?.is_dir() { + return Err(Error::Invalid("VM staging path is not a directory".into())); + } + Ok(()) +} + +fn copy_if_changed(source: &Path, target: &Path, mode: u32) -> Result<()> { + reject_symlinks(source)?; + reject_symlinks(target)?; + let bytes = fs::read(source)?; + write_if_changed(target, &bytes, mode) +} + +fn write_if_changed(path: &Path, contents: &[u8], mode: u32) -> Result<()> { + reject_symlinks(path)?; + if path.is_file() + && fs::read(path)? == contents + && fs::metadata(path)?.permissions().mode() & 0o777 == mode + { + return Ok(()); + } + let parent = path + .parent() + .ok_or_else(|| Error::Invalid("staged file has no parent directory".into()))?; + let mut staged = tempfile::NamedTempFile::new_in(parent)?; + staged + .as_file() + .set_permissions(fs::Permissions::from_mode(mode))?; + staged.write_all(contents)?; + staged.as_file().sync_all()?; + staged + .persist(path) + .map_err(|error| Error::Io(error.error))?; + Ok(()) +} + +fn hash_path(path: &Path) -> Result { + reject_symlinks(path)?; + let mut file = fs::File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 16 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + Ok(hex::encode(digest.finalize())) +} + +fn find_file<'a>(root: &'a mut Value, path: &str) -> Result<&'a mut Mapping> { + let entries = sequence_at_mut(root, &["storage", "files"])?; + entries + .iter_mut() + .filter_map(Value::as_mapping_mut) + .find(|entry| string_at(entry, "path").as_deref() == Some(path)) + .ok_or_else(|| Error::Invalid(format!("required Butane file entry is absent: {path}"))) +} + +fn set_inline(file: &mut Mapping, inline: &str) -> Result<()> { + let contents = mapping_at_mut(file, "contents")?; + contents.insert(Value::String("inline".into()), Value::String(inline.into())); + Ok(()) +} + +fn remove_file(root: &mut Value, path: &str) -> Result<()> { + if root + .get("storage") + .is_none_or(|value| value.is_mapping() && value.get("files").is_none()) + { + return Ok(()); + } + let files = sequence_at_mut(root, &["storage", "files"])?; + files.retain(|value| { + value + .as_mapping() + .and_then(|entry| string_at(entry, "path")) + .is_none_or(|candidate| candidate != path) + }); + Ok(()) +} + +fn remove_unit(root: &mut Value, name: &str) -> Result<()> { + if root + .get("systemd") + .is_none_or(|value| value.is_mapping() && value.get("units").is_none()) + { + return Ok(()); + } + let units = sequence_at_mut(root, &["systemd", "units"])?; + units.retain(|value| { + value + .as_mapping() + .and_then(|entry| string_at(entry, "name")) + .is_none_or(|candidate| candidate != name) + }); + Ok(()) +} + +fn add_authorized_key(root: &mut Value, key: &str) -> Result<()> { + let users = sequence_at_mut(root, &["passwd", "users"])?; + let user = users + .iter_mut() + .filter_map(Value::as_mapping_mut) + .find(|user| string_at(user, "name").as_deref() == Some("giacomo")) + .ok_or_else(|| Error::Invalid("Butane config lacks the giacomo account".into()))?; + let keys = user + .entry(Value::String("ssh_authorized_keys".into())) + .or_insert_with(|| Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or_else(|| Error::Invalid("SSH authorized key list is not a sequence".into()))?; + let key = Value::String(key.trim().to_owned()); + if key.as_str().is_none_or(str::is_empty) { + return Err(Error::Invalid("generated SSH public key is empty".into())); + } + if !keys.contains(&key) { + keys.push(key); + } + Ok(()) +} + +fn add_vm_sudoers(root: &mut Value) -> Result<()> { + let storage = root + .as_mapping_mut() + .ok_or_else(|| Error::Invalid("Butane root is not a mapping".into()))? + .entry(Value::String("storage".into())) + .or_insert_with(|| Value::Mapping(Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| Error::Invalid("Butane storage is not a mapping".into()))?; + let files = storage + .entry(Value::String("files".into())) + .or_insert_with(|| Value::Sequence(Vec::new())) + .as_sequence_mut() + .ok_or_else(|| Error::Invalid("Butane files are not a sequence".into()))?; + if files.iter().any(|value| { + value + .as_mapping() + .and_then(|entry| string_at(entry, "path")) + .as_deref() + == Some("/etc/sudoers.d/90-skillet-vm") + }) { + return Ok(()); + } + files.push(serde_yml::from_str( + "path: /etc/sudoers.d/90-skillet-vm\nmode: 288\ncontents:\n inline: |\n giacomo ALL=(ALL) NOPASSWD: ALL\n", + ) + .map_err(|error| Error::Preparation(error.to_string()))?); + Ok(()) +} + +fn sequence_at_mut<'a>(root: &'a mut Value, path: &[&str]) -> Result<&'a mut Vec> { + let mut current = root; + for segment in path { + current = current + .as_mapping_mut() + .and_then(|mapping| mapping.get_mut(Value::String((*segment).into()))) + .ok_or_else(|| { + Error::Invalid(format!("required Butane mapping is absent: {segment}")) + })?; + } + current + .as_sequence_mut() + .ok_or_else(|| Error::Invalid("required Butane field is not a sequence".into())) +} + +fn mapping_at_mut<'a>(root: &'a mut Mapping, key: &str) -> Result<&'a mut Mapping> { + root.get_mut(Value::String(key.into())) + .and_then(Value::as_mapping_mut) + .ok_or_else(|| Error::Invalid(format!("required Butane mapping is absent: {key}"))) +} + +fn string_at(mapping: &Mapping, key: &str) -> Option { + mapping + .get(Value::String(key.into())) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +#[path = "staging_tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/staging_tests.rs b/ublue/skillet/crates/vm/src/staging_tests.rs new file mode 100644 index 00000000..65a6eec8 --- /dev/null +++ b/ublue/skillet/crates/vm/src/staging_tests.rs @@ -0,0 +1,249 @@ +use super::*; +use crate::{Backend, Connection, RunIdentity}; +use std::{fs, os::unix::fs::symlink, path::PathBuf, process::Command}; + +fn fixture() -> ( + tempfile::TempDir, + ManifestStore, + VmRun, + PathBuf, + PathBuf, + PathBuf, + PathBuf, + PathBuf, +) { + let temp = tempfile::tempdir().unwrap(); + let store = ManifestStore::new(temp.path(), crate::current_uid()).unwrap(); + let identity = RunIdentity::new("clamps", "stage-test").unwrap(); + let run = store + .prepare_intent( + &identity, + Connection { + backend: Backend::Native, + uri: "qemu:///session".into(), + runtime_dir: PathBuf::from(format!("/run/user/{}", crate::current_uid())), + }, + 2204, + "source-revision", + ) + .unwrap(); + let config = temp.path().join("clamps.bu"); + let includes = temp.path().join("includes"); + fs::create_dir(&includes).unwrap(); + fs::write( + &config, + "variant: fcos\nversion: 1.6.0\nstorage:\n files:\n - path: /etc/hostname\n mode: 420\n contents:\n inline: clamps\n", + ) + .unwrap(); + fs::write( + includes.join("skillet.bu"), + "variant: fcos\nversion: 1.6.0\nstorage:\n files:\n - path: /var/usrlocal/bin/skillet\n contents:\n local: files/skillet\n - path: /etc/other\n contents:\n inline: preserved\n", + ) + .unwrap(); + fs::write( + includes.join("skillet-clamps.bu"), + "variant: fcos\nversion: 1.6.0\nstorage:\n files:\n - path: /var/usrlocal/bin/skillet-clamps\n contents:\n local: files/skillet-clamps\n", + ) + .unwrap(); + fs::write( + includes.join("passwd.bu"), + "variant: fcos\nversion: 1.6.0\npasswd:\n users:\n - name: giacomo\n ssh_authorized_keys:\n - ssh-ed25519 existing\nstorage:\n files:\n - path: /usr/local/bin/force_pw_change.sh\n contents:\n inline: script\nsystemd:\n units:\n - name: force-pw-change.service\n enabled: true\n", + ) + .unwrap(); + fs::write(includes.join("extra.bu"), "variant: fcos\nversion: 1.6.0\n").unwrap(); + let host_binary = temp.path().join("skillet-clamps"); + let generic_binary = temp.path().join("skillet"); + let image = temp.path().join("image.qcow2"); + fs::write(&host_binary, b"host executable").unwrap(); + fs::write(&generic_binary, b"generic executable").unwrap(); + fs::write(&image, b"image bytes").unwrap(); + fs::create_dir_all(run.ssh.identity.parent().unwrap()).unwrap(); + fs::set_permissions( + run.ssh.identity.parent().unwrap(), + fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let keygen = Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", ""]) + .arg("-f") + .arg(&run.ssh.identity) + .status() + .unwrap(); + assert!(keygen.success()); + ( + temp, + store, + run, + config, + includes, + host_binary, + generic_binary, + image, + ) +} + +#[test] +fn staging_specializes_only_the_owned_copy_and_hashes_artifacts() { + let (_temp, store, run, config, includes, host_binary, generic_binary, image) = fixture(); + stage_butane_source( + &store, + &run, + &config, + &includes, + &host_binary, + &generic_binary, + &image, + ) + .unwrap(); + let staged = store.run_dir(&run.identity).join("source"); + let host: Value = + serde_yml::from_str(&fs::read_to_string(staged.join("clamps.bu")).unwrap()).unwrap(); + assert_eq!( + host["storage"]["files"][0]["contents"]["inline"].as_str(), + Some(run.guest_hostname.as_str()) + ); + let shared: Value = + serde_yml::from_str(&fs::read_to_string(staged.join("includes/skillet.bu")).unwrap()) + .unwrap(); + assert_eq!(shared["storage"]["files"].as_sequence().unwrap().len(), 1); + let host_include: Value = serde_yml::from_str( + &fs::read_to_string(staged.join("includes/skillet-clamps.bu")).unwrap(), + ) + .unwrap(); + assert_eq!( + host_include["storage"]["files"] + .as_sequence() + .unwrap() + .len(), + 0 + ); + let passwd: Value = + serde_yml::from_str(&fs::read_to_string(staged.join("includes/passwd.bu")).unwrap()) + .unwrap(); + let user = &passwd["passwd"]["users"][0]; + assert_eq!(user["ssh_authorized_keys"].as_sequence().unwrap().len(), 2); + assert_eq!(passwd["storage"]["files"].as_sequence().unwrap().len(), 1); + assert_eq!(passwd["systemd"]["units"].as_sequence().unwrap().len(), 0); + assert_eq!( + passwd["storage"]["files"][0]["path"].as_str(), + Some("/etc/sudoers.d/90-skillet-vm") + ); + assert_eq!( + fs::read(staged.join("files/skillet-clamps")).unwrap(), + b"host executable" + ); + assert_eq!( + fs::metadata(staged.join("files/skillet-clamps")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + assert!(store.run_dir(&run.identity).join("run.conf").is_file()); + assert!(store + .run_dir(&run.identity) + .join("skillet-generic.sha256") + .is_file()); + assert!(fs::read_to_string(config) + .unwrap() + .contains("inline: clamps")); +} + +#[test] +fn staging_converts_octal_mode_strings_to_butane_numbers() { + let (_temp, store, run, config, includes, host_binary, generic_binary, image) = fixture(); + fs::write( + &config, + "variant: fcos\nversion: 1.6.0\nstorage:\n files:\n - path: /etc/hostname\n mode: '0644'\n contents:\n inline: clamps\n", + ) + .unwrap(); + stage_butane_source( + &store, + &run, + &config, + &includes, + &host_binary, + &generic_binary, + &image, + ) + .unwrap(); + let staged = store.run_dir(&run.identity).join("source/clamps.bu"); + let yaml: Value = serde_yml::from_str(&fs::read_to_string(staged).unwrap()).unwrap(); + assert_eq!(yaml["storage"]["files"][0]["mode"].as_u64(), Some(0o644)); +} + +#[test] +fn staging_is_idempotent_and_refuses_progressed_runs_or_linked_sources() { + let (_temp, store, run, config, includes, host_binary, generic_binary, image) = fixture(); + let arguments = || { + ( + &store, + &run, + &config, + &includes, + &host_binary, + &generic_binary, + &image, + ) + }; + let args = arguments(); + stage_butane_source(args.0, args.1, args.2, args.3, args.4, args.5, args.6).unwrap(); + let source_dir = store.run_dir(&run.identity).join("source"); + let passwd_path = source_dir.join("includes/passwd.bu"); + let staged_once = fs::read(&passwd_path).unwrap(); + let args = arguments(); + stage_butane_source(args.0, args.1, args.2, args.3, args.4, args.5, args.6).unwrap(); + assert_eq!(fs::read(&passwd_path).unwrap(), staged_once); + + let symlink_config = config.with_file_name("linked-clamps.bu"); + symlink(&config, &symlink_config).unwrap(); + assert!(stage_butane_source( + &store, + &run, + &symlink_config, + &includes, + &host_binary, + &generic_binary, + &image + ) + .is_err()); + store.mark_started(&run.identity).unwrap(); + assert!(stage_butane_source( + &store, + &run, + &config, + &includes, + &host_binary, + &generic_binary, + &image + ) + .is_err()); +} + +#[test] +fn staging_accepts_account_only_passwd_includes_and_tpm_root() { + let (_tmp, store, mut run, config, includes, host, generic, image) = fixture(); + fs::write(includes.join("passwd.bu"),"variant: fcos\nversion: 1.6.0\npasswd:\n users:\n - name: giacomo\n ssh_authorized_keys: []\n").unwrap(); + let mut value: Value = serde_yml::from_str(&fs::read_to_string(&config).unwrap()).unwrap(); + value["storage"].as_mapping_mut().unwrap().insert(Value::String("filesystems".into()),serde_yml::from_str("- device: /dev/disk/by-partlabel/root\n format: btrfs\n label: root\n wipe_filesystem: true\n").unwrap()); + fs::write(&config, serde_yml::to_string(&value).unwrap()).unwrap(); + run.root_profile = crate::install::RootProfile::Tpm; + store.save(&run).unwrap(); + for _ in 0..2 { + stage_butane_source(&store, &run, &config, &includes, &host, &generic, &image).unwrap(); + } + let path = store.run_dir(&run.identity).join("source/clamps.bu"); + let staged: Value = serde_yml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!( + staged["storage"]["filesystems"][0]["device"].as_str(), + Some("/dev/mapper/root") + ); + let passwd = fs::read_to_string( + store + .run_dir(&run.identity) + .join("source/includes/passwd.bu"), + ) + .unwrap(); + assert!(passwd.contains("90-skillet-vm")); +} diff --git a/ublue/skillet/crates/vm/src/transport.rs b/ublue/skillet/crates/vm/src/transport.rs new file mode 100644 index 00000000..cb19c7e7 --- /dev/null +++ b/ublue/skillet/crates/vm/src/transport.rs @@ -0,0 +1,298 @@ +//! Verified SSH execution/file delivery with explicit target and trust policy. +use crate::{Error, Result, SshTarget}; +use std::{ + path::Path, + process::{Command, ExitStatus, Output}, + time::Duration, +}; + +/// A localhost listener forwarded only to the guest's localhost. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LoopbackForward { + local: u16, + guest: u16, +} + +impl std::str::FromStr for LoopbackForward { + type Err = Error; + + fn from_str(value: &str) -> Result { + let invalid = + || Error::Invalid("forward must be LOCAL:GUEST with nonzero TCP ports".into()); + let (local, guest) = value.split_once(':').ok_or_else(invalid)?; + let local = local.parse::().map_err(|_| invalid())?; + let guest = guest.parse::().map_err(|_| invalid())?; + if local == 0 || guest == 0 { + return Err(invalid()); + } + Ok(Self { local, guest }) + } +} + +pub struct GuestCommand<'a> { + pub program: &'a str, + pub arguments: &'a [&'a str], +} + +pub trait GuestTransport { + fn execute(&self, command: &GuestCommand<'_>, input: Option<&[u8]>) -> Result; + fn upload(&self, source: &Path, destination: &str) -> Result<()>; +} + +/// Check that the target is still owned immediately before and after each +/// remote operation. Lifecycle callers keep their run lock for the wrapper's +/// lifetime and supply the ownership check for the selected backend. +pub struct OwnershipCheckedTransport<'a, T> { + transport: &'a T, + ownership: &'a dyn Fn() -> Result<()>, +} + +impl<'a, T> OwnershipCheckedTransport<'a, T> { + pub fn new(transport: &'a T, ownership: &'a dyn Fn() -> Result<()>) -> Self { + Self { + transport, + ownership, + } + } +} + +impl GuestTransport for OwnershipCheckedTransport<'_, T> { + fn execute(&self, command: &GuestCommand<'_>, input: Option<&[u8]>) -> Result { + (self.ownership)()?; + let result = self.transport.execute(command, input); + (self.ownership)()?; + result + } + + fn upload(&self, source: &Path, destination: &str) -> Result<()> { + (self.ownership)()?; + let result = self.transport.upload(source, destination); + (self.ownership)()?; + result + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HostKeyPolicy { + Enroll, + Verify, +} + +pub struct SshTransport { + target: SshTarget, + policy: HostKeyPolicy, + pub timeout: Duration, +} + +impl SshTransport { + pub fn new(target: SshTarget, policy: HostKeyPolicy) -> Result { + if target.user.is_empty() + || target.user.starts_with('-') + || !target + .user + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + || !valid_address(&target.address) + || target.port == 0 + { + return Err(Error::Invalid("invalid SSH user, address or port".into())); + } + for path in [&target.identity, &target.known_hosts] { + crate::manifest::reject_symlinks(path)?; + if !path.is_absolute() { + return Err(Error::Invalid("SSH key paths must be absolute".into())); + } + } + if !target.identity.is_file() + || (policy == HostKeyPolicy::Verify && !target.known_hosts.is_file()) + { + return Err(Error::Invalid( + "SSH identity or recorded host key is absent".into(), + )); + } + Ok(Self { + target, + policy, + timeout: Duration::from_mins(10), + }) + } + + fn options(&self, command: &mut Command, port_flag: &str) { + command + .args([ + "-o", + "BatchMode=yes", + "-o", + "IdentitiesOnly=yes", + "-o", + "ConnectTimeout=5", + "-o", + "ConnectionAttempts=1", + "-o", + ]) + .arg(format!( + "StrictHostKeyChecking={}", + match self.policy { + HostKeyPolicy::Enroll => "accept-new", + HostKeyPolicy::Verify => "yes", + } + )) + .arg("-o") + .arg(format!( + "UserKnownHostsFile={}", + self.target.known_hosts.display() + )) + .arg("-i") + .arg(&self.target.identity) + .arg(port_flag) + .arg(self.target.port.to_string()); + } + + fn remote_target(&self) -> String { + format!("{}@{}", self.target.user, self.target.address) + } + + fn command(&self, request: &GuestCommand<'_>) -> Result { + if request.program.is_empty() + || request.program.contains('\0') + || request + .arguments + .iter() + .any(|argument| argument.contains('\0')) + { + return Err(Error::Invalid( + "empty or NUL-containing guest command".into(), + )); + } + let mut command = Command::new("ssh"); + command.arg("-T"); + self.options(&mut command, "-p"); + // OpenSSH uses a remote shell to launch executables. Quote each argument + // literally; never construct a shell program or interpolate payloads. + let remote = std::iter::once(request.program) + .chain(request.arguments.iter().copied()) + .map(quote_argument) + .collect::>() + .join(" "); + command.arg(self.remote_target()).arg(remote); + Ok(command) + } + + fn upload_command(&self, source: &Path, destination: &str) -> Result { + if !destination.starts_with("/var/tmp/") + || !destination.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.') + }) + || destination + .split('/') + .any(|part| matches!(part, "." | "..")) + { + return Err(Error::Invalid( + "upload destination must be a literal staging path".into(), + )); + } + crate::manifest::reject_symlinks(source)?; + if !source.is_absolute() || !source.is_file() { + return Err(Error::Invalid( + "upload source must be an existing absolute file".into(), + )); + } + let mut command = Command::new("scp"); + command.arg("-O"); + self.options(&mut command, "-P"); + let target = match self.target.address.parse::() { + Ok(std::net::IpAddr::V6(_)) => { + format!("{}@[{}]", self.target.user, self.target.address) + } + _ => self.remote_target(), + }; + command.arg(source).arg(format!("{target}:{destination}")); + Ok(command) + } + + fn interactive_command(&self, forwards: &[LoopbackForward]) -> Result { + let mut command = Command::new("ssh"); + command.arg("-tt"); + self.options(&mut command, "-p"); + let mut ports = std::collections::BTreeSet::new(); + for forward in forwards { + if !ports.insert(forward.local) { + return Err(Error::Invalid("duplicate local forwarding port".into())); + } + command.arg("-L").arg(format!( + "127.0.0.1:{}:127.0.0.1:{}", + forward.local, forward.guest + )); + } + if !forwards.is_empty() { + command.args([ + "-o", + "ExitOnForwardFailure=yes", + "-o", + "ControlMaster=no", + "-o", + "ControlPath=none", + "-o", + "ControlPersist=no", + ]); + } + command.arg(self.remote_target()); + Ok(command) + } + + /// Run an interactive guest shell with the caller's terminal attached. + /// Unlike captured guest commands this is intentionally unbounded until + /// the user exits, and inherits stdin/stdout/stderr directly. + pub fn interactive(&self) -> Result { + self.interactive_with_forwards(&[]) + } + + /// Keep forwards scoped to the interactive SSH process and caller's run lock. + pub fn interactive_with_forwards(&self, forwards: &[LoopbackForward]) -> Result { + self.interactive_command(forwards)? + .status() + .map_err(Error::Io) + } +} + +impl GuestTransport for SshTransport { + fn execute(&self, command: &GuestCommand<'_>, input: Option<&[u8]>) -> Result { + crate::process::capture_with_input(self.command(command)?, self.timeout, input) + } + fn upload(&self, source: &Path, destination: &str) -> Result<()> { + let output = + crate::process::capture(self.upload_command(source, destination)?, self.timeout)?; + if !output.status.success() { + return Err(Error::Guest { + operation: "upload".into(), + code: output.status.code(), + }); + } + Ok(()) + } +} + +fn quote_argument(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn valid_address(value: &str) -> bool { + if value.parse::().is_ok() { + return true; + } + !value.is_empty() + && value.len() <= 253 + && value.trim_end_matches('.').split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +#[cfg(test)] +#[path = "transport/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/vm/src/transport/tests.rs b/ublue/skillet/crates/vm/src/transport/tests.rs new file mode 100644 index 00000000..45ba0ab7 --- /dev/null +++ b/ublue/skillet/crates/vm/src/transport/tests.rs @@ -0,0 +1,287 @@ +use super::*; +use std::{ + fs, + os::unix::fs::symlink, + sync::{Arc, Mutex}, +}; + +struct FakeTransport(Arc>>); + +impl GuestTransport for FakeTransport { + fn execute(&self, _: &GuestCommand<'_>, _: Option<&[u8]>) -> Result { + self.0.lock().unwrap().push("execute"); + Ok(std::process::Command::new("true").output().unwrap()) + } + + fn upload(&self, _: &Path, _: &str) -> Result<()> { + self.0.lock().unwrap().push("upload"); + Ok(()) + } +} + +#[test] +fn ownership_checked_transport_checks_before_and_after_each_remote_operation() { + let operations = Arc::new(Mutex::new(Vec::new())); + let transport = FakeTransport(operations.clone()); + let checks = std::cell::Cell::new(0); + let ownership = || checks.set(checks.get() + 1); + let validate = || { + ownership(); + Ok(()) + }; + let checked = OwnershipCheckedTransport::new(&transport, &validate); + checked + .execute( + &GuestCommand { + program: "true", + arguments: &[], + }, + None, + ) + .unwrap(); + checked + .upload(Path::new("unused"), "/var/tmp/file") + .unwrap(); + assert_eq!(checks.get(), 4); + assert_eq!(*operations.lock().unwrap(), ["execute", "upload"]); +} + +#[test] +fn ownership_loss_after_operation_is_reported() { + let operations = Arc::new(Mutex::new(Vec::new())); + let transport = FakeTransport(operations.clone()); + let checks = std::cell::Cell::new(0); + let validate = || { + checks.set(checks.get() + 1); + if checks.get() == 2 { + return Err(Error::Invalid("ownership changed".into())); + } + Ok(()) + }; + let checked = OwnershipCheckedTransport::new(&transport, &validate); + let result = checked.execute( + &GuestCommand { + program: "true", + arguments: &[], + }, + None, + ); + assert!(result + .unwrap_err() + .to_string() + .contains("ownership changed")); + assert_eq!(*operations.lock().unwrap(), ["execute"]); +} + +#[test] +fn ownership_loss_between_delivery_operations_blocks_the_next_operation() { + let operations = Arc::new(Mutex::new(Vec::new())); + let transport = FakeTransport(operations.clone()); + let checks = std::cell::Cell::new(0); + let validate = || { + checks.set(checks.get() + 1); + if checks.get() >= 3 { + return Err(Error::Invalid("ownership changed".into())); + } + Ok(()) + }; + let checked = OwnershipCheckedTransport::new(&transport, &validate); + checked + .execute( + &GuestCommand { + program: "install", + arguments: &[], + }, + None, + ) + .unwrap(); + let result = checked.upload(Path::new("unused"), "/var/tmp/file"); + assert!(result + .unwrap_err() + .to_string() + .contains("ownership changed")); + assert_eq!(*operations.lock().unwrap(), ["execute"]); +} + +fn target(dir: &Path) -> SshTarget { + let identity = dir.join("id_ed25519"); + let known_hosts = dir.join("known_hosts"); + fs::write(&identity, "dummy fixture key").unwrap(); + fs::write(&known_hosts, "dummy fixture host key").unwrap(); + SshTarget { + user: "fixture".into(), + address: "127.0.0.1".into(), + port: 2209, + identity, + known_hosts, + } +} + +#[test] +fn remote_executable_arguments_are_quoted_and_transport_is_noninteractive() { + let dir = tempfile::tempdir().unwrap(); + let transport = SshTransport::new(target(dir.path()), HostKeyPolicy::Verify).unwrap(); + let command = transport + .command(&GuestCommand { + program: "sudo", + arguments: &["-n", "printf", "$(touch nope); 'value'\n"], + }) + .unwrap(); + let arguments: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(arguments.contains(&"BatchMode=yes")); + assert!(arguments.contains(&"StrictHostKeyChecking=yes")); + assert!(arguments.contains(&"-T")); + assert!(arguments.contains(&"2209")); + assert_eq!(arguments[arguments.len() - 2], "fixture@127.0.0.1"); + assert_eq!( + arguments.last().unwrap(), + &"'sudo' '-n' 'printf' '$(touch nope); '\\''value'\\''\n'" + ); +} + +#[test] +fn enroll_policy_does_not_replace_a_recorded_host_key_and_verify_requires_it() { + let dir = tempfile::tempdir().unwrap(); + let target = target(dir.path()); + fs::remove_file(&target.known_hosts).unwrap(); + assert!(SshTransport::new(target.clone(), HostKeyPolicy::Verify).is_err()); + let transport = SshTransport::new(target, HostKeyPolicy::Enroll).unwrap(); + let command = transport + .command(&GuestCommand { + program: "true", + arguments: &[], + }) + .unwrap(); + assert!(command + .get_args() + .any(|arg| arg == "StrictHostKeyChecking=accept-new")); +} + +#[test] +fn interactive_session_inherits_a_terminal_and_uses_recorded_target() { + let dir = tempfile::tempdir().unwrap(); + let transport = SshTransport::new(target(dir.path()), HostKeyPolicy::Enroll).unwrap(); + let command = transport.interactive_command(&[]).unwrap(); + let arguments: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(arguments.contains(&"-tt")); + assert!(!arguments.contains(&"-T")); + assert_eq!(arguments.last(), Some(&"fixture@127.0.0.1")); +} + +#[test] +fn upload_uses_recorded_port_and_refuses_shell_or_path_traversal() { + let dir = tempfile::tempdir().unwrap(); + let transport = SshTransport::new(target(dir.path()), HostKeyPolicy::Verify).unwrap(); + let source = dir.path().join("binary"); + fs::write(&source, "fixture").unwrap(); + let command = transport + .upload_command(&source, "/var/tmp/skillet-update") + .unwrap(); + let arguments: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(arguments.windows(2).any(|pair| pair == ["-P", "2209"])); + assert_eq!( + arguments.last().unwrap(), + &"fixture@127.0.0.1:/var/tmp/skillet-update" + ); + for destination in [ + "/etc/binary", + "/var/tmp/../etc/binary", + "/var/tmp/a;command", + "/var/tmp/$(command)", + "/var/tmp/a b", + ] { + assert!(transport.upload_command(&source, destination).is_err()); + } +} + +#[test] +fn malformed_targets_and_linked_keys_are_refused_before_execution() { + let dir = tempfile::tempdir().unwrap(); + let original = target(dir.path()); + let mut malformed = original.clone(); + malformed.user = "user;command".into(); + assert!(SshTransport::new(malformed, HostKeyPolicy::Verify).is_err()); + let mut malformed = original.clone(); + malformed.address = "host;command".into(); + assert!(SshTransport::new(malformed, HostKeyPolicy::Verify).is_err()); + let mut linked = original.clone(); + linked.identity = dir.path().join("linked"); + symlink(&original.identity, &linked.identity).unwrap(); + assert!(SshTransport::new(linked, HostKeyPolicy::Verify).is_err()); +} + +#[test] +fn generic_transport_supports_dns_and_ipv6_targets_without_option_injection() { + let dir = tempfile::tempdir().unwrap(); + let mut address = target(dir.path()); + address.address = "host.example.invalid".into(); + assert!(SshTransport::new(address.clone(), HostKeyPolicy::Verify).is_ok()); + address.user = "-option".into(); + assert!(SshTransport::new(address, HostKeyPolicy::Verify).is_err()); + let mut address = target(dir.path()); + address.address = "::1".into(); + let transport = SshTransport::new(address, HostKeyPolicy::Verify).unwrap(); + let source = dir.path().join("binary"); + fs::write(&source, "fixture").unwrap(); + let command = transport + .upload_command(&source, "/var/tmp/skillet") + .unwrap(); + assert_eq!( + command.get_args().last().unwrap(), + "fixture@[::1]:/var/tmp/skillet" + ); +} + +#[test] +fn loopback_forward_rejects_addresses_zero_and_invalid_ports() { + for value in [ + "0:8443", + "8443:0", + "65536:8443", + "8443", + "8443:host:8443", + "0.0.0.0:8443:8443", + ] { + assert!(value.parse::().is_err(), "{value}"); + } + assert_eq!( + "18443:8443".parse::().unwrap(), + LoopbackForward { + local: 18443, + guest: 8443 + } + ); +} + +#[test] +fn interactive_forwards_are_loopback_only_and_fail_on_bind_error() { + let dir = tempfile::tempdir().unwrap(); + let transport = SshTransport::new(target(dir.path()), HostKeyPolicy::Verify).unwrap(); + let forward = "18443:8443".parse::().unwrap(); + let command = transport.interactive_command(&[forward]).unwrap(); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(args + .windows(2) + .any(|pair| pair == ["-L", "127.0.0.1:18443:127.0.0.1:8443"])); + for option in [ + "ExitOnForwardFailure=yes", + "ControlMaster=no", + "ControlPath=none", + "ControlPersist=no", + ] { + assert!(args.contains(&option)); + } + assert!(transport.interactive_command(&[forward, forward]).is_err()); +} diff --git a/ublue/skillet/crates/workstation/Cargo.toml b/ublue/skillet/crates/workstation/Cargo.toml new file mode 100644 index 00000000..e4e1a510 --- /dev/null +++ b/ublue/skillet/crates/workstation/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "skillet_workstation" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +base64 = "0.22" +serde_yml.workspace = true +skillet_smtp.workspace = true +skillet_datadog.workspace = true +cloudflare.workspace = true +hex.workspace = true +keepass.workspace = true +keyutils.workspace = true +rpassword.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +skillet_caddy.workspace = true +skillet_ddns.workspace = true +skillet_hosts.workspace = true +skillet_vm.workspace = true +tempfile.workspace = true +thiserror.workspace = true + +zeroize = "1.9" + +[dev-dependencies] +nix = { workspace = true, features = ["term", "fs"] } +uuid.workspace = true diff --git a/ublue/skillet/crates/workstation/examples/secrets_documentation.rs b/ublue/skillet/crates/workstation/examples/secrets_documentation.rs new file mode 100644 index 00000000..bf575fb9 --- /dev/null +++ b/ublue/skillet/crates/workstation/examples/secrets_documentation.rs @@ -0,0 +1,4 @@ +fn main() -> Result<(), skillet_workstation::secrets::CheckError> { + print!("{}", skillet_workstation::secrets::documentation()?); + Ok(()) +} diff --git a/ublue/skillet/crates/workstation/examples/tpm_recovery.rs b/ublue/skillet/crates/workstation/examples/tpm_recovery.rs new file mode 100644 index 00000000..27c06995 --- /dev/null +++ b/ublue/skillet/crates/workstation/examples/tpm_recovery.rs @@ -0,0 +1,674 @@ +//! Explicit native-only recovery acceptance. Never clone a provisioned service VM. +use sha2::{Digest as _, Sha256}; +use skillet_vm::{ + backend::{VirshBackend, VmBackend}, + transport::{ + GuestCommand, GuestTransport, HostKeyPolicy, OwnershipCheckedTransport, SshTransport, + }, + Backend, ManifestStore, Phase, RunIdentity, VmRun, +}; +use skillet_workstation::{ + root_recovery::{Record, RecoveryStore, VaultStore}, + vault::Vault, +}; +use std::{ + error::Error, + fs, + io::{Read, Write}, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + path::Path, + process::Command, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; +type Result = std::result::Result>; +#[derive(serde::Serialize, serde::Deserialize)] +struct Ancestry { + source: RunIdentity, + source_uuid: uuid::Uuid, + clone_uuid: uuid::Uuid, + volume: String, + original_slot: String, + source_header_hash: String, +} +fn path(id: &RunIdentity) -> String { + format!( + "skillet/environments/test/hosts/{}/instances/{}/storage/root-recovery-key", + id.host(), + id.instance() + ) +} +fn main() -> Result<()> { + let args: Vec<_> = std::env::args().collect(); + if args.len() != 7 { + return Err( + "usage: tpm_recovery BUTANE HOST SOURCE CLONE PORT clone|unlock|rebind|verify".into(), + ); + } + let butane = fs::canonicalize(&args[1])?; + let source_id = RunIdentity::new(&args[2], &args[3])?; + let clone_id = RunIdentity::new(&args[2], &args[4])?; + if source_id == clone_id { + return Err("source and recovery instance must differ".into()); + } + let port: u16 = args[5].parse()?; + let store = ManifestStore::new(&butane.join("runs"), skillet_vm::current_uid())?; + let _source_lock = store.lock(&source_id)?; + let source = store.load(&source_id)?; + let _clone_lock = if args[6] == "clone" { + None + } else { + Some(store.lock(&clone_id)?) + }; + if source.connection.backend != Backend::Native + || source.root_profile != skillet_vm::install::RootProfile::Tpm + { + return Err("requires a native TPM source VM".into()); + } + let backend = VirshBackend::for_run(&source, &butane.join("bin/virsh"))?; + owned(&backend, &source)?; + let mut vault = Vault::open(&Vault::default_path()?, None)?; + let mut recovery = VaultStore { + vault: &mut vault, + key_file: None, + }; + let record = recovery + .load(&path(&source_id))? + .ok_or("source recovery is absent")?; + if !record.verified || record.header.is_none() { + return Err("source recovery and header must be verified first".into()); + } + if args[6] == "clone" { + if recovery.load(&path(&clone_id))?.is_some() { + return Err("clone archive already exists; use another instance".into()); + } + return clone_vm(&butane, &store, &backend, &source, &clone_id, port, &record); + } + let clone = store.load(&clone_id)?; + let ancestry: Ancestry = serde_json::from_slice(&fs::read( + store.run_dir(&clone_id).join("recovery-source.json"), + )?)?; + if ancestry.source != source_id + || ancestry.source_uuid != source.uuid + || ancestry.clone_uuid != clone.uuid + || ancestry.volume != record.volume + || ancestry.source_header_hash + != hex::encode(Sha256::digest( + record.header.as_ref().ok_or("source header absent")?, + )) + { + return Err("recovery ancestry mismatch".into()); + } + if clone.ssh.port != port { + return Err("recorded recovery port differs".into()); + } + owned(&backend, &clone)?; + match args[6].as_str() { + "unlock" => unlock(&backend, &clone, &record)?, + "rebind" => rebind( + &butane, + &store, + &backend, + clone, + &record, + &ancestry, + &mut recovery, + )?, + "verify" => { + verify(&backend, &clone, &record, &recovery)?; + verify_source(&backend, &source, &record, &ancestry.original_slot)?; + } + _ => return Err("unknown recovery action".into()), + } + Ok(()) +} +fn verify( + backend: &VirshBackend, + clone: &VmRun, + record: &Record, + recovery: &VaultStore<'_>, +) -> Result<()> { + let ssh = SshTransport::new(clone.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, clone); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + wait_ssh(&guest)?; + assert_volume(&guest, record)?; + require_base_only(&guest)?; + let metadata = execute( + &guest, + "sudo", + &[ + "-n", + "cryptsetup", + "luksDump", + "--dump-json-metadata", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + let metadata: serde_json::Value = serde_json::from_slice(&metadata)?; + if metadata["keyslots"].as_object().map(serde_json::Map::len) != Some(2) { + return Err("expected exactly new TPM plus recovery slots".into()); + } + let marker = execute( + &guest, + "sudo", + &["-n", "cat", "/var/lib/data/.skillet-tpm-loss-sentinel"], + None, + )?; + if marker != b"TPM replacement preserves encrypted data\n" { + return Err("data sentinel changed".into()); + } + if recovery.load(&path(&clone.identity))?.is_none_or(|r| { + !r.verified || r.header.is_none() || r.volume != record.volume || *r.key != *record.key + }) { + return Err("clone archive incomplete".into()); + } + println!("PASS: unattended cold boot with fresh TPM, same volume/data, exactly two slots and verified recovery header"); + Ok(()) +} + +fn verify_source( + backend: &VirshBackend, + source: &VmRun, + record: &Record, + slot: &str, +) -> Result<()> { + let ssh = SshTransport::new(source.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, source); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + wait_ssh(&guest)?; + assert_volume(&guest, record)?; + require_base_only(&guest)?; + let pass = Zeroizing::new(execute( + &guest, + "sudo", + &[ + "-n", + "clevis", + "luks", + "pass", + "-d", + "/dev/disk/by-partlabel/root", + "-s", + slot, + ], + None, + )?); + execute( + &guest, + "sudo", + &[ + "-n", + "cryptsetup", + "open", + "--test-passphrase", + "--key-file=-", + "/dev/disk/by-partlabel/root", + ], + Some(&pass), + )?; + println!("PASS: original source TPM still unlocks its unchanged volume and original header archive is preserved"); + Ok(()) +} + +fn rebind( + butane: &Path, + store: &ManifestStore, + backend: &VirshBackend, + mut clone: VmRun, + record: &Record, + ancestry: &Ancestry, + recovery: &mut VaultStore<'_>, +) -> Result<()> { + let ssh = SshTransport::new(clone.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, &clone); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + assert_volume(&guest, record)?; + guest.upload( + &butane.join("../skillet/scripts/root-tpm-rebind.sh"), + "/var/tmp/skillet-root-tpm-rebind.sh", + )?; + execute( + &guest, + "sudo", + &[ + "-n", + "bash", + "/var/tmp/skillet-root-tpm-rebind.sh", + &ancestry.original_slot, + ], + Some(record.key.as_bytes()), + )?; + execute( + &guest, + "sudo", + &["-n", "hostnamectl", "set-hostname", &clone.guest_hostname], + None, + )?; + // Separate archive: do not replace the source's original TPM header. + if recovery.load(&path(&clone.identity))?.is_none() { + recovery.save( + &path(&clone.identity), + &Record { + key: Zeroizing::new(record.key.to_string()), + volume: record.volume.clone(), + verified: true, + header: None, + }, + )?; + } + skillet_workstation::root_recovery::provision( + clone.identity.host(), + &path(&clone.identity), + &butane.join("../skillet/scripts/root-recovery.sh"), + recovery, + &guest, + || { + Err(skillet_workstation::root_recovery::RecoveryError::Invalid( + "clone must reuse its recorded recovery key", + )) + }, + )?; + + clone.phase = Phase::Started; + store.save(&clone)?; + backend.stop(&clone)?; + owned(backend, &clone)?; + backend.start(&clone)?; + owned(backend, &clone)?; + println!("PASS: new TPM verified, original binding removed, refreshed clone header archived; cold boot started"); + Ok(()) +} + +fn owned(backend: &VirshBackend, run: &VmRun) -> skillet_vm::Result<()> { + backend + .inspect(run)? + .ok_or_else(|| skillet_vm::Error::Invalid("owned recovery VM absent".into()))? + .validate_owned(run) +} +fn execute( + guest: &impl GuestTransport, + program: &str, + arguments: &[&str], + input: Option<&[u8]>, +) -> Result> { + let out = guest.execute(&GuestCommand { program, arguments }, input)?; + if !out.status.success() { + return Err("recovery guest assertion failed; sensitive output suppressed".into()); + } + Ok(out.stdout) +} +fn assert_volume(guest: &impl GuestTransport, record: &Record) -> Result<()> { + let volume = execute( + guest, + "sudo", + &[ + "-n", + "cryptsetup", + "luksUUID", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + if std::str::from_utf8(&volume)?.trim() != record.volume { + return Err("recovered volume differs".into()); + } + execute( + guest, + "sudo", + &[ + "-n", + "cryptsetup", + "open", + "--test-passphrase", + "--key-file=-", + "/dev/disk/by-partlabel/root", + ], + Some(record.key.as_bytes()), + )?; + Ok(()) +} +fn wait_ssh(guest: &impl GuestTransport) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(180); + while Instant::now() < deadline { + if guest + .execute( + &GuestCommand { + program: "true", + arguments: &[], + }, + None, + ) + .is_ok_and(|out| out.status.success()) + { + return Ok(()); + } + std::thread::sleep(Duration::from_secs(2)); + } + Err("recovery SSH deadline exceeded".into()) +} +fn private_copy(source: &Path, destination: &Path) -> Result<()> { + if fs::symlink_metadata(source)?.file_type().is_symlink() { + return Err("refusing linked source artifact".into()); + } + if source.is_dir() { + fs::create_dir_all(destination)?; + fs::set_permissions(destination, fs::Permissions::from_mode(0o700))?; + for entry in fs::read_dir(source)? { + let entry = entry?; + private_copy(&entry.path(), &destination.join(entry.file_name()))?; + } + } else if source.is_file() { + fs::copy(source, destination)?; + fs::set_permissions(destination, fs::Permissions::from_mode(0o600))?; + } else { + return Err("refusing non-regular clone artifact".into()); + } + Ok(()) +} +fn refuse_provider_journals(source_dir: &Path) -> Result<()> { + // Provider delivery would make a disk clone impersonate live services. + for entry in fs::read_dir(source_dir)? { + let name = entry?.file_name(); + let name = name.to_string_lossy(); + if name.ends_with(".json") && !matches!(name.as_ref(), "vm.json" | "final-status.json") { + return Err( + "source has additional journals; use a fresh base-only encryption VM".into(), + ); + } + } + Ok(()) +} + +fn clone_vm( + butane: &Path, + store: &ManifestStore, + backend: &VirshBackend, + source: &VmRun, + id: &RunIdentity, + port: u16, + record: &Record, +) -> Result<()> { + if source.phase != Phase::Ready || store.run_dir(id).join("vm.json").exists() { + return Err("requires ready source and unused clone instance".into()); + } + skillet_vm::provisioning::validate_ssh_port(port)?; + let source_dir = store.run_dir(&source.identity); + refuse_provider_journals(&source_dir)?; + if backend.contains_name(&id.domain_name())? { + return Err("clone domain already exists".into()); + } + let intent = store.prepare_intent_with_profile( + id, + source.connection.clone(), + port, + &source.source_commit, + source.root_profile, + )?; + let _lock = store.lock(id)?; + let slot = prepare_source(butane, backend, source, record)?; + let dir = store.run_dir(id); + let mut clone = source.clone(); + clone.identity = id.clone(); + clone.uuid = intent.uuid; + clone.guest_hostname = id.domain_name(); + clone.disk = dir.join(format!("{}.qcow2", id.domain_name())); + clone.ignition = dir.join("ignition").join(format!("{}.ign", id.host())); + clone.ssh.port = port; + clone.ssh.identity = dir.join("ssh/id_ed25519"); + clone.ssh.known_hosts = dir.join("ssh/known_hosts"); + clone.phase = Phase::Preparing; + store.save(&clone)?; + fs::write( + dir.join("recovery-source.json"), + serde_json::to_vec_pretty(&Ancestry { + source: source.identity.clone(), + source_uuid: source.uuid, + clone_uuid: clone.uuid, + volume: record.volume.clone(), + original_slot: slot, + source_header_hash: hex::encode(Sha256::digest( + record.header.as_ref().ok_or("source header absent")?, + )), + })?, + )?; + fs::set_permissions( + dir.join("recovery-source.json"), + fs::Permissions::from_mode(0o600), + )?; + let mut stopped = source.clone(); + stopped.phase = Phase::Started; + store.save(&stopped)?; + owned(backend, source)?; + backend.stop(source)?; + if backend.inspect(source)?.ok_or("source absent")?.state != "shut off" { + return Err("source is not stopped".into()); + } + owned(backend, source)?; + let copied = (|| -> Result<()> { + let mut command = Command::new("qemu-img"); + command + .args(["convert", "-f", "qcow2", "-O", "qcow2"]) + .arg(&source.disk) + .arg(&clone.disk); + let output = skillet_vm::capture_command(command, Duration::from_secs(600))?; + if !output.status.success() { + return Err("disk clone failed".into()); + } + owned(backend, source)?; + fs::set_permissions(&clone.disk, fs::Permissions::from_mode(0o600))?; + for name in ["ignition", "ssh", "source"] { + private_copy(&source_dir.join(name), &dir.join(name))?; + } + let known = fs::read_to_string(&clone.ssh.known_hosts)?.replace( + &format!("[127.0.0.1]:{}", source.ssh.port), + &format!("[127.0.0.1]:{port}"), + ); + fs::write(&clone.ssh.known_hosts, known)?; + Ok(()) + })(); + owned(backend, source)?; + backend.start(source)?; + owned(backend, source)?; + copied?; + let xml = skillet_vm::domain_xml::write_recovery_domain_xml( + store, + &clone, + &backend.x86_64_emulator(&clone)?, + )?; + if backend.inspect(&clone)?.is_some() { + return Err("clone domain appeared during preparation".into()); + } + VmBackend::define(backend, &clone, &xml)?; + clone.phase = Phase::Defined; + store.save(&clone)?; + owned(backend, &clone)?; + println!("PASS: archived header restored on tmpfs fixture; stopped disk cloned; independent TPM/NVRAM and unlogged console defined"); + Ok(()) +} +fn require_base_only(guest: &impl GuestTransport) -> Result<()> { + for (program, arguments) in [ + ("sudo", vec!["-n", "podman", "ps", "-aq"]), + ( + "sudo", + vec!["-n", "podman", "secret", "ls", "--format", "{{.Name}}"], + ), + ( + "sudo", + vec![ + "-n", + "find", + "/etc", + "-maxdepth", + "3", + "-name", + "skillet*.cred", + ], + ), + ] { + if !execute(guest, program, &arguments, None)?.is_empty() { + return Err( + "source contains containers or delivered credentials; use a fresh base-only VM" + .into(), + ); + } + } + Ok(()) +} + +fn prepare_source( + butane: &Path, + backend: &VirshBackend, + source: &VmRun, + record: &Record, +) -> Result { + let ssh = SshTransport::new(source.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, source); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + assert_volume(&guest, record)?; + require_base_only(&guest)?; + let metadata = execute( + &guest, + "sudo", + &[ + "-n", + "cryptsetup", + "luksDump", + "--dump-json-metadata", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + let metadata: serde_json::Value = serde_json::from_slice(&metadata)?; + if metadata["keyslots"].as_object().map(serde_json::Map::len) != Some(2) { + return Err("source must have only TPM and independent recovery slots".into()); + } + let bindings = execute( + &guest, + "sudo", + &[ + "-n", + "clevis", + "luks", + "list", + "-d", + "/dev/disk/by-partlabel/root", + ], + None, + )?; + let bindings = std::str::from_utf8(&bindings)?; + if bindings.lines().count() != 1 { + return Err("expected one source TPM binding".into()); + } + let slot = bindings.split_once(':').ok_or("source slot absent")?.0; + if !slot.bytes().all(|b| b.is_ascii_digit()) { + return Err("invalid source slot".into()); + } + guest.upload( + &butane.join("../skillet/integration_tests/root-header-restore.sh"), + "/var/tmp/skillet-header-restore.sh", + )?; + let mut payload = Zeroizing::new(record.key.as_bytes().to_vec()); + payload.extend_from_slice(record.header.as_ref().ok_or("header absent")?); + execute( + &guest, + "sudo", + &["-n", "bash", "/var/tmp/skillet-header-restore.sh"], + Some(&payload), + )?; + execute( + &guest, + "sudo", + &["-n", "tee", "/var/lib/data/.skillet-tpm-loss-sentinel"], + Some(b"TPM replacement preserves encrypted data\n"), + )?; + execute( + &guest, + "sudo", + &[ + "-n", + "sync", + "-f", + "/var/lib/data/.skillet-tpm-loss-sentinel", + ], + None, + )?; + Ok(slot.to_owned()) +} + +fn unlock(backend: &VirshBackend, run: &VmRun, record: &Record) -> Result<()> { + if backend.inspect(run)?.ok_or("clone absent")?.state != "shut off" { + return Err("unlock requires a stopped recovery clone".into()); + } + owned(backend, run)?; + backend.start(run)?; + owned(backend, run)?; + let tty = backend.recovery_console(run)?; + owned(backend, run)?; + let mut terminal = fs::OpenOptions::new() + .read(true) + .write(true) + .custom_flags(nix::libc::O_NONBLOCK | nix::libc::O_NOCTTY) + .open(tty)?; + let mut settings = nix::sys::termios::tcgetattr(&terminal)?; + nix::sys::termios::cfmakeraw(&mut settings); + nix::sys::termios::tcsetattr(&terminal, nix::sys::termios::SetArg::TCSANOW, &settings)?; + let deadline = Instant::now() + Duration::from_secs(180); + let mut output = Zeroizing::new(Vec::new()); + let mut bytes = Zeroizing::new([0u8; 4096]); + let mut prompted = false; + while Instant::now() < deadline { + owned(backend, run)?; + match terminal.read(&mut *bytes) { + Ok(count) => { + output.extend_from_slice(&bytes[..count]); + if output.len() > 65536 { + output.drain(..32768); + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => (), + Err(error) => return Err(error.into()), + } + let text = String::from_utf8_lossy(&output); + if text.contains("Please enter passphrase") || text.contains("Enter passphrase for") { + prompted = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + if !prompted { + return Err("fresh TPM did not reach the recovery prompt within deadline".into()); + } + let ssh = SshTransport::new(run.ssh.clone(), HostKeyPolicy::Verify)?; + let guard = || owned(backend, run); + let guest = OwnershipCheckedTransport::new(&ssh, &guard); + if guest + .execute( + &GuestCommand { + program: "true", + arguments: &[], + }, + None, + ) + .is_ok_and(|out| out.status.success()) + { + return Err("fresh TPM unexpectedly unlocked".into()); + } + owned(backend, run)?; + terminal.write_all(record.key.as_bytes())?; + terminal.write_all(b"\r")?; + // Stop collecting console output before credential transfer; never print it. + drop(terminal); + drop(output); + drop(bytes); + owned(backend, run)?; + wait_ssh(&guest)?; + assert_volume(&guest, record)?; + println!("PASS: fresh TPM blocked unattended boot; vault recovery passphrase booted the same encrypted volume"); + Ok(()) +} + +#[cfg(test)] +#[path = "tpm_recovery/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/examples/tpm_recovery/tests.rs b/ublue/skillet/crates/workstation/examples/tpm_recovery/tests.rs new file mode 100644 index 00000000..4d613dc3 --- /dev/null +++ b/ublue/skillet/crates/workstation/examples/tpm_recovery/tests.rs @@ -0,0 +1,43 @@ +use super::*; +use std::{os::unix::process::ExitStatusExt as _, process::Output}; +struct Guest { + blocked: &'static str, +} +impl GuestTransport for Guest { + fn upload(&self, _: &Path, _: &str) -> skillet_vm::Result<()> { + panic!("preflight must not upload") + } + fn execute( + &self, + command: &GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + assert!(input.is_none()); + let blocked = !self.blocked.is_empty() && command.arguments.contains(&self.blocked); + Ok(Output { + status: std::process::ExitStatus::from_raw(0), + stdout: if blocked { + b"existing resource\n".to_vec() + } else { + vec![] + }, + stderr: vec![], + }) + } +} +#[test] +fn base_only_preflight_rejects_containers_and_delivered_credentials() { + require_base_only(&Guest { blocked: "" }).unwrap(); + for blocked in ["ps", "secret", "find"] { + assert!(require_base_only(&Guest { blocked }).is_err()); + } +} +#[test] +fn readiness_evidence_is_not_provider_ownership() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("vm.json"), "manifest").unwrap(); + fs::write(dir.path().join("final-status.json"), "readiness").unwrap(); + refuse_provider_journals(dir.path()).unwrap(); + fs::write(dir.path().join("cloudflare.json"), "ownership").unwrap(); + assert!(refuse_provider_journals(dir.path()).is_err()); +} diff --git a/ublue/skillet/crates/workstation/src/cloudflare/mod.rs b/ublue/skillet/crates/workstation/src/cloudflare/mod.rs new file mode 100644 index 00000000..e47197d5 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/cloudflare/mod.rs @@ -0,0 +1,824 @@ +use cloudflare::framework::{ + auth::Credentials, + client::{blocking_api::HttpApiClient, ClientConfig}, + endpoint::{spec::EndpointSpec, Method, RequestBody}, + response::ApiSuccess, + Environment, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error as StdError, + net::IpAddr, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; + +const API_BASE: &str = "https://api.cloudflare.com/client/v4/"; +const RECORD_TTL: u32 = 120; + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; + +#[derive(Debug, Error)] +pub enum CloudflareError { + #[error("{context}: {source}")] + Source { + context: String, + #[source] + source: Box, + }, + #[error("{0}")] + Invalid(String), +} + +pub type Result = std::result::Result; + +fn source(context: impl Into, error: E) -> CloudflareError +where + E: StdError + Send + Sync + 'static, +{ + CloudflareError::Source { + context: context.into(), + source: Box::new(error), + } +} + +trait ResultContext { + fn context(self, context: impl Into) -> Result; +} + +impl ResultContext for std::result::Result +where + E: StdError + Send + Sync + 'static, +{ + fn context(self, context: impl Into) -> Result { + self.map_err(|error| source(context, error)) + } +} + +impl From for CloudflareError { + fn from(error: serde_json::Error) -> Self { + source("Cloudflare JSON", error) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RecordRef { + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub record_type: String, + pub content: String, + pub comment: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct IssuedToken { + pub id: String, + pub value: String, + pub expires_on: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Zone { + pub id: String, + pub name: String, + pub account: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ZoneAccount { + pub id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct OwnedDns { + pub marker: String, + pub records: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesiredRecord { + pub name: String, + pub record_type: String, + pub content: String, +} + +pub struct Cloudflare { + base: String, +} + +impl Default for Cloudflare { + fn default() -> Self { + Self::new() + } +} + +impl Cloudflare { + pub fn new() -> Self { + Self { + base: API_BASE.to_string(), + } + } + + #[cfg(test)] + fn with_base(base: &str) -> Self { + Self { + base: base.trim_end_matches('/').to_string() + "/", + } + } + + fn request( + &self, + token: &str, + method: Method, + path: String, + query: Option, + body: Option, + ) -> Result> { + let endpoint = ApiEndpoint { + method, + path, + query, + body: body + .map(|value| serde_json::to_string(&value)) + .transpose() + .context("encoding Cloudflare API request")?, + }; + let environment = if self.base == API_BASE { + Environment::Production + } else { + Environment::Custom(self.base.clone()) + }; + let client = HttpApiClient::new( + Credentials::UserAuthToken { + token: token.to_string(), + }, + ClientConfig::default(), + environment, + ) + .context("building cloudflare-rs client")?; + client + .request(&endpoint) + .map_err(|error| source("Cloudflare API request failed", error)) + } + + fn result( + &self, + token: &str, + method: Method, + path: String, + query: Option, + body: Option, + ) -> Result { + Ok(self.request(token, method, path, query, body)?.result.0) + } + + pub fn zone(&self, token: &str, zone_id: &str) -> Result { + validate_zone_id(zone_id)?; + let value = self + .result(token, Method::GET, format!("zones/{zone_id}"), None, None) + .context("reading configured Cloudflare zone; check Zone > Zone > Read permission and access to the configured zone")?; + serde_json::from_value(value).context("decoding Cloudflare zone") + } + + pub fn account_id(zone: &Zone) -> Result<&str> { + zone.account + .as_ref() + .map(|account| account.id.as_str()) + .filter(|id| validate_token_id(id).is_ok()) + .ok_or_else(|| { + CloudflareError::Invalid( + "Cloudflare zone response does not include a valid account ID".into(), + ) + }) + } + + fn permission_groups(&self, token: &str, account_id: &str) -> Result<(String, String)> { + validate_token_id(account_id).context("validating Cloudflare account ID")?; + let value = self.result( + token, + Method::GET, + format!("accounts/{account_id}/tokens/permission_groups"), + Some("per_page=500".to_string()), + None, + ).context("discovering Cloudflare account-token permission groups; the issuer needs Account > API Tokens > Read or Write")?; + let groups = value.as_array().ok_or_else(|| { + CloudflareError::Invalid("Cloudflare permission group response is malformed".into()) + })?; + let mut zone_read = None; + let mut dns_write = None; + for group in groups { + if group.get("is_selectable").and_then(Value::as_bool) == Some(false) { + continue; + } + let Some(id) = group.get("id").and_then(Value::as_str) else { + continue; + }; + let name = group + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + let scopes = group + .get("scopes") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + if name == "Zone Read" && scopes.contains("com.cloudflare.api.account.zone") { + zone_read = Some(id.to_string()); + } + if matches!(name, "DNS Write" | "DNS Edit") + && scopes.contains("com.cloudflare.api.account.zone") + { + dns_write = Some(id.to_string()); + } + } + match (zone_read, dns_write) { + (Some(read), Some(write)) => Ok((read, write)), + _ => Err(CloudflareError::Invalid("Cloudflare did not expose selectable Zone Read and DNS Write permission groups for this account".into())), + } + } + + pub fn create_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + validate_zone_id(zone_id)?; + let (zone_read, dns_write) = self.permission_groups(creator_token, account_id)?; + let mut payload = json!({ + "name": name, + "policies": [{ + "effect": "allow", + "permission_groups": [{"id": zone_read}, {"id": dns_write}], + "resources": {format!("com.cloudflare.api.account.zone.{zone_id}"): "*"} + }] + }); + if let Some(lifetime) = lifetime { + payload["expires_on"] = json!(expiry_rfc3339(lifetime)?); + } + let value = self.result( + creator_token, + Method::POST, + format!("accounts/{account_id}/tokens"), + None, + Some(payload), + )?; + serde_json::from_value(value).context("decoding newly issued Cloudflare token") + } + + pub fn replace_named_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + // A previous create may have reached Cloudflare even when its response + // was lost. Deterministic names let the next invocation find and + // revoke that orphan before issuing a replacement. + for id in self.token_ids_by_name(creator_token, account_id, name)? { + self.revoke_token(creator_token, account_id, &id)?; + } + self.create_zone_token(creator_token, zone_id, account_id, name, lifetime) + } + + pub fn token_ids_by_name( + &self, + creator_token: &str, + account_id: &str, + name: &str, + ) -> Result> { + validate_token_id(account_id).context("validating Cloudflare account ID")?; + let mut matches = Vec::new(); + for page in 1..=100_u32 { + let response = self.request( + creator_token, + Method::GET, + format!("accounts/{account_id}/tokens"), + Some(format!("page={page}&per_page=100")), + None, + )?; + let values = response.result.0.as_array().ok_or_else(|| { + CloudflareError::Invalid("Cloudflare token list response is malformed".into()) + })?; + matches.extend(values.iter().filter_map(|token| { + (token.get("name").and_then(Value::as_str) == Some(name)) + .then(|| token.get("id").and_then(Value::as_str)) + .flatten() + .filter(|id| validate_token_id(id).is_ok()) + .map(ToOwned::to_owned) + })); + let total_pages = response + .result_info + .as_ref() + .and_then(|info| info.get("total_pages")) + .and_then(Value::as_u64) + .unwrap_or(u64::from(page)); + if u64::from(page) >= total_pages { + break; + } + } + Ok(matches) + } + + pub fn revoke_token( + &self, + creator_token: &str, + account_id: &str, + token_id: &str, + ) -> Result<()> { + validate_token_id(account_id).context("validating Cloudflare account ID")?; + validate_token_id(token_id)?; + self.result( + creator_token, + Method::DELETE, + format!("accounts/{account_id}/tokens/{token_id}"), + None, + None, + )?; + Ok(()) + } + + pub fn list_records(&self, token: &str, zone_id: &str) -> Result> { + validate_zone_id(zone_id)?; + let mut records = Vec::new(); + for page in 1..=100_u32 { + let response = self.request( + token, + Method::GET, + format!("zones/{zone_id}/dns_records"), + Some(format!("page={page}&per_page=100")), + None, + )?; + let batch: Vec = serde_json::from_value(response.result.0)?; + let count = batch.len(); + records.extend(batch); + let total_pages = response + .result_info + .as_ref() + .and_then(|info| info.get("total_pages")) + .and_then(Value::as_u64) + .unwrap_or(u64::from(page)); + if u64::from(page) >= total_pages || count == 0 { + break; + } + } + Ok(records) + } + + /// Return only records carrying this DDNS instance marker at its exact + /// pre-journaled names. Any marker outside that set is an ownership error. + pub fn list_owned_ddns_records( + &self, + token: &str, + zone_id: &str, + marker: &str, + names: &[String], + ) -> Result> { + validate_zone_id(zone_id)?; + if marker.is_empty() || names.is_empty() { + return Err(CloudflareError::Invalid( + "invalid DDNS ownership scope".into(), + )); + } + let names = names + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect::>(); + let mut owned = Vec::new(); + for record in self.list_records(token, zone_id)? { + if record.comment.as_deref() != Some(marker) { + continue; + } + if record.record_type != "A" || !names.contains(&record.name.to_ascii_lowercase()) { + return Err(CloudflareError::Invalid( + "DDNS ownership marker appears outside the recorded A-record names".into(), + )); + } + owned.push(record); + } + Ok(owned) + } + + /// Remove only exact test-owned DDNS records after checking both the + /// caller's allowlist and the immutable record IDs captured by the journal. + pub fn remove_owned_ddns_records( + &self, + token: &str, + zone_id: &str, + marker: &str, + names: &[String], + recorded_ids: &[String], + ) -> Result<()> { + let owned = self.list_owned_ddns_records(token, zone_id, marker, names)?; + let current = self.list_records(token, zone_id)?; + for record in &owned { + if !recorded_ids.is_empty() && !recorded_ids.contains(&record.id) { + return Err(CloudflareError::Invalid( + "test DDNS record identity changed; refusing cleanup".into(), + )); + } + } + for id in recorded_ids { + if let Some(record) = current.iter().find(|record| &record.id == id) { + if record.comment.as_deref() != Some(marker) + || record.record_type != "A" + || !names + .iter() + .any(|name| name.eq_ignore_ascii_case(&record.name)) + { + return Err(CloudflareError::Invalid( + "journaled DDNS record no longer matches its recorded owner".into(), + )); + } + } + } + for record in owned { + self.delete_record(token, zone_id, &record.id)?; + } + Ok(()) + } + + fn create_record( + &self, + token: &str, + zone_id: &str, + name: &str, + record_type: &str, + content: &str, + marker: &str, + ) -> Result { + let value = self.result( + token, + Method::POST, + format!("zones/{zone_id}/dns_records"), + None, + Some(json!({ + "type": record_type, + "name": name, + "content": content, + "ttl": RECORD_TTL, + "proxied": false, + "comment": marker + })), + )?; + serde_json::from_value(value).context("decoding created DNS record") + } + + fn update_record( + &self, + token: &str, + zone_id: &str, + existing: &RecordRef, + content: &str, + marker: &str, + ) -> Result { + validate_record_id(&existing.id)?; + let value = self.result( + token, + Method::PATCH, + format!("zones/{zone_id}/dns_records/{}", existing.id), + None, + Some(json!({ + "type": existing.record_type, + "name": existing.name, + "content": content, + "ttl": RECORD_TTL, + "proxied": false, + "comment": marker + })), + )?; + serde_json::from_value(value).context("decoding updated DNS record") + } + + fn delete_record(&self, token: &str, zone_id: &str, id: &str) -> Result<()> { + validate_record_id(id)?; + self.result( + token, + Method::DELETE, + format!("zones/{zone_id}/dns_records/{id}"), + None, + None, + )?; + Ok(()) + } + + pub fn reconcile_dns( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + desired: &[DesiredRecord], + ) -> Result { + validate_zone_id(zone_id)?; + validate_fqdn(ui_domain)?; + if marker.is_empty() || marker.len() > 255 { + return Err(CloudflareError::Invalid( + "invalid Skillet DNS ownership marker".into(), + )); + } + let mut wanted: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + for record in desired { + validate_fqdn(&record.name)?; + validate_record_content(&record.record_type, &record.content)?; + wanted + .entry((record.name.to_ascii_lowercase(), record.record_type.clone())) + .or_default() + .insert(record.content.clone()); + } + let mut records = self.list_records(token, zone_id)?; + let desired_names = desired + .iter() + .map(|record| record.name.to_ascii_lowercase()) + .collect::>(); + for record in records + .iter() + .filter(|record| desired_names.contains(&record.name.to_ascii_lowercase())) + { + if record.comment.as_deref() != Some(marker) { + return Err(CloudflareError::Invalid(format!( + "DNS name {} has a record not owned by this Skillet deployment", + record.name + ))); + } + } + + let mut retained = Vec::new(); + for ((name, record_type), contents) in &wanted { + let mut existing = records + .iter_mut() + .filter(|record| { + record.name.eq_ignore_ascii_case(name) + && record.record_type == *record_type + && record.comment.as_deref() == Some(marker) + }) + .collect::>(); + for content in contents { + if let Some(index) = existing + .iter() + .position(|record| record.content == *content) + { + retained.push(existing.remove(index).clone()); + } else if let Some(record) = existing.pop() { + let updated = self.update_record(token, zone_id, record, content, marker)?; + retained.push(updated); + } else { + retained.push(self.create_record( + token, + zone_id, + name, + record_type, + content, + marker, + )?); + } + } + for duplicate in existing { + self.delete_record(token, zone_id, &duplicate.id)?; + } + } + let retained_ids = retained + .iter() + .map(|record| record.id.as_str()) + .collect::>(); + for stale in records + .drain(..) + .filter(|record| record.comment.as_deref() == Some(marker)) + { + if stale.name != ui_domain && !stale.name.ends_with(&format!(".{ui_domain}")) { + return Err(CloudflareError::Invalid("Skillet ownership marker is present outside the configured UI domain; refusing cleanup".into())); + } + if !retained_ids.contains(stale.id.as_str()) { + self.delete_record(token, zone_id, &stale.id)?; + } + } + retained.sort_by(|left, right| { + (&left.name, &left.record_type, &left.content).cmp(&( + &right.name, + &right.record_type, + &right.content, + )) + }); + Ok(OwnedDns { + marker: marker.to_string(), + records: retained, + }) + } + + pub fn remove_dns_marker( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + ) -> Result<()> { + validate_zone_id(zone_id)?; + validate_fqdn(ui_domain)?; + for record in self.list_records(token, zone_id)? { + if record.comment.as_deref() != Some(marker) { + continue; + } + if !matches!(record.record_type.as_str(), "A" | "AAAA" | "CNAME") + || !(record.name == ui_domain || record.name.ends_with(&format!(".{ui_domain}"))) + { + return Err(CloudflareError::Invalid("Skillet ownership marker appears on an unexpected DNS record; refusing cleanup".into())); + } + self.delete_record(token, zone_id, &record.id)?; + } + Ok(()) + } +} + +pub fn desired_records( + machine_name: &str, + addresses: &BTreeSet, + sites: &skillet_caddy::CaddySites, +) -> Result> { + validate_fqdn(machine_name)?; + let mut result = Vec::new(); + let mut has_v4 = false; + let mut has_v6 = false; + for address in addresses { + let parsed = address + .parse::() + .context("invalid Tailscale IP address")?; + has_v4 |= parsed.is_ipv4(); + has_v6 |= parsed.is_ipv6(); + result.push(DesiredRecord { + name: machine_name.to_string(), + record_type: if parsed.is_ipv4() { "A" } else { "AAAA" }.to_string(), + content: parsed.to_string(), + }); + } + if !has_v4 || !has_v6 { + return Err(CloudflareError::Invalid( + "Tailscale device must have both IPv4 and IPv6 addresses before publishing DNS".into(), + )); + } + for service in &sites.services { + result.push(DesiredRecord { + name: service.hostname.clone(), + record_type: "CNAME".to_string(), + content: machine_name.to_string(), + }); + for alias in &service.aliases { + result.push(DesiredRecord { + name: alias.clone(), + record_type: "CNAME".to_string(), + content: service.hostname.clone(), + }); + } + } + Ok(result) +} + +pub fn validate_zone_id(id: &str) -> Result<()> { + if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(CloudflareError::Invalid( + "Cloudflare Zone ID must be 32 hexadecimal characters".into(), + )); + } + Ok(()) +} + +pub fn validate_token_id(id: &str) -> Result<()> { + if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(CloudflareError::Invalid( + "Cloudflare token ID is malformed".into(), + )); + } + Ok(()) +} + +fn validate_record_id(id: &str) -> Result<()> { + if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(CloudflareError::Invalid( + "Cloudflare DNS record ID is malformed".into(), + )); + } + Ok(()) +} + +fn validate_record_content(record_type: &str, content: &str) -> Result<()> { + match record_type { + "A" | "AAAA" => { + let address = content + .parse::() + .context("invalid IP in DNS plan")?; + if (record_type == "A") != address.is_ipv4() { + return Err(CloudflareError::Invalid( + "DNS record address family does not match type".into(), + )); + } + } + "CNAME" => validate_fqdn(content)?, + _ => { + return Err(CloudflareError::Invalid( + "unsupported managed DNS record type".into(), + )) + } + } + Ok(()) +} + +fn validate_fqdn(name: &str) -> Result<()> { + skillet_caddy::validate_domain(name) + .map_err(|error| CloudflareError::Invalid(error.to_string())) +} + +fn expiry_rfc3339(lifetime: Duration) -> Result { + let seconds = SystemTime::now() + .checked_add(lifetime) + .ok_or_else(|| CloudflareError::Invalid("Cloudflare token expiry overflow".into()))? + .duration_since(UNIX_EPOCH) + .map_err(|error| source("calculating Cloudflare token expiry", error))? + .as_secs(); + let days = seconds / 86_400; + let time = seconds % 86_400; + let (year, month, day) = civil_from_days( + i64::try_from(days).map_err(|error| source("converting Cloudflare token expiry", error))?, + ); + if !(1..=9999).contains(&year) { + return Err(CloudflareError::Invalid( + "Cloudflare token expiry is outside RFC3339 range".into(), + )); + } + Ok(format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + time / 3600, + (time % 3600) / 60, + time % 60 + )) +} + +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let days = days + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + (year, month, day) +} + +#[derive(Clone, Debug)] +struct JsonResult(Value); + +impl<'de> Deserialize<'de> for JsonResult { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + Value::deserialize(deserializer).map(Self) + } +} + +impl cloudflare::framework::response::ApiResult for JsonResult {} + +#[derive(Debug)] +struct ApiEndpoint { + method: Method, + path: String, + query: Option, + body: Option, +} + +impl EndpointSpec for ApiEndpoint { + type JsonResponse = JsonResult; + type ResponseType = ApiSuccess; + + fn method(&self) -> Method { + self.method.clone() + } + + fn path(&self) -> String { + self.path.clone() + } + + fn query(&self) -> Option { + self.query.clone() + } + + fn body(&self) -> Option> { + self.body.clone().map(RequestBody::Json) + } +} diff --git a/ublue/skillet/crates/workstation/src/cloudflare/tests.rs b/ublue/skillet/crates/workstation/src/cloudflare/tests.rs new file mode 100644 index 00000000..d0dfd4c8 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/cloudflare/tests.rs @@ -0,0 +1,500 @@ +use super::*; +use std::{ + io::{Read, Write}, + net::TcpListener, + thread, +}; + +#[test] +fn desired_dns_contains_dual_stack_machine_ui_and_alias_records() { + let services = vec![skillet_caddy::UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + tailnet_identity: false, + aliases: vec!["sync.{host}".to_string()], + }]; + let sites = skillet_caddy::CaddySites::from_host( + "clamps", + &skillet_caddy::UiEnvironment { + ui_domain: "example.test".to_string(), + acme_staging: true, + }, + &services, + ) + .expect("valid sites"); + let addresses = BTreeSet::from(["100.64.0.2".to_string(), "fd7a:115c:a1e0::2".to_string()]); + let records = desired_records(&sites.machine_hostname, &addresses, &sites) + .expect("valid dual-stack records"); + assert!(records.contains(&DesiredRecord { + name: "clamps.example.test".to_string(), + record_type: "A".to_string(), + content: "100.64.0.2".to_string(), + })); + assert!(records.contains(&DesiredRecord { + name: "clamps.example.test".to_string(), + record_type: "AAAA".to_string(), + content: "fd7a:115c:a1e0::2".to_string(), + })); + assert!(records.contains(&DesiredRecord { + name: "syncthing.clamps.example.test".to_string(), + record_type: "CNAME".to_string(), + content: "clamps.example.test".to_string(), + })); + assert!(records.contains(&DesiredRecord { + name: "sync.clamps.example.test".to_string(), + record_type: "CNAME".to_string(), + content: "syncthing.clamps.example.test".to_string(), + })); +} + +#[test] +fn desired_dns_rejects_missing_address_family() { + let sites = skillet_caddy::CaddySites::from_host( + "clamps", + &skillet_caddy::UiEnvironment { + ui_domain: "example.test".to_string(), + acme_staging: true, + }, + &[skillet_caddy::UiService { + name: "syncthing".to_string(), + upstream: "syncthing".to_string(), + port: 8384, + tailnet_identity: false, + aliases: Vec::new(), + }], + ) + .expect("valid sites"); + let addresses = BTreeSet::from(["100.64.0.2".to_string()]); + assert!(desired_records(&sites.machine_hostname, &addresses, &sites).is_err()); +} + +#[test] +fn sdk_transport_calls_cloudflare_with_bearer_token() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("client"); + let mut request = [0_u8; 4096]; + let count = stream.read(&mut request).expect("request"); + let request = String::from_utf8_lossy(&request[..count]).to_ascii_lowercase(); + assert!(request.contains("authorization: bearer test-token")); + assert!(request.contains("/client/v4/zones/0123456789abcdef0123456789abcdef")); + let body = r#"{"success":true,"errors":[],"messages":[],"result":{"id":"0123456789abcdef0123456789abcdef","name":"example.test","account":{"id":"abcdef0123456789abcdef0123456789"}}}"#; + write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("response"); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let zone = api + .zone("test-token", "0123456789abcdef0123456789abcdef") + .expect("zone request"); + assert_eq!(zone.name, "example.test"); + server.join().expect("server thread"); +} + +#[test] +fn forbidden_zone_lookup_reports_permission_context_without_zone_id() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("client"); + let _request = read_request(&mut stream); + let body = r#"{"success":false,"errors":[{"code":9109,"message":"Unauthorized to access requested resource"}],"messages":[],"result":null}"#; + write!(stream, "HTTP/1.1 403 Forbidden\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("response"); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let error = api + .zone("issuer-token", "0123456789abcdef0123456789abcdef") + .expect_err("forbidden lookup"); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("reading configured Cloudflare zone")); + assert!(diagnostic.contains("Zone > Zone > Read")); + assert!(diagnostic.contains("403")); + assert!(!diagnostic.contains("issuer-token")); + assert!(!diagnostic.contains("0123456789abcdef0123456789abcdef")); + server.join().expect("server thread"); +} + +#[test] +fn discovers_permission_group_ids_and_creates_scoped_token_through_sdk() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + for (expected_path, body) in [ + ( + "/client/v4/accounts/abcdef0123456789abcdef0123456789/tokens/permission_groups", + r#"{"success":true,"errors":[],"messages":[],"result":[{"id":"zone-read-id","name":"Zone Read","scopes":["com.cloudflare.api.account.zone"],"is_selectable":true},{"id":"dns-write-id","name":"DNS Write","scopes":["com.cloudflare.api.account.zone"],"is_selectable":true}]}"#, + ), + ( + "/client/v4/accounts/abcdef0123456789abcdef0123456789/tokens", + r#"{"success":true,"errors":[],"messages":[],"result":{"id":"0123456789abcdef0123456789abcdef","value":"child-secret","expires_on":"2026-10-02T00:00:00Z"}}"#, + ), + ] { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer issuer-token")); + assert!(request.contains(expected_path)); + if expected_path.ends_with("/tokens") { + let (_, request_body) = request.split_once("\r\n\r\n").expect("POST body"); + let payload: Value = serde_json::from_str(request_body).expect("JSON payload"); + assert_eq!( + payload["policies"][0]["permission_groups"][0]["id"], + "zone-read-id" + ); + assert_eq!( + payload["policies"][0]["permission_groups"][1]["id"], + "dns-write-id" + ); + assert_eq!( + payload["policies"][0]["resources"] + ["com.cloudflare.api.account.zone.0123456789abcdef0123456789abcdef"], + "*" + ); + assert_eq!(payload["name"], "skillet:test:clamps-smoke"); + assert!(payload.get("expires_on").is_some()); + } + write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("response"); + } + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let token = api + .create_zone_token( + "issuer-token", + "0123456789abcdef0123456789abcdef", + "abcdef0123456789abcdef0123456789", + "skillet:test:clamps-smoke", + Some(Duration::from_mins(30)), + ) + .expect("scoped token"); + assert_eq!(token.value, "child-secret"); + assert_eq!(token.id, "0123456789abcdef0123456789abcdef"); + server.join().expect("server thread"); +} + +#[test] +fn lists_and_revokes_tokens_through_account_endpoints() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + for (expected, body) in [ + ( + "GET /client/v4/accounts/abcdef0123456789abcdef0123456789/tokens?", + r#"{"success":true,"errors":[],"messages":[],"result":[{"id":"0123456789abcdef0123456789abcdef","name":"skillet:test:clamps-smoke"}],"result_info":{"total_pages":1}}"#, + ), + ( + "DELETE /client/v4/accounts/abcdef0123456789abcdef0123456789/tokens/0123456789abcdef0123456789abcdef", + r#"{"success":true,"errors":[],"messages":[],"result":{}}"#, + ), + ] { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + assert!(request.starts_with(expected)); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer issuer-token")); + write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("response"); + } + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let ids = api + .token_ids_by_name( + "issuer-token", + "abcdef0123456789abcdef0123456789", + "skillet:test:clamps-smoke", + ) + .expect("list account tokens"); + assert_eq!(ids, ["0123456789abcdef0123456789abcdef"]); + api.revoke_token("issuer-token", "abcdef0123456789abcdef0123456789", &ids[0]) + .expect("revoke account token"); + server.join().expect("server thread"); +} + +#[test] +fn named_token_retry_recovers_and_replaces_a_token_after_ambiguous_creation() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let mut tokens: Vec = Vec::new(); + let mut post_count = 0; + for _ in 0..7 { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + let line = request.lines().next().expect("request line"); + let (headers, body) = request.split_once("\r\n\r\n").expect("headers"); + assert!(headers + .to_ascii_lowercase() + .contains("authorization: bearer issuer-token")); + if line.starts_with("GET /client/v4/accounts/") && line.contains("/tokens?") { + send_json(&mut stream, 200, &json!({"result": tokens})); + } else if line.starts_with("GET /client/v4/accounts/") + && line.contains("/tokens/permission_groups?") + { + send_json( + &mut stream, + 200, + &json!({"result": [ + {"id":"zone-read-id", "name":"Zone Read", "scopes":["com.cloudflare.api.account.zone"], "is_selectable":true}, + {"id":"dns-write-id", "name":"DNS Write", "scopes":["com.cloudflare.api.account.zone"], "is_selectable":true} + ]}), + ); + } else if line.starts_with("DELETE /client/v4/accounts/") { + let id = line + .split_whitespace() + .nth(1) + .and_then(|path| path.split('/').next_back()) + .expect("token ID"); + tokens.retain(|token| token["id"] != id); + send_json(&mut stream, 200, &json!({"result": {}})); + } else if line.starts_with("POST /client/v4/accounts/") && line.contains("/tokens ") { + let payload: Value = serde_json::from_str(body).expect("token payload"); + post_count += 1; + let id = if post_count == 1 { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } else { + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }; + tokens.push(json!({"id":id, "name":payload["name"]})); + if post_count == 1 { + // The API committed the token, but the caller saw a failed + // response. This models the retry ambiguity. + send_json(&mut stream, 500, &json!({"errors":[{"code":1}]})); + } else { + send_json( + &mut stream, + 200, + &json!({"result":{"id":id, "value":"replacement-secret"}}), + ); + } + } else { + panic!("unexpected mock Cloudflare request: {line}"); + } + } + assert_eq!(post_count, 2); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0]["id"], "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let account = "abcdef0123456789abcdef0123456789"; + let zone = "0123456789abcdef0123456789abcdef"; + let name = "skillet:production:clamps"; + + assert!(api + .replace_named_zone_token("issuer-token", zone, account, name, None) + .is_err()); + let retry = api + .replace_named_zone_token("issuer-token", zone, account, name, None) + .expect("retry replaces ambiguous first issuance"); + assert_eq!(retry.id, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + assert_eq!(retry.value, "replacement-secret"); + server.join().expect("server thread"); +} + +#[test] +fn dns_reconcile_retry_does_not_duplicate_a_record_after_lost_create_response() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let mut records = Vec::::new(); + let mut posts = 0; + for _ in 0..4 { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + let line = request.lines().next().expect("request line"); + if line.starts_with("GET /client/v4/zones/") { + send_json(&mut stream, 200, &json!({"result": records})); + } else if line.starts_with("POST /client/v4/zones/") { + let (_, body) = request.split_once("\r\n\r\n").expect("POST body"); + let payload: Value = serde_json::from_str(body).expect("record payload"); + posts += 1; + let id = format!("{posts:032x}"); + let record = json!({ + "id": id, + "name": payload["name"], + "type": payload["type"], + "content": payload["content"], + "comment": payload["comment"] + }); + records.push(record.clone()); + if posts == 2 { + send_json(&mut stream, 500, &json!({"errors":[{"code":1}]})); + } else { + send_json(&mut stream, 200, &json!({"result": record})); + } + } else { + panic!("unexpected mock Cloudflare request: {line}"); + } + } + assert_eq!(posts, 2); + assert_eq!(records.len(), 2); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let desired = [ + DesiredRecord { + name: "clamps.example.test".to_string(), + record_type: "A".to_string(), + content: "100.64.0.2".to_string(), + }, + DesiredRecord { + name: "syncthing.clamps.example.test".to_string(), + record_type: "CNAME".to_string(), + content: "clamps.example.test".to_string(), + }, + ]; + assert!(api + .reconcile_dns( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet:test:clamps:smoke", + "example.test", + &desired, + ) + .is_err()); + let reconciled = api + .reconcile_dns( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet:test:clamps:smoke", + "example.test", + &desired, + ) + .expect("retry adopts both previously created records"); + assert_eq!(reconciled.records.len(), 2); + server.join().expect("server thread"); +} + +#[test] +fn dns_cleanup_retry_finishes_after_a_partial_delete_failure() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let mut records = vec![ + json!({"id":"11111111111111111111111111111111", "name":"clamps.example.test", "type":"A", "content":"100.64.0.2", "comment":"skillet:test:clamps:smoke"}), + json!({"id":"22222222222222222222222222222222", "name":"syncthing.clamps.example.test", "type":"CNAME", "content":"clamps.example.test", "comment":"skillet:test:clamps:smoke"}), + json!({"id":"33333333333333333333333333333333", "name":"other.example.test", "type":"A", "content":"192.0.2.4", "comment":"other-owner"}), + ]; + let mut fail_once = true; + for _ in 0..5 { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + let line = request.lines().next().expect("request line"); + if line.starts_with("GET /client/v4/zones/") { + send_json(&mut stream, 200, &json!({"result": records})); + } else if line.starts_with("DELETE /client/v4/zones/") { + let id = line + .split_whitespace() + .nth(1) + .and_then(|path| path.split('/').next_back()) + .expect("record ID"); + if id.starts_with('2') && fail_once { + fail_once = false; + send_json(&mut stream, 500, &json!({"errors":[{"code":1}]})); + } else { + records.retain(|record| record["id"] != id); + send_json(&mut stream, 200, &json!({"result": {}})); + } + } else { + panic!("unexpected mock Cloudflare request: {line}"); + } + } + assert_eq!(records.len(), 1); + assert_eq!(records[0]["comment"], "other-owner"); + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + assert!(api + .remove_dns_marker( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet:test:clamps:smoke", + "example.test", + ) + .is_err()); + api.remove_dns_marker( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet:test:clamps:smoke", + "example.test", + ) + .expect("retry removes remaining owned record"); + server.join().expect("server thread"); +} + +#[test] +fn ddns_cleanup_refuses_an_unjournaled_owned_record_before_deleting() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener"); + let address = listener.local_addr().expect("address"); + let server = thread::spawn(move || { + let records = json!([ + {"id":"11111111111111111111111111111111", "name":"host.example.test", "type":"A", "content":"203.0.113.10", "comment":"skillet-ddns:test:clamps-ddns"}, + {"id":"22222222222222222222222222222222", "name":"host.example.test", "type":"A", "content":"203.0.113.11", "comment":"skillet-ddns:test:clamps-ddns"} + ]); + for _ in 0..2 { + let (mut stream, _) = listener.accept().expect("client"); + let request = read_request(&mut stream); + assert!(request.starts_with("GET /client/v4/zones/")); + send_json(&mut stream, 200, &json!({"result": records})); + } + }); + let api = Cloudflare::with_base(&format!("http://{address}/client/v4/")); + let error = api + .remove_owned_ddns_records( + "child-token", + "0123456789abcdef0123456789abcdef", + "skillet-ddns:test:clamps-ddns", + &["host.example.test".into()], + &["11111111111111111111111111111111".into()], + ) + .expect_err("unrecorded duplicate must prevent cleanup"); + assert!(error.to_string().contains("identity changed")); + server.join().expect("server thread"); +} + +fn send_json(stream: &mut std::net::TcpStream, status: u16, value: &Value) { + let success = (200..300).contains(&status); + let body = json!({ + "success": success, + "errors": value.get("errors").cloned().unwrap_or_else(|| json!([])), + "messages": [], + "result": value.get("result").cloned().unwrap_or(Value::Null), + "result_info": value.get("result_info").cloned().unwrap_or(Value::Null) + }) + .to_string(); + let phrase = if success { + "OK" + } else { + "Internal Server Error" + }; + write!(stream, "HTTP/1.1 {status} {phrase}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).expect("mock response"); +} + +fn read_request(stream: &mut std::net::TcpStream) -> String { + let mut request = Vec::new(); + let mut chunk = [0_u8; 2048]; + loop { + let count = stream.read(&mut chunk).expect("request bytes"); + request.extend_from_slice(&chunk[..count]); + let text = String::from_utf8_lossy(&request); + let Some((headers, body)) = text.split_once("\r\n\r\n") else { + continue; + }; + let body_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or_default(); + if body.len() >= body_length { + return text.into_owned(); + } + } +} + +#[test] +fn zone_and_token_identifiers_are_validated() { + assert!(validate_zone_id("0123456789abcdef0123456789abcdef").is_ok()); + assert!(validate_zone_id("example.test").is_err()); + assert!(validate_token_id("0123456789abcdef0123456789abcdef").is_ok()); + assert!(validate_token_id("../token").is_err()); +} diff --git a/ublue/skillet/crates/workstation/src/configuration-templates.json b/ublue/skillet/crates/workstation/src/configuration-templates.json new file mode 100644 index 00000000..776e3bd1 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/configuration-templates.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "services": { + "datadog": { + "default": { + "version": 1, + "api_key": { + "$secret": "skillet/datadog/api-key" + }, + "site": "datadoghq.com", + "tags": [] + }, + "hosts": {} + }, + "ddns": { + "default": { + "version": 1, + "records": [ + { + "name": { + "$secret": "skillet/environments/{environment}/hosts/{host}/cloudflare/ddns-dns-name" + }, + "proxied": false + } + ], + "takeover_existing": false + }, + "hosts": {} + } + } +} diff --git a/ublue/skillet/crates/workstation/src/configuration_templates.rs b/ublue/skillet/crates/workstation/src/configuration_templates.rs new file mode 100644 index 00000000..97a14a16 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/configuration_templates.rs @@ -0,0 +1,169 @@ +//! Workstation-side configuration templates with explicit `KeePassXC` references. +use crate::vault::{Vault, VaultError}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::BTreeMap; +use thiserror::Error; + +const TEMPLATE_CATALOG: &str = include_str!("configuration-templates.json"); + +#[derive(Debug, Error)] +pub enum ConfigurationTemplateError { + #[error("configuration template catalog is invalid: {0}")] + Catalog(#[source] serde_json::Error), + #[error("could not serialize rendered configuration")] + Serialization(#[source] serde_json::Error), + #[error("unsupported configuration template catalog version")] + Version, + #[error("no configuration template registered for service {service} and host {host}")] + MissingTemplate { service: String, host: String }, + #[error("host or environment is not a valid template path component")] + InvalidContext, + #[error("invalid secret reference in configuration template")] + InvalidReference, + #[error("KeePassXC entry is missing: {0}")] + MissingSecret(String), + #[error(transparent)] + Vault(#[from] VaultError), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TemplateCatalog { + version: u32, + services: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ServiceTemplates { + default: Value, + #[serde(default)] + hosts: BTreeMap, +} + +/// Render the selected service template and resolve typed references through +/// `KeePassXC`. A host-specific template replaces the complete shared template. +/// Secret values are inserted as JSON strings and are never interpolated into +/// surrounding text. +pub fn render( + service: &str, + host: &str, + environment: &str, + vault: &Vault, +) -> Result { + render_catalog(TEMPLATE_CATALOG, service, host, environment, &|path| { + vault.get(path) + }) +} + +/// Discover resolved references using the same parser and host override selection +/// as delivery. No vault reads or secret values are required. +pub fn secret_paths( + service: &str, + host: &str, + environment: &str, +) -> Result, ConfigurationTemplateError> { + let paths = std::cell::RefCell::new(std::collections::BTreeSet::new()); + render_catalog(TEMPLATE_CATALOG, service, host, environment, &|path| { + paths.borrow_mut().insert(path.to_string()); + Ok(Some("audit-placeholder".into())) + })?; + Ok(paths.into_inner().into_iter().collect()) +} + +fn render_catalog( + catalog: &str, + service: &str, + host: &str, + environment: &str, + lookup: &impl Fn(&str) -> Result, VaultError>, +) -> Result { + if !valid_component(host) || !valid_component(environment) { + return Err(ConfigurationTemplateError::InvalidContext); + } + let catalog: TemplateCatalog = + serde_json::from_str(catalog).map_err(ConfigurationTemplateError::Catalog)?; + if catalog.version != 1 { + return Err(ConfigurationTemplateError::Version); + } + let service_templates = catalog.services.get(service).ok_or_else(|| { + ConfigurationTemplateError::MissingTemplate { + service: service.to_string(), + host: host.to_string(), + } + })?; + let mut value = service_templates + .hosts + .get(host) + .unwrap_or(&service_templates.default) + .clone(); + resolve_value(&mut value, host, environment, lookup)?; + serde_json::to_string(&value).map_err(ConfigurationTemplateError::Serialization) +} + +fn resolve_value( + value: &mut Value, + host: &str, + environment: &str, + lookup: &impl Fn(&str) -> Result, VaultError>, +) -> Result<(), ConfigurationTemplateError> { + match value { + Value::Object(fields) => { + if let Some(reference) = fields.get("$secret") { + if fields.len() != 1 { + return Err(ConfigurationTemplateError::InvalidReference); + } + let Value::String(reference) = reference else { + return Err(ConfigurationTemplateError::InvalidReference); + }; + let path = resolve_entry_path(reference, host, environment)?; + let secret = lookup(&path)? + .ok_or_else(|| ConfigurationTemplateError::MissingSecret(path.clone()))?; + *value = Value::String(secret); + } else { + for child in fields.values_mut() { + resolve_value(child, host, environment, lookup)?; + } + } + } + Value::Array(values) => { + for child in values { + resolve_value(child, host, environment, lookup)?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + Ok(()) +} + +fn resolve_entry_path( + reference: &str, + host: &str, + environment: &str, +) -> Result { + let path = reference + .replace("{host}", host) + .replace("{environment}", environment); + if !path.starts_with("skillet/") + || path.contains('{') + || path.contains('}') + || path + .split('/') + .any(|component| component.is_empty() || component == "." || component == "..") + { + return Err(ConfigurationTemplateError::InvalidReference); + } + Ok(path) +} + +fn valid_component(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +#[cfg(test)] +#[path = "configuration_templates/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/configuration_templates/tests.rs b/ublue/skillet/crates/workstation/src/configuration_templates/tests.rs new file mode 100644 index 00000000..60e93de8 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/configuration_templates/tests.rs @@ -0,0 +1,127 @@ +use super::*; +use serde_json::json; +use std::cell::Cell; + +fn catalog(default: &Value, hosts: &Value) -> String { + json!({"version": 1, "services": {"example": {"default": default, "hosts": hosts}}}).to_string() +} + +#[test] +fn embedded_ddns_template_resolves_each_environment_and_validates_consumer_schema() { + for environment in ["prod", "test"] { + let expected = + format!("skillet/environments/{environment}/hosts/clamps/cloudflare/ddns-dns-name"); + let rendered = render_catalog(TEMPLATE_CATALOG, "ddns", "clamps", environment, &|path| { + assert_eq!(path, expected); + Ok(Some("edge".into())) + }) + .unwrap(); + let config = skillet_ddns::config::PrivateConfig::parse(&rendered).unwrap(); + assert_eq!(config.records.len(), 1); + assert_eq!(config.records[0].name, "edge"); + assert!(!config.records[0].proxied); + assert!(!config.takeover_existing); + } +} + +#[test] +fn host_override_replaces_default_without_merging_or_reading_default_secrets() { + let source = catalog( + &json!({"default_only": {"$secret": "skillet/missing"}, "records": [1, 2]}), + &json!({"clamps": {"records": [3]}}), + ); + let rendered = render_catalog(&source, "example", "clamps", "test", &|_| { + panic!("replaced defaults must not access the vault") + }) + .unwrap(); + assert_eq!( + serde_json::from_str::(&rendered).unwrap(), + json!({"records": [3]}) + ); +} + +#[test] +fn nested_secrets_are_escaped_strings_and_never_evaluated_recursively() { + let secret = "\"\\\n{\"$secret\":\"skillet/other\"}"; + let source = catalog( + &json!({"values": [{"nested": {"$secret": "skillet/{environment}/{host}/value"}}], + "literal": "secret{skillet/value} {host}"}), + &json!({}), + ); + let reads = Cell::new(0); + let rendered = render_catalog(&source, "example", "clamps", "test", &|path| { + assert_eq!(path, "skillet/test/clamps/value"); + reads.set(reads.get() + 1); + Ok(Some(secret.into())) + }) + .unwrap(); + let result: Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(result["values"][0]["nested"], secret); + assert_eq!(result["literal"], "secret{skillet/value} {host}"); + assert_eq!(reads.get(), 1); +} + +#[test] +fn malformed_references_fail_before_lookup() { + for marker in [ + json!({"$secret": 12}), + json!({"$secret": "skillet/value", "extra": true}), + json!({"$secret": "elsewhere/value"}), + json!({"$secret": "skillet/{instance}/value"}), + json!({"$secret": "skillet//value"}), + json!({"$secret": "skillet/../value"}), + json!({"$secret": "skillet/./value"}), + ] { + let source = catalog(&marker, &json!({})); + assert!(matches!( + render_catalog(&source, "example", "clamps", "test", &|_| { + panic!("invalid references must not reach the vault") + }), + Err(ConfigurationTemplateError::InvalidReference) + )); + } +} + +#[test] +fn invalid_context_and_catalog_fail_before_lookup() { + for (host, environment) in [("../clamps", "test"), ("clamps", ""), ("clamps", "test/x")] { + assert!(matches!( + render_catalog(TEMPLATE_CATALOG, "ddns", host, environment, &|_| { + panic!("invalid context must not reach the vault") + }), + Err(ConfigurationTemplateError::InvalidContext) + )); + } + assert!(matches!( + render_catalog( + "{\"version\":2,\"services\":{}}", + "ddns", + "clamps", + "test", + &|_| Ok(None) + ), + Err(ConfigurationTemplateError::Version) + )); + assert!(matches!( + render_catalog(TEMPLATE_CATALOG, "unknown", "clamps", "test", &|_| Ok(None)), + Err(ConfigurationTemplateError::MissingTemplate { .. }) + )); +} + +#[test] +fn missing_entries_fail_and_invalid_secret_values_still_require_consumer_validation() { + let source = catalog(&json!({"$secret": "skillet/missing"}), &json!({})); + assert!(matches!( + render_catalog(&source, "example", "clamps", "test", &|_| Ok(None)), + Err(ConfigurationTemplateError::MissingSecret(path)) if path == "skillet/missing" + )); + let secret = "private.invalid/record"; + let rendered = render_catalog(TEMPLATE_CATALOG, "ddns", "clamps", "test", &|_| { + Ok(Some(secret.into())) + }) + .unwrap(); + let error = skillet_ddns::config::PrivateConfig::parse(&rendered) + .err() + .unwrap(); + assert!(!error.to_string().contains(secret)); +} diff --git a/ublue/skillet/crates/workstation/src/credential_delivery.rs b/ublue/skillet/crates/workstation/src/credential_delivery.rs new file mode 100644 index 00000000..16883ce8 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/credential_delivery.rs @@ -0,0 +1,242 @@ +//! Workstation-side persistence and delivery of host credentials. + +use crate::{ + provisioning_policy::{DeviceClass, Environment, ProvisioningPolicy}, + tailscale::{self, AuthKey, OAuthCredentials, TailscaleError}, + vault::{SecretStore, VaultError}, +}; +use skillet_vm::{credential, transport::GuestTransport}; +use std::{fs::File, io::Read}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CredentialDeliveryError { + #[error(transparent)] + Datadog(#[from] skillet_datadog::DatadogError), + #[error("invalid host credential delivery: {0}")] + Invalid(String), + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Tailscale(#[from] TailscaleError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), + #[error("operating system randomness is unavailable: {0}")] + Randomness(#[from] std::io::Error), +} + +pub fn deliver_datadog_credential( + host: &str, + policy: ProvisioningPolicy, + input: &str, + guest: &impl GuestTransport, +) -> Result<(), CredentialDeliveryError> { + let profile = skillet_hosts::profile_for_name(host) + .filter(|profile| profile.supports_service("datadog")) + .ok_or_else(|| CredentialDeliveryError::Invalid("host does not declare Datadog".into()))?; + let production_region = profile + .services + .iter() + .find_map(|service| match service.config { + skillet_hosts::ServiceConfig::Datadog { + production_region, .. + } => Some(production_region), + _ => None, + }) + .ok_or_else(|| CredentialDeliveryError::Invalid("host does not declare Datadog".into()))?; + let input = skillet_datadog::Input::parse(input)? + .render_for_environment(policy.vault_name(), production_region)?; + credential::install( + guest, + profile.id.as_str(), + skillet_datadog::CREDENTIAL, + "skillet-datadog-apply.service", + credential::ActivationPolicy::StartConsumer, + input.as_bytes(), + )?; + Ok(()) +} + +pub trait ProductionAuthKeyProvider { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result; +} + +impl ProductionAuthKeyProvider for OAuthCredentials { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result { + tailscale::create_auth_key(self, tag, description) + } +} + +pub fn deliver_pihole_credential( + host: &str, + store: &mut impl SecretStore, + guest: &impl GuestTransport, +) -> Result<(), CredentialDeliveryError> { + let profile = skillet_hosts::profile_for_name(host) + .ok_or_else(|| CredentialDeliveryError::Invalid(format!("unknown host profile {host}")))?; + if !profile.supports_service("pihole") { + return Err(CredentialDeliveryError::Invalid(format!( + "host {host} does not declare Pi-hole credential delivery" + ))); + } + let path = format!("skillet/hosts/{host}/pihole/web-password"); + let password = if let Some(password) = store.get(&path)? { + store.ensure_unchanged()?; + password + } else { + if remote_credential_present(host, guest)? { + return Err(CredentialDeliveryError::Invalid( + "host already has a Pi-hole credential; restore the missing KeePassXC entry instead of creating a replacement".into(), + )); + } + store.ensure_unchanged()?; + let password = random_password()?; + store.save_verified(&path, &password)?; + password + }; + credential::install( + guest, + host, + "pihole_web_password", + "skillet-full-apply.service", + credential::ActivationPolicy::StartConsumer, + password.as_bytes(), + )?; + Ok(()) +} + +pub fn deliver_tailscale_credential( + host: &str, + policy: ProvisioningPolicy, + provider: &impl ProductionAuthKeyProvider, + guest: &impl GuestTransport, +) -> Result<(), CredentialDeliveryError> { + if policy.environment() != Environment::Production { + return Err(CredentialDeliveryError::Invalid( + "production Tailscale credential delivery requires production policy".into(), + )); + } + let profile = skillet_hosts::profile_for_name(host) + .ok_or_else(|| CredentialDeliveryError::Invalid(format!("unknown host profile {host}")))?; + if !profile.supports_service("tailscale") { + return Err(CredentialDeliveryError::Invalid(format!( + "host {host} does not declare Tailscale credential delivery" + ))); + } + let tag = policy.tailscale_tag(DeviceClass::ProductionHost); + let auth_key = provider.create_auth_key(tag, &format!("Skillet {host} production host"))?; + credential::install( + guest, + host, + "tailscale_auth_key", + "skillet-full-apply.service", + credential::ActivationPolicy::StartConsumer, + skillet_hosts::tailscale::EnrollmentInput::new(auth_key.key, profile.tailscale_exit_node()) + .payload() + .map_err(|e| CredentialDeliveryError::Invalid(e.to_string()))? + .as_bytes(), + )?; + Ok(()) +} + +pub fn ensure_disposable_pihole_credential( + host: &str, + rotate: bool, + guest: &impl GuestTransport, +) -> Result<(), CredentialDeliveryError> { + let profile = skillet_hosts::profile_for_name(host) + .ok_or_else(|| CredentialDeliveryError::Invalid(format!("unknown host profile {host}")))?; + if !profile.supports_service("pihole") { + return Err(CredentialDeliveryError::Invalid(format!( + "host {host} does not declare Pi-hole" + ))); + } + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &[ + "-n", + "test", + "-s", + "/etc/credstore.encrypted/skillet/pihole_web_password.cred", + ], + }, + None, + )?; + let present = match output.status.code() { + Some(0) => true, + Some(1) => false, + _ => { + return Err(CredentialDeliveryError::Invalid( + "could not inspect disposable VM credential state".into(), + )); + } + }; + if rotate || !present { + credential::install( + guest, + host, + "pihole_web_password", + "skillet-full-apply.service", + credential::ActivationPolicy::StartConsumer, + random_password()?.as_bytes(), + )?; + } else { + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "systemctl", "start", "skillet-full-apply.service"], + }, + None, + )?; + if !output.status.success() { + return Err(CredentialDeliveryError::Invalid(format!( + "starting host apply failed with status {}", + output.status + ))); + } + } + Ok(()) +} + +fn remote_credential_present( + host: &str, + guest: &impl GuestTransport, +) -> Result { + let program = format!("/var/usrlocal/bin/skillet-{host}"); + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &[ + "-n", + program.as_str(), + "credential", + "state", + "pihole_web_password", + ], + }, + None, + )?; + if !output.status.success() { + return Err(CredentialDeliveryError::Invalid( + "host credential state check failed; no production credential was generated".into(), + )); + } + match output.stdout.as_slice() { + b"present\n" => Ok(true), + b"absent\n" => Ok(false), + _ => Err(CredentialDeliveryError::Invalid( + "host returned an unrecognized credential state".into(), + )), + } +} + +fn random_password() -> Result { + let mut bytes = [0_u8; 32]; + File::open("/dev/urandom")?.read_exact(&mut bytes)?; + Ok(hex::encode(bytes)) +} + +#[cfg(test)] +#[path = "credential_delivery/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs new file mode 100644 index 00000000..67b45626 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/credential_delivery/tests.rs @@ -0,0 +1,310 @@ +use super::*; +use crate::{ + provisioning_policy::Environment, + vault::{SecretStore, VaultError}, +}; +use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + os::unix::process::ExitStatusExt, + process::{ExitStatus, Output}, + sync::Mutex, +}; + +#[derive(Default)] +struct FakeStore { + entries: RefCell>, + saves: Cell, + changed: Cell, +} + +impl SecretStore for FakeStore { + fn get(&self, path: &str) -> Result, VaultError> { + Ok(self.entries.borrow().get(path).cloned()) + } + + fn ensure_unchanged(&self) -> Result<(), VaultError> { + if self.changed.get() { + Err(VaultError::Invalid("fixture vault changed".into())) + } else { + Ok(()) + } + } + + fn save_verified(&mut self, path: &str, secret: &str) -> Result<(), VaultError> { + self.saves.set(self.saves.get() + 1); + self.entries + .borrow_mut() + .insert(path.to_string(), secret.to_string()); + Ok(()) + } +} + +#[derive(Default)] +struct FakeGuest { + state: Vec, + state_exit_code: i32, + calls: Mutex>, +} + +type CapturedGuestCall = (Vec, Option>); + +#[test] +fn datadog_delivery_validates_capability_and_sends_only_stdin() { + let guest = FakeGuest::default(); + let key = "0123456789abcdef0123456789abcdef"; + let payload = serde_json::json!({"version":1,"api_key":key,"site":"datadoghq.eu"}).to_string(); + let policy = ProvisioningPolicy::new(Environment::Test); + assert!(deliver_datadog_credential("agent", policy, &payload, &guest).is_err()); + assert!(deliver_datadog_credential("clamps", policy, "invalid", &guest).is_err()); + assert!(guest.calls.lock().unwrap().is_empty()); + deliver_datadog_credential("clamps", policy, &payload, &guest).unwrap(); + let calls = guest.calls.lock().unwrap(); + assert!(calls + .iter() + .all(|(args, _)| args.iter().all(|arg| !arg.contains(key)))); + assert!(calls + .iter() + .any(|(_, input)| input.as_ref().is_some_and(|bytes| { + let value = String::from_utf8_lossy(bytes); + value.contains(key) && value.contains("env:test") && value.contains("region:lab") + }))); +} + +impl GuestTransport for FakeGuest { + fn execute( + &self, + command: &skillet_vm::transport::GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + self.calls.lock().unwrap().push(( + std::iter::once(command.program.to_string()) + .chain( + command + .arguments + .iter() + .map(std::string::ToString::to_string), + ) + .collect(), + input.map(<[u8]>::to_vec), + )); + let status = if command.arguments.contains(&"test") { + ExitStatus::from_raw(self.state_exit_code << 8) + } else { + ExitStatus::from_raw(0) + }; + Ok(Output { + status, + stdout: if command.arguments.contains(&"state") { + self.state.clone() + } else { + Vec::new() + }, + stderr: Vec::new(), + }) + } + + fn upload(&self, _source: &std::path::Path, _destination: &str) -> skillet_vm::Result<()> { + Ok(()) + } +} + +#[derive(Default)] +struct FakeTailscale { + calls: Cell, + tag: RefCell, + description: RefCell, +} + +impl ProductionAuthKeyProvider for FakeTailscale { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result { + self.calls.set(self.calls.get() + 1); + *self.tag.borrow_mut() = tag.to_string(); + *self.description.borrow_mut() = description.to_string(); + Ok(AuthKey { + key: "dummy auth key".into(), + }) + } +} + +#[test] +fn missing_pihole_password_checks_remote_state_then_persists_and_installs() { + let mut store = FakeStore::default(); + let guest = FakeGuest { + state: b"absent\n".to_vec(), + ..FakeGuest::default() + }; + + deliver_pihole_credential("clamps", &mut store, &guest).unwrap(); + + assert_eq!(store.saves.get(), 1); + let password = store + .entries + .borrow() + .get("skillet/hosts/clamps/pihole/web-password") + .cloned() + .unwrap(); + assert_eq!(password.len(), 64); + assert!(password.bytes().all(|byte| byte.is_ascii_hexdigit())); + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls[0].0.iter().any(|argument| argument == "state")); + assert_eq!(calls[1].1.as_deref(), Some(password.as_bytes())); +} + +#[test] +fn existing_pihole_password_is_reused_without_remote_state_probe() { + let mut store = FakeStore::default(); + store.entries.borrow_mut().insert( + "skillet/hosts/clamps/pihole/web-password".into(), + "existing password".into(), + ); + let guest = FakeGuest::default(); + + deliver_pihole_credential("clamps", &mut store, &guest).unwrap(); + + assert_eq!(store.saves.get(), 0); + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1.as_deref(), Some(b"existing password".as_slice())); +} + +#[test] +fn existing_remote_pihole_secret_blocks_replacement_and_store_mutation() { + let mut store = FakeStore::default(); + let guest = FakeGuest { + state: b"present\n".to_vec(), + ..FakeGuest::default() + }; + + assert!(deliver_pihole_credential("clamps", &mut store, &guest).is_err()); + assert_eq!(store.saves.get(), 0); + assert_eq!(guest.calls.lock().unwrap().len(), 1); +} + +#[test] +fn tailscale_production_key_uses_profile_tag_and_shared_credential_installer() { + let provider = FakeTailscale::default(); + let guest = FakeGuest::default(); + + deliver_tailscale_credential( + "clamps", + ProvisioningPolicy::new(Environment::Production), + &provider, + &guest, + ) + .unwrap(); + + assert_eq!(provider.calls.get(), 1); + assert_eq!(&*provider.tag.borrow(), tailscale::SERVER_TAG); + assert_eq!( + &*provider.description.borrow(), + "Skillet clamps production host" + ); + let calls = guest.calls.lock().unwrap(); + let payload = std::str::from_utf8(calls[0].1.as_deref().unwrap()).unwrap(); + let input = skillet_hosts::tailscale::EnrollmentInput::parse(payload).unwrap(); + assert_eq!(input.auth_key, "dummy auth key"); + assert!(input.advertise_exit_node); +} + +#[test] +fn invalid_host_or_nonproduction_tailscale_policy_fails_before_effects() { + let provider = FakeTailscale::default(); + let guest = FakeGuest::default(); + assert!(deliver_tailscale_credential( + "clamps", + ProvisioningPolicy::new(Environment::Test), + &provider, + &guest, + ) + .is_err()); + assert!(deliver_pihole_credential("agent", &mut FakeStore::default(), &guest).is_err()); + assert_eq!(provider.calls.get(), 0); + assert!(guest.calls.lock().unwrap().is_empty()); +} + +#[test] +fn disposable_pihole_credential_is_created_when_missing_and_rotation_is_requested() { + let guest = FakeGuest { + state_exit_code: 1, + ..FakeGuest::default() + }; + + ensure_disposable_pihole_credential("clamps", false, &guest).unwrap(); + + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls[0].0.iter().any(|argument| argument == "test")); + assert_eq!(calls[1].1.as_ref().map(Vec::len), Some(64)); +} + +#[test] +fn disposable_pihole_reapply_reuses_secret_and_rotation_replaces_it() { + let guest = FakeGuest::default(); + + ensure_disposable_pihole_credential("clamps", false, &guest).unwrap(); + + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls[1] + .0 + .windows(3) + .any(|arguments| arguments == ["systemctl", "start", "skillet-full-apply.service"])); +} + +#[test] +fn disposable_pihole_inspection_errors_do_not_generate_a_replacement() { + let guest = FakeGuest { + state_exit_code: 2, + ..FakeGuest::default() + }; + + assert!(ensure_disposable_pihole_credential("clamps", false, &guest).is_err()); + assert_eq!(guest.calls.lock().unwrap().len(), 1); +} + +#[test] +fn datadog_production_delivery_uses_caller_region() { + let guest = FakeGuest::default(); + let payload = serde_json::json!({"version":1,"api_key":"0123456789abcdef0123456789abcdef", + "site":"datadoghq.com","tags":["region:obsolete"]}) + .to_string(); + deliver_datadog_credential( + "clamps", + ProvisioningPolicy::new(Environment::Production), + &payload, + &guest, + ) + .unwrap(); + let calls = guest.calls.lock().unwrap(); + let input = calls.iter().find_map(|(_, input)| input.as_ref()).unwrap(); + let value: serde_json::Value = serde_json::from_slice(input).unwrap(); + assert_eq!( + value["tags"], + serde_json::json!(["env:prod", "region:ftwo"]) + ); +} + +#[test] +fn smtp_delivery_is_stdin_only_and_capture_cannot_contain_provider_fields() { + let guest = FakeGuest::default(); + let input = skillet_smtp::Input::Production { + host: "smtp.example.com".into(), + port: 587, + tls: "starttls".into(), + username: "fixture-key".into(), + password: "fixture-password".into(), + sender: "server@example.com".into(), + }; + assert!(crate::smtp_provisioning::deliver("agent", &input, &guest).is_err()); + assert_eq!(guest.calls.lock().unwrap().len(), 0); + crate::smtp_provisioning::deliver("joey", &input, &guest).unwrap(); + let calls = guest.calls.lock().unwrap(); + assert!(calls.iter().all(|(args, _)| args + .iter() + .all(|arg| !arg.contains("fixture-password") && !arg.contains("fixture-key")))); + assert!(calls.iter().any(|(_, bytes)| bytes + .as_ref() + .is_some_and(|value| String::from_utf8_lossy(value).contains("fixture-password")))); +} diff --git a/ublue/skillet/crates/workstation/src/ddns_provisioning.rs b/ublue/skillet/crates/workstation/src/ddns_provisioning.rs new file mode 100644 index 00000000..9d3b089b --- /dev/null +++ b/ublue/skillet/crates/workstation/src/ddns_provisioning.rs @@ -0,0 +1,606 @@ +//! Persistent DDNS credential issuance and encrypted guest delivery. +use crate::{ + cloudflare::{Cloudflare, CloudflareError, IssuedToken, RecordRef, Zone}, + provisioning_policy::{Environment, ProvisioningPolicy}, + vault::{SecretStore, VaultError}, +}; +use skillet_ddns::config::{ConfigError, PrivateConfig}; +use skillet_vm::{credential, transport::GuestTransport}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DdnsProvisioningError { + #[error("invalid DDNS provisioning: {0}")] + Invalid(&'static str), + #[error(transparent)] + Config(#[from] ConfigError), + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Cloudflare(#[from] CloudflareError), + #[error(transparent)] + Token(#[from] crate::zone_token::TokenError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), + #[error(transparent)] + State(#[from] crate::provisioning_state::ProvisioningStateError), + #[error(transparent)] + Caddy(#[from] skillet_caddy::CaddyError), +} + +/// Provider operations needed for persistent public-address updates. +/// DNS is observed here; the guest updater is the sole continuing writer. +pub trait DdnsProvider { + fn zone(&self, token: &str, zone: &str) -> Result; + fn records(&self, token: &str, zone: &str) -> Result, CloudflareError>; + fn issue( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + lifetime: Option, + ) -> Result; + fn revoke(&self, creator: &str, account: &str, id: &str) -> Result<(), CloudflareError>; + fn replace( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + lifetime: Option, + ) -> Result; + fn owned_test_records( + &self, + token: &str, + zone: &str, + marker: &str, + names: &[String], + ) -> Result, CloudflareError>; + fn remove_test_records( + &self, + token: &str, + zone: &str, + marker: &str, + names: &[String], + ids: &[String], + ) -> Result<(), CloudflareError>; + fn token_ids( + &self, + creator: &str, + account: &str, + name: &str, + ) -> Result, CloudflareError>; +} + +impl DdnsProvider for Cloudflare { + fn zone(&self, token: &str, zone: &str) -> Result { + Self::zone(self, token, zone) + } + fn records(&self, token: &str, zone: &str) -> Result, CloudflareError> { + self.list_records(token, zone) + } + fn issue( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + lifetime: Option, + ) -> Result { + self.create_zone_token(creator, zone, account, name, lifetime) + } + fn revoke(&self, creator: &str, account: &str, id: &str) -> Result<(), CloudflareError> { + self.revoke_token(creator, account, id) + } + fn replace( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + lifetime: Option, + ) -> Result { + self.replace_named_zone_token(creator, zone, account, name, lifetime) + } + fn owned_test_records( + &self, + token: &str, + zone: &str, + marker: &str, + names: &[String], + ) -> Result, CloudflareError> { + self.list_owned_ddns_records(token, zone, marker, names) + } + fn remove_test_records( + &self, + token: &str, + zone: &str, + marker: &str, + names: &[String], + ids: &[String], + ) -> Result<(), CloudflareError> { + self.remove_owned_ddns_records(token, zone, marker, names, ids) + } + fn token_ids( + &self, + creator: &str, + account: &str, + name: &str, + ) -> Result, CloudflareError> { + self.token_ids_by_name(creator, account, name) + } +} + +pub struct DdnsDelivery<'a> { + pub host: &'a str, + pub policy: ProvisioningPolicy, + pub zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub config: &'a str, + pub creator_token: &'a str, +} + +pub fn deliver_persistent_ddns( + request: &DdnsDelivery<'_>, + store: &mut impl SecretStore, + provider: &impl DdnsProvider, + guest: &impl GuestTransport, +) -> Result<(), DdnsProvisioningError> { + if request.policy.environment() != Environment::Production { + return Err(DdnsProvisioningError::Invalid( + "disposable DDNS requires its own ownership and cleanup scenario; not enabled yet", + )); + } + skillet_hosts::profile_for_name(request.host) + .filter(|profile| profile.supports_service("ddns")) + .ok_or(DdnsProvisioningError::Invalid("host does not declare DDNS"))?; + let config = PrivateConfig::parse(request.config)?; + crate::cloudflare::validate_zone_id(request.zone_id)?; + // Validate credentials before issuance as well as at the receiving host. + config.payload(request.zone_id, "validation-placeholder")?; + let zone = provider.zone(request.creator_token, request.zone_id)?; + if zone.id != request.zone_id { + return Err(DdnsProvisioningError::Invalid( + "provider zone identity mismatch", + )); + } + let ui_domain = skillet_caddy::resolve_ui_domain(&zone.name, request.relative_ui_domain)?; + config.validate_zone(&zone.name, &[])?; + // Reserve the complete UI namespace, including aliases and other hosts. + // Its naming rules remain owned by Caddy, rather than copied here. + if config.records.iter().any(|record| { + let name = format!("{}.{name}", record.name, name = zone.name); + name == ui_domain || name.ends_with(&format!(".{ui_domain}")) + }) { + return Err(ConfigError::Overlap.into()); + } + let account = Cloudflare::account_id(&zone)?; + let token_path = format!( + "skillet/environments/{}/hosts/{}/cloudflare/ddns-token", + request.policy.vault_name(), + request.host + ); + let token_name = format!("skillet:{}:{}:ddns", request.policy.name(), request.host); + let token = crate::zone_token::use_or_create( + store, + &token_path, + || { + provider.issue( + request.creator_token, + request.zone_id, + account, + &token_name, + None, + ) + }, + |id| provider.revoke(request.creator_token, account, id), + )?; + let child_zone = provider.zone(&token, request.zone_id)?; + if child_zone.id != zone.id || child_zone.name != zone.name { + return Err(DdnsProvisioningError::Invalid( + "child token zone identity mismatch", + )); + } + let existing = provider.records(&token, request.zone_id)?; + let marker = format!("skillet-ddns:{}:{}", request.policy.name(), request.host); + validate_takeover(&config, &zone.name, &marker, &existing)?; + store.ensure_unchanged()?; + let payload = config + .payload(request.zone_id, &token)? + .with_record_comment(&marker)? + .render()?; + credential::install( + guest, + request.host, + skillet_ddns::CREDENTIAL, + "skillet-ddns-apply.service", + credential::ActivationPolicy::StartConsumer, + payload.as_bytes(), + )?; + Ok(()) +} + +pub struct DisposableDdnsRequest<'a> { + pub host: &'a str, + pub instance: &'a str, + pub policy: ProvisioningPolicy, + pub zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub config: &'a str, + pub creator_token: &'a str, + pub run_directory: &'a std::path::Path, +} + +struct DisposableDdnsPlan { + config: PrivateConfig, + account: String, + marker: String, + token_name: String, + cleanup_token_name: String, + record_names: Vec, +} + +/// Journal first, mint a bounded test token, then activate the existing guest +/// DDNS writer and wait for its allowlisted marked A records to appear. +pub fn provision_disposable_ddns( + request: &DisposableDdnsRequest<'_>, + provider: &impl DdnsProvider, + guest: &impl GuestTransport, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Result<(), DdnsProvisioningError> { + if request.policy.environment() != Environment::Test { + return Err(DdnsProvisioningError::Invalid( + "disposable DDNS requires test policy", + )); + } + skillet_hosts::profile_for_name(request.host) + .filter(|profile| profile.supports_service("ddns")) + .ok_or(DdnsProvisioningError::Invalid("host does not declare DDNS"))?; + let plan = disposable_ddns_plan(request, provider)?; + let path = request.run_directory.join("ddns.json"); + let mut ownership = load_or_create_ddns_ownership(request, &plan, &path)?; + let token = provider.replace( + request.creator_token, + request.zone_id, + &plan.account, + &plan.token_name, + request.policy.cloudflare_token_lifetime(), + )?; + ownership.token_id = Some(token.id); + ownership.expires_on = token.expires_on; + crate::provisioning_state::save_ddns_ownership(&path, &ownership)?; + activate_disposable_ddns( + request, + &plan, + &token.value, + &mut ownership, + &path, + provider, + guest, + timeout, + interval, + ) +} + +fn disposable_ddns_plan( + request: &DisposableDdnsRequest<'_>, + provider: &impl DdnsProvider, +) -> Result { + let config = PrivateConfig::parse(request.config)?; + crate::cloudflare::validate_zone_id(request.zone_id)?; + config.payload(request.zone_id, "validation-placeholder")?; + let zone = provider.zone(request.creator_token, request.zone_id)?; + if zone.id != request.zone_id { + return Err(DdnsProvisioningError::Invalid( + "provider zone identity mismatch", + )); + } + config.validate_zone(&zone.name, &[])?; + let ui_domain = skillet_caddy::resolve_ui_domain(&zone.name, request.relative_ui_domain)?; + if config.records.iter().any(|record| { + let name = format!("{}.{zone}", record.name, zone = zone.name); + name == ui_domain || name.ends_with(&format!(".{ui_domain}")) + }) { + return Err(ConfigError::Overlap.into()); + } + let account = Cloudflare::account_id(&zone)?; + let marker = format!("skillet-ddns:test:{}:{}", request.host, request.instance); + let token_name = format!("skillet:test:{}-{}:ddns", request.host, request.instance); + let cleanup_token_name = format!("{token_name}:cleanup"); + let record_names = config + .records + .iter() + .map(|record| format!("{}.{zone}", record.name, zone = zone.name)) + .collect::>(); + Ok(DisposableDdnsPlan { + config, + account: account.to_string(), + marker, + token_name, + cleanup_token_name, + record_names, + }) +} + +fn load_or_create_ddns_ownership( + request: &DisposableDdnsRequest<'_>, + plan: &DisposableDdnsPlan, + path: &std::path::Path, +) -> Result { + let ownership = if crate::provisioning_state::ddns_ownership_exists(path)? { + let saved = crate::provisioning_state::load_ddns_ownership(path)?; + if saved.identity + != crate::provisioning_state::ProvisioningIdentity::new( + request.host, + request.policy.name(), + request.instance, + ) + || saved.zone_id != request.zone_id + || saved.marker != plan.marker + || saved.token_name != plan.token_name + || saved.record_names != plan.record_names + || saved.cleanup_token_name != plan.cleanup_token_name + { + return Err(DdnsProvisioningError::Invalid( + "DDNS journal does not match this VM/configuration", + )); + } + saved + } else { + let value = crate::provisioning_state::DdnsVmOwnership { + identity: crate::provisioning_state::ProvisioningIdentity::new( + request.host, + request.policy.name(), + request.instance, + ), + zone_id: request.zone_id.into(), + marker: plan.marker.clone(), + token_name: plan.token_name.clone(), + token_id: None, + expires_on: None, + record_names: plan.record_names.clone(), + record_ids: Vec::new(), + cleanup_token_name: plan.cleanup_token_name.clone(), + cleanup_token_id: None, + }; + crate::provisioning_state::save_ddns_ownership(path, &value)?; + value + }; + Ok(ownership) +} + +#[allow(clippy::too_many_arguments)] +fn activate_disposable_ddns( + request: &DisposableDdnsRequest<'_>, + plan: &DisposableDdnsPlan, + token: &str, + ownership: &mut crate::provisioning_state::DdnsVmOwnership, + path: &std::path::Path, + provider: &impl DdnsProvider, + guest: &impl GuestTransport, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Result<(), DdnsProvisioningError> { + let existing = + provider.owned_test_records(token, request.zone_id, &plan.marker, &plan.record_names)?; + let all_records = provider.records(token, request.zone_id)?; + for name in &plan.record_names { + if all_records.iter().any(|record| { + record.name.eq_ignore_ascii_case(name) + && record.comment.as_deref() != Some(plan.marker.as_str()) + }) { + return Err(DdnsProvisioningError::Invalid( + "test DDNS record name is already in use", + )); + } + } + ownership.record_ids = existing.iter().map(|record| record.id.clone()).collect(); + crate::provisioning_state::save_ddns_ownership(path, ownership)?; + let payload = plan + .config + .payload(request.zone_id, token)? + .with_record_comment(&plan.marker)? + .render()?; + credential::install( + guest, + request.host, + skillet_ddns::CREDENTIAL, + "skillet-ddns-apply.service", + credential::ActivationPolicy::StartConsumer, + payload.as_bytes(), + )?; + wait_for_disposable_ddns( + request, plan, token, ownership, path, provider, timeout, interval, + ) +} + +#[allow(clippy::too_many_arguments)] +fn wait_for_disposable_ddns( + request: &DisposableDdnsRequest<'_>, + plan: &DisposableDdnsPlan, + token: &str, + ownership: &mut crate::provisioning_state::DdnsVmOwnership, + path: &std::path::Path, + provider: &impl DdnsProvider, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Result<(), DdnsProvisioningError> { + let deadline = std::time::Instant::now() + timeout; + loop { + let records = provider.owned_test_records( + token, + request.zone_id, + &plan.marker, + &plan.record_names, + )?; + let ready = plan.record_names.iter().all(|name| { + records.iter().any(|record| { + record.name.eq_ignore_ascii_case(name) && public_ipv4(&record.content) + }) + }); + if ready { + ownership.record_ids = records.iter().map(|record| record.id.clone()).collect(); + crate::provisioning_state::save_ddns_ownership(path, ownership)?; + return Ok(()); + } + if std::time::Instant::now() >= deadline { + return Err(DdnsProvisioningError::Invalid( + "timed out waiting for marked public DDNS records; ownership journal retained", + )); + } + std::thread::sleep(interval); + } +} + +fn public_ipv4(value: &str) -> bool { + let Ok(ip) = value.parse::() else { + return false; + }; + let octets = ip.octets(); + let documentation = (octets[0] == 192 && octets[1] == 0 && octets[2] == 2) + || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100) + || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113); + let special = octets[0] == 0 + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + || (octets[0] == 192 && octets[1] == 88 && octets[2] == 99) + || (octets[0] == 198 && (18..=19).contains(&octets[1])) + || octets[0] >= 240; + !documentation + && !special + && !ip.is_private() + && !ip.is_loopback() + && !ip.is_link_local() + && !ip.is_multicast() + && !ip.is_broadcast() + && !ip.is_unspecified() + && !(octets[0] == 100 && (64..=127).contains(&octets[1])) +} + +pub struct DisposableDdnsCleanup<'a> { + pub host: &'a str, + pub instance: &'a str, + pub policy: ProvisioningPolicy, + pub ownership_path: &'a std::path::Path, + pub configured_zone_id: &'a str, + pub creator_token: &'a str, +} + +pub fn cleanup_disposable_ddns( + request: &DisposableDdnsCleanup<'_>, + provider: &impl DdnsProvider, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(DdnsProvisioningError::Invalid( + "DDNS cleanup requires test policy", + )); + } + if !crate::provisioning_state::ddns_ownership_exists(request.ownership_path)? { + return Ok(false); + } + let mut owner = crate::provisioning_state::load_ddns_ownership(request.ownership_path)?; + let expected = crate::provisioning_state::ProvisioningIdentity::new( + request.host, + request.policy.name(), + request.instance, + ); + let token_name = format!("skillet:test:{}-{}:ddns", request.host, request.instance); + if owner.identity != expected + || owner.zone_id != request.configured_zone_id + || owner.marker != format!("skillet-ddns:test:{}:{}", request.host, request.instance) + || owner.token_name != token_name + || owner.cleanup_token_name != format!("{token_name}:cleanup") + || owner.record_names.is_empty() + { + return Err(DdnsProvisioningError::Invalid( + "DDNS ownership journal does not match this VM and environment", + )); + } + let zone = provider.zone(request.creator_token, &owner.zone_id)?; + if zone.id != owner.zone_id { + return Err(DdnsProvisioningError::Invalid( + "provider zone identity mismatch", + )); + } + let account = Cloudflare::account_id(&zone)?; + let cleanup = provider.replace( + request.creator_token, + &owner.zone_id, + account, + &owner.cleanup_token_name, + Some(request.policy.cleanup_token_lifetime()), + )?; + owner.cleanup_token_id = Some(cleanup.id); + crate::provisioning_state::save_ddns_ownership(request.ownership_path, &owner)?; + provider.zone(&cleanup.value, &owner.zone_id)?; + provider.remove_test_records( + &cleanup.value, + &owner.zone_id, + &owner.marker, + &owner.record_names, + &owner.record_ids, + )?; + for id in provider.token_ids(request.creator_token, account, &owner.token_name)? { + provider.revoke(request.creator_token, account, &id)?; + } + for id in provider.token_ids(request.creator_token, account, &owner.cleanup_token_name)? { + provider.revoke(request.creator_token, account, &id)?; + } + crate::provisioning_state::remove_ddns_ownership(request.ownership_path)?; + Ok(true) +} + +fn validate_takeover( + config: &PrivateConfig, + zone: &str, + marker: &str, + existing: &[RecordRef], +) -> Result<(), DdnsProvisioningError> { + // The pinned legacy updater fetches one page of at most 100 A records. + // Refuse a zone it cannot fully observe instead of risking duplicates. + let existing_a = existing + .iter() + .filter(|record| record.record_type == "A") + .count(); + let new_a = config + .records + .iter() + .filter(|record| { + let name = format!("{}.{zone}", record.name); + !existing + .iter() + .any(|entry| entry.record_type == "A" && entry.name.eq_ignore_ascii_case(&name)) + }) + .count(); + if existing_a + new_a > 100 { + return Err(DdnsProvisioningError::Invalid( + "selected updater supports at most 100 zone A records", + )); + } + for record in &config.records { + let name = format!("{}.{zone}", record.name); + let matches = existing + .iter() + .filter(|entry| entry.name.eq_ignore_ascii_case(&name)) + .collect::>(); + // Require explicit adoption on first activation and every retry. A UI + // ownership marker or conflicting record type must never be adopted. + if !matches.is_empty() + && ((!config.takeover_existing && matches[0].comment.as_deref() != Some(marker)) + || matches.len() != 1 + || matches[0].record_type != "A" + || matches[0].comment.as_deref().is_some_and(|comment| { + comment.starts_with("skillet:") + || (comment.starts_with("skillet-ddns:") && comment != marker) + })) + { + return Err(DdnsProvisioningError::Invalid("record collision: explicit public A-record takeover required; UI-owned and conflicting records cannot be adopted")); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "ddns_provisioning/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs new file mode 100644 index 00000000..d8c48f81 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/ddns_provisioning/tests.rs @@ -0,0 +1,405 @@ +use super::*; +use crate::cloudflare::ZoneAccount; +use skillet_vm::transport::GuestCommand; +use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + os::unix::process::ExitStatusExt, + path::Path, + process::{ExitStatus, Output}, +}; + +const ZONE: &str = "0123456789abcdef0123456789abcdef"; +const CONFIG: &str = r#"{"version":1,"records":[{"name":"edge","proxied":false}]}"#; +const MARKER: &str = "skillet-ddns:production:clamps"; + +#[derive(Default)] +struct Store { + entries: BTreeMap, + fail_save: bool, + changed: bool, +} +impl SecretStore for Store { + fn get(&self, path: &str) -> Result, VaultError> { + Ok(self.entries.get(path).cloned()) + } + fn ensure_unchanged(&self) -> Result<(), VaultError> { + if self.changed { + Err(VaultError::Invalid("vault changed".into())) + } else { + Ok(()) + } + } + fn save_verified(&mut self, path: &str, value: &str) -> Result<(), VaultError> { + if self.fail_save { + return Err(VaultError::Invalid("save failed".into())); + } + self.entries.insert(path.into(), value.into()); + Ok(()) + } +} + +#[derive(Default)] +struct Provider { + issued: Cell, + revoked: Cell, + records: Vec, + fail_revoke: bool, + owned_calls: Cell, + removed_records: Cell, +} +impl DdnsProvider for Provider { + fn zone(&self, _token: &str, zone: &str) -> Result { + Ok(Zone { + id: zone.into(), + name: "example.com".into(), + account: Some(ZoneAccount { id: ZONE.into() }), + }) + } + fn records(&self, token: &str, zone: &str) -> Result, CloudflareError> { + assert_eq!(zone, ZONE); + assert!(matches!(token, "dummy-child-token" | "dummy-rotated-token")); + Ok(self.records.clone()) + } + fn issue( + &self, + creator: &str, + zone: &str, + account: &str, + name: &str, + lifetime: Option, + ) -> Result { + assert_eq!(creator, "dummy-master-token"); + assert_eq!(zone, ZONE); + assert_eq!(account, ZONE); + assert_eq!(name, "skillet:production:clamps:ddns"); + assert_eq!(lifetime, None); + self.issued.set(self.issued.get() + 1); + Ok(IssuedToken { + id: "dummy-id".into(), + value: "dummy-child-token".into(), + expires_on: None, + }) + } + fn revoke(&self, _creator: &str, _account: &str, id: &str) -> Result<(), CloudflareError> { + assert!(id == "dummy-id" || id == "abcdefabcdefabcdefabcdefabcdefab"); + self.revoked.set(self.revoked.get() + 1); + if self.fail_revoke { + Err(CloudflareError::Invalid("revoke failed".into())) + } else { + Ok(()) + } + } + + fn replace( + &self, + _creator: &str, + _zone: &str, + _account: &str, + name: &str, + lifetime: Option, + ) -> Result { + assert!(name.starts_with("skillet:test:clamps-") && name.contains(":ddns")); + assert!(lifetime.is_some()); + self.issued.set(self.issued.get() + 1); + Ok(IssuedToken { + id: "abcdefabcdefabcdefabcdefabcdefab".into(), + value: "dummy-child-token".into(), + expires_on: Some("2030-01-01T00:00:00Z".into()), + }) + } + fn owned_test_records( + &self, + _token: &str, + _zone: &str, + marker: &str, + names: &[String], + ) -> Result, CloudflareError> { + let call = self.owned_calls.get(); + self.owned_calls.set(call + 1); + if call == 0 { + return Ok(Vec::new()); + } + Ok(names + .iter() + .map(|name| RecordRef { + id: "1234567890abcdef1234567890abcdef".into(), + name: name.clone(), + record_type: "A".into(), + content: "8.8.8.8".into(), + comment: Some(marker.into()), + }) + .collect()) + } + fn remove_test_records( + &self, + _token: &str, + _zone: &str, + marker: &str, + names: &[String], + ids: &[String], + ) -> Result<(), CloudflareError> { + assert!(marker.starts_with("skillet-ddns:test:")); + assert_ne!(ids.len(), 0); + assert_eq!(names.len(), ids.len()); + self.removed_records.set(ids.len()); + Ok(()) + } + fn token_ids( + &self, + _creator: &str, + _account: &str, + name: &str, + ) -> Result, CloudflareError> { + assert!(name.contains(":ddns")); + Ok(vec!["abcdefabcdefabcdefabcdefabcdefab".into()]) + } +} + +#[derive(Default)] +struct Guest { + payloads: RefCell>>, + fail: Cell, +} +impl GuestTransport for Guest { + fn execute( + &self, + command: &GuestCommand<'_>, + payload: Option<&[u8]>, + ) -> skillet_vm::Result { + assert_eq!(command.program, "/usr/bin/sudo"); + assert!(command.arguments.contains(&"cloudflare_ddns_config")); + assert!(command.arguments.contains(&"skillet-ddns-apply.service")); + assert!(command + .arguments + .iter() + .all(|arg| !arg.contains("dummy-child-token") && !arg.contains("dummy-master-token"))); + let payload = payload.unwrap(); + assert!(!String::from_utf8_lossy(payload).contains("dummy-master-token")); + skillet_ddns::config::Payload::parse(std::str::from_utf8(payload).unwrap()).unwrap(); + self.payloads.borrow_mut().push(payload.to_vec()); + Ok(Output { + status: ExitStatus::from_raw(if self.fail.get() { 256 } else { 0 }), + stdout: Vec::new(), + stderr: Vec::new(), + }) + } + fn upload(&self, _source: &Path, _destination: &str) -> skillet_vm::Result<()> { + panic!("delivery must use stdin") + } +} +fn request(config: &str) -> DdnsDelivery<'_> { + DdnsDelivery { + host: "clamps", + policy: ProvisioningPolicy::new(Environment::Production), + zone_id: ZONE, + relative_ui_domain: None, + config, + creator_token: "dummy-master-token", + } +} + +#[test] +fn creates_once_and_reuses_saved_token_after_delivery_failure() { + let mut store = Store::default(); + let provider = Provider::default(); + let guest = Guest::default(); + guest.fail.set(true); + assert!(deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).is_err()); + guest.fail.set(false); + deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).unwrap(); + assert_eq!(provider.issued.get(), 1); + assert_eq!( + store.entries["skillet/environments/prod/hosts/clamps/cloudflare/ddns-token"], + "dummy-child-token" + ); + assert_eq!(guest.payloads.borrow()[0], guest.payloads.borrow()[1]); + store.entries.insert( + "skillet/environments/prod/hosts/clamps/cloudflare/ddns-token".into(), + "dummy-rotated-token".into(), + ); + deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).unwrap(); + assert_eq!(provider.issued.get(), 1); + assert!(String::from_utf8_lossy(&guest.payloads.borrow()[2]).contains("dummy-rotated-token")); +} + +#[test] +fn invalid_config_ui_namespace_and_test_policy_never_issue_or_deliver() { + let mut store = Store::default(); + let provider = Provider::default(); + let guest = Guest::default(); + for config in [ + "invalid", + r#"{"version":1,"records":[{"name":"edge.ui","proxied":false}]}"#, + ] { + assert!(deliver_persistent_ddns(&request(config), &mut store, &provider, &guest).is_err()); + } + let mut request = request(CONFIG); + request.policy = ProvisioningPolicy::new(Environment::Test); + assert!(deliver_persistent_ddns(&request, &mut store, &provider, &guest).is_err()); + request.policy = ProvisioningPolicy::new(Environment::Production); + request.host = "agent"; + assert!(deliver_persistent_ddns(&request, &mut store, &provider, &guest).is_err()); + assert_eq!(provider.issued.get(), 0); + assert!(guest.payloads.borrow().is_empty()); +} + +#[test] +fn save_failure_revokes_and_vault_conflict_prevents_issuance() { + let provider = Provider::default(); + let guest = Guest::default(); + let mut store = Store { + fail_save: true, + ..Store::default() + }; + assert!(deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).is_err()); + assert_eq!(provider.revoked.get(), 1); + assert!(guest.payloads.borrow().is_empty()); + store.changed = true; + assert!(deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &guest).is_err()); + assert_eq!(provider.issued.get(), 1); +} + +#[test] +fn revocation_failure_returns_safe_recovery_error() { + let provider = Provider { + fail_revoke: true, + ..Provider::default() + }; + let mut store = Store { + fail_save: true, + ..Store::default() + }; + let error = deliver_persistent_ddns(&request(CONFIG), &mut store, &provider, &Guest::default()) + .unwrap_err(); + assert!(error.to_string().contains("named-token recovery")); + assert!(!format!("{error:?} {error}").contains("dummy-child-token")); +} + +fn existing(kind: &str, comment: Option<&str>) -> RecordRef { + RecordRef { + id: "record-id".into(), + name: "edge.example.com".into(), + record_type: kind.into(), + content: "192.0.2.1".into(), + comment: comment.map(str::to_string), + } +} +#[test] +fn only_explicit_single_public_a_record_takeover_is_allowed() { + let mut config = PrivateConfig::parse(CONFIG).unwrap(); + assert!(validate_takeover(&config, "example.com", MARKER, &[existing("A", None)]).is_err()); + assert!(validate_takeover( + &config, + "example.com", + MARKER, + &[existing("A", Some(MARKER))] + ) + .is_ok()); + config.takeover_existing = true; + assert!(validate_takeover(&config, "example.com", MARKER, &[existing("A", None)]).is_ok()); + for records in [ + vec![existing("CNAME", None)], + vec![existing("A", Some("skillet:production:clamps"))], + vec![existing("A", Some("skillet-ddns:production:other"))], + vec![existing("A", None), existing("A", None)], + ] { + assert!(validate_takeover(&config, "example.com", MARKER, &records).is_err()); + } +} + +#[test] +fn disposable_provision_and_cleanup_are_journaled_and_identity_bound() { + let directory = tempfile::tempdir().unwrap(); + let provider = Provider::default(); + let guest = Guest::default(); + let policy = ProvisioningPolicy::new(Environment::Test); + let request = DisposableDdnsRequest { + host: "clamps", + instance: "ddns-smoke", + policy, + zone_id: ZONE, + relative_ui_domain: Some("ui"), + config: CONFIG, + creator_token: "dummy-master-token", + run_directory: directory.path(), + }; + provision_disposable_ddns( + &request, + &provider, + &guest, + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ) + .unwrap(); + let path = directory.path().join("ddns.json"); + let owner = crate::provisioning_state::load_ddns_ownership(&path).unwrap(); + assert_eq!(owner.identity.host, "clamps"); + assert_eq!(owner.identity.environment, "test"); + assert_eq!(owner.record_names, ["edge.example.com"]); + assert_eq!(owner.record_ids, ["1234567890abcdef1234567890abcdef"]); + let cleanup = DisposableDdnsCleanup { + host: "clamps", + instance: "ddns-smoke", + policy, + ownership_path: &path, + configured_zone_id: ZONE, + creator_token: "dummy-master-token", + }; + assert!(cleanup_disposable_ddns(&cleanup, &provider).unwrap()); + assert_eq!(provider.removed_records.get(), 1); + assert!(!crate::provisioning_state::ddns_ownership_exists(&path).unwrap()); + assert!(!cleanup_disposable_ddns(&cleanup, &provider).unwrap()); +} + +#[test] +fn disposable_cleanup_refuses_identity_and_zone_mismatches_before_api_changes() { + let directory = tempfile::tempdir().unwrap(); + let provider = Provider::default(); + let policy = ProvisioningPolicy::new(Environment::Test); + let request = DisposableDdnsRequest { + host: "clamps", + instance: "ddns-smoke", + policy, + zone_id: ZONE, + relative_ui_domain: None, + config: CONFIG, + creator_token: "dummy-master-token", + run_directory: directory.path(), + }; + provision_disposable_ddns( + &request, + &provider, + &Guest::default(), + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ) + .unwrap(); + let path = directory.path().join("ddns.json"); + let wrong = DisposableDdnsCleanup { + host: "clamps", + instance: "another", + policy, + ownership_path: &path, + configured_zone_id: ZONE, + creator_token: "dummy-master-token", + }; + assert!(cleanup_disposable_ddns(&wrong, &provider).is_err()); + assert_eq!(provider.removed_records.get(), 0); + assert!(!public_ipv4("100.64.0.2")); + assert!(!public_ipv4("192.0.2.1")); + assert!(public_ipv4("8.8.8.8")); +} + +#[test] +fn rejects_zones_that_exceed_the_updaters_record_page() { + let config = PrivateConfig::parse(CONFIG).unwrap(); + let records = (0..100) + .map(|index| RecordRef { + name: format!("other-{index}.example.com"), + ..existing("A", None) + }) + .collect::>(); + assert!(validate_takeover(&config, "example.com", MARKER, &records).is_err()); + assert!(validate_takeover(&config, "example.com", MARKER, &records[..99]).is_ok()); +} diff --git a/ublue/skillet/crates/workstation/src/installation.rs b/ublue/skillet/crates/workstation/src/installation.rs new file mode 100644 index 00000000..2eb1b1e2 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/installation.rs @@ -0,0 +1,378 @@ +//! Physical install media preparation. Never opens or writes a host block device. +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde_yml::Value; +use sha2::{Digest as _, Sha256}; +use skillet_vm::install::RootProfile; +use std::{ + fs, + io::Write as _, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::Command, + time::Duration, +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid installation request: {0}")] + Invalid(String), + #[error("installation artifact I/O: {0}")] + Io(#[from] std::io::Error), + #[error("installation YAML: {0}")] + Yaml(#[from] serde_yml::Error), + #[error("installation tooling: {0}")] + Tool(#[from] skillet_vm::Error), +} +pub type Result = std::result::Result; + +pub struct Request<'a> { + pub host: &'a str, + pub disk: &'a str, + pub root_profile: RootProfile, + pub iso: &'a Path, + pub output: &'a Path, + pub public_key: &'a Path, + pub password_hash: &'a Path, + pub mok_certificate: Option<&'a Path>, + pub butane: &'a Path, + pub workspace: &'a Path, +} + +/// Prepare an explicitly new private bundle. Existing bundles are never overwritten. +pub fn prepare(request: &Request<'_>) -> Result { + validate_request(request)?; + let key = fs::read_to_string(request.public_key)?; + let hash = fs::read_to_string(request.password_hash)?; + let source = request.butane.join(format!("{}.bu", request.host)); + let mut config: Value = serde_yml::from_str(&fs::read_to_string(source)?)?; + skillet_vm::install::normalize_butane_modes(&mut config)?; + specialize(&mut config, request.disk, request.root_profile)?; + let mut passwd: Value = serde_yml::from_str(&fs::read_to_string( + request.butane.join("includes/passwd.bu"), + )?)?; + configure_access(&mut passwd, &key, &hash)?; + skillet_vm::install::normalize_butane_modes(&mut passwd)?; + let iso = fs::canonicalize(request.iso)?; + if !iso.is_file() { + return Err(Error::Invalid("ISO must be a regular file".into())); + } + // Complete validation before building or publishing any artifacts. + let package = format!("skillet-{}", request.host); + let binaries = skillet_vm::artifacts::build(&skillet_vm::artifacts::BuildRequest { + workspace: request.workspace, + packages: &["skillet", &package], + target: "x86_64-unknown-linux-musl", + profile: "release", + })?; + let parent = request + .output + .parent() + .ok_or_else(|| Error::Invalid("output needs a parent directory".into()))?; + fs::create_dir_all(parent)?; + let staging = tempfile::Builder::new() + .prefix(".skillet-install-") + .tempdir_in(parent)?; + fs::set_permissions(staging.path(), fs::Permissions::from_mode(0o700))?; + let source_dir = stage_source(request, staging.path(), &config, &passwd, &binaries)?; + let mut compiler = Command::new(request.butane.join("bin/butane")); + compiler + .args(["-s", "-o"]) + .arg(staging.path()) + .arg(source_dir.join(format!("{}.bu", request.host))); + run(compiler, "Butane compilation")?; + let ignition_path = staging.path().join(format!("{}.ign", request.host)); + fs::set_permissions(&ignition_path, fs::Permissions::from_mode(0o600))?; + let ignition = fs::read(ignition_path)?; + let helper = fs::read(request.butane.join("bin/install-hardware"))?; + let certificate = request.mok_certificate.map(fs::read).transpose()?; + let live = live_ignition( + request.disk, + request.root_profile, + &ignition, + &helper, + key.trim(), + certificate.as_deref(), + ); + write_private( + &staging.path().join("live.ign"), + &serde_json::to_vec_pretty(&live).map_err(skillet_vm::Error::from)?, + )?; + customize_iso(staging.path(), &iso)?; + // No-clobber directory publication, including concurrent preparations. + fs::create_dir(request.output)?; + fs::set_permissions(request.output, fs::Permissions::from_mode(0o700))?; + for entry in fs::read_dir(staging.path())? { + let entry = entry?; + fs::rename(entry.path(), request.output.join(entry.file_name()))?; + } + Ok(request.output.join("install.iso")) +} + +fn run(command: Command, operation: &str) -> Result<()> { + let output = skillet_vm::capture_command(command, Duration::from_mins(30))?; + if !output.status.success() { + let mut diagnostic = tempfile::Builder::new() + .prefix("skillet-install-diagnostic-") + .suffix(".log") + .tempfile()?; + diagnostic.write_all(&output.stdout)?; + diagnostic.write_all(&output.stderr)?; + let (_, path) = diagnostic.keep().map_err(|error| error.error)?; + return Err(Error::Invalid(format!( + "{operation} failed (exit {:?}); inspect private diagnostic {}. Partial staging was removed", + output.status.code(), path.display() + ))); + } + Ok(()) +} + +fn write_private(path: &Path, data: &[u8]) -> Result<()> { + fs::write(path, data)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +fn specialize(config: &mut Value, disk: &str, profile: RootProfile) -> Result<()> { + skillet_vm::install::configure_disk(config, disk)?; + skillet_vm::install::configure_root(config, profile)?; + Ok(()) +} + +fn configure_access(passwd: &mut Value, public_key: &str, hash: &str) -> Result<()> { + let key = public_key.trim(); + let hash = hash.trim(); + let mut parts = key.split_whitespace(); + if parts.next() != Some("ssh-ed25519") + || parts.next().is_none_or(|part| { + STANDARD.decode(part).map_or(true, |decoded| { + decoded.len() != 51 || !decoded.starts_with(b"\0\0\0\x0bssh-ed25519\0\0\0\x20") + }) + }) + || key.contains(['\n', '\r']) + { + return Err(Error::Invalid( + "provide one OpenSSH Ed25519 public key".into(), + )); + } + if !valid_password_hash(hash) { + return Err(Error::Invalid( + "provide a private crypt-format bootstrap password hash file".into(), + )); + } + let users = passwd + .get_mut("passwd") + .and_then(|v| v.get_mut("users")) + .and_then(Value::as_sequence_mut) + .ok_or_else(|| Error::Invalid("missing production user declarations".into()))?; + let mut found = false; + for user in users { + user["ssh_authorized_keys"] = serde_yml::to_value([key])?; + if user["name"].as_str() == Some("giacomo") { + user["password_hash"] = Value::String(hash.into()); + found = true; + } + } + if !found { + return Err(Error::Invalid("missing provisioning account".into())); + } + Ok(()) +} + +fn live_ignition( + disk: &str, + profile: RootProfile, + ignition: &[u8], + helper: &[u8], + public_key: &str, + certificate: Option<&[u8]>, +) -> serde_json::Value { + let mut files = vec![ + live_file( + "/etc/skillet-install/ignition.sha256", + 0o600, + hex::encode(Sha256::digest(ignition)).as_bytes(), + ), + live_file("/etc/skillet-install/disk", 0o600, disk.as_bytes()), + live_file( + "/etc/skillet-install/profile", + 0o600, + if profile == RootProfile::Tpm { + b"tpm" + } else { + b"unencrypted" + }, + ), + live_file("/var/usrlocal/bin/skillet-install", 0o700, helper), + ]; + if let Some(cert) = certificate { + files.push(live_file("/etc/skillet-install/ublue.der", 0o600, cert)); + } + serde_json::json!({"ignition": {"version": "3.5.0"}, "storage": {"files": files}, "passwd": {"users": [{"name": "core", "sshAuthorizedKeys": [public_key]}]}}) +} +fn live_file(path: &str, mode: u32, contents: &[u8]) -> serde_json::Value { + serde_json::json!({"path": path, "mode": mode, "contents": {"source": format!("data:;base64,{}", STANDARD.encode(contents))}}) +} + +#[cfg(test)] +#[path = "installation/tests.rs"] +mod tests; + +fn reject_symlinks(path: &Path) -> Result<()> { + for component in path.ancestors() { + match fs::symlink_metadata(component) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(Error::Invalid( + "symlink in installation output or source includes".into(), + )) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +fn stage_source( + request: &Request<'_>, + staging: &Path, + config: &Value, + passwd: &Value, + binaries: &std::collections::BTreeMap, +) -> Result { + let package = format!("skillet-{}", request.host); + let source_dir = staging.join("source"); + fs::create_dir_all(source_dir.join("includes"))?; + fs::create_dir(source_dir.join("files"))?; + for entry in fs::read_dir(request.butane.join("includes"))? { + let entry = entry?; + if entry.path().extension().is_some_and(|ext| ext == "bu") { + reject_symlinks(&entry.path())?; + fs::copy( + entry.path(), + source_dir.join("includes").join(entry.file_name()), + )?; + } + } + write_private( + &source_dir.join(format!("{}.bu", request.host)), + serde_yml::to_string(&config)?.as_bytes(), + )?; + write_private( + &source_dir.join("includes/passwd.bu"), + serde_yml::to_string(&passwd)?.as_bytes(), + )?; + for name in ["skillet", package.as_str()] { + let binary = binaries + .get(name) + .ok_or_else(|| Error::Invalid("missing build artifact".into()))?; + let target = source_dir.join("files").join(name); + fs::copy(binary, &target)?; + fs::set_permissions(target, fs::Permissions::from_mode(0o755))?; + } + Ok(source_dir) +} + +fn customize_iso(staging: &Path, iso: &Path) -> Result<()> { + let mut customize = Command::new("podman"); + customize + .args(["run", "--rm", "--security-opt", "label=disable", "--volume"]) + .arg(format!("{}:/work", staging.display())) + .arg("--volume") + .arg(format!("{}:/input.iso:ro", iso.display())) + .args([ + "quay.io/coreos/coreos-installer:release", + "iso", + "customize", + "--live-ignition", + "/work/live.ign", + "--output", + "/work/install.iso", + "/input.iso", + ]); + run(customize, "CoreOS ISO customization")?; + fs::set_permissions( + staging.join("install.iso"), + fs::Permissions::from_mode(0o600), + )?; + Ok(()) +} + +fn validate_request(request: &Request<'_>) -> Result<()> { + skillet_vm::install::validate_physical_disk(request.disk)?; + if !request + .host + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + || request.host.is_empty() + || skillet_hosts::profile_for_name(request.host).is_none() + || !request + .workspace + .join("crates/hosts") + .join(request.host) + .join("Cargo.toml") + .is_file() + { + return Err(Error::Invalid("unknown host profile".into())); + } + if request.root_profile == RootProfile::Tpm && request.mok_certificate.is_none() { + return Err(Error::Invalid( + "TPM media requires --mok-certificate; enroll it before installation".into(), + )); + } + if !request.output.is_absolute() + || request + .output + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(Error::Invalid( + "output must be an absolute path without parent traversal".into(), + )); + } + let workspace = fs::canonicalize(request.workspace)?; + let repository = workspace + .parent() + .and_then(Path::parent) + .ok_or_else(|| Error::Invalid("workspace must belong to a repository".into()))?; + if request.output.starts_with(repository) { + return Err(Error::Invalid( + "private install bundle must be outside the repository".into(), + )); + } + reject_symlinks(request.output)?; + if request.output.exists() { + return Err(Error::Invalid( + "output already exists; choose a new bundle directory".into(), + )); + } + Ok(()) +} + +fn valid_password_hash(hash: &str) -> bool { + fn crypt_text(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"./".contains(&b)) + } + let fields: Vec<_> = hash.split('$').collect(); + match fields.as_slice() { + ["", algorithm @ ("5" | "6"), salt, digest] => { + crypt_text(salt) + && salt.len() <= 16 + && crypt_text(digest) + && digest.len() == if *algorithm == "6" { 86 } else { 43 } + } + ["", algorithm @ ("5" | "6"), rounds, salt, digest] => { + rounds + .strip_prefix("rounds=") + .is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) + && valid_password_hash(&format!("${algorithm}${salt}${digest}")) + } + ["", "y", parameters, salt, digest] => { + crypt_text(parameters) && crypt_text(salt) && crypt_text(digest) && digest.len() == 43 + } + _ => false, + } +} diff --git a/ublue/skillet/crates/workstation/src/installation/tests.rs b/ublue/skillet/crates/workstation/src/installation/tests.rs new file mode 100644 index 00000000..4c8491ab --- /dev/null +++ b/ublue/skillet/crates/workstation/src/installation/tests.rs @@ -0,0 +1,134 @@ +use super::*; +fn host() -> Value { + serde_yml::from_str(include_str!("../../../../../butane/clamps.bu")).unwrap() +} +fn passwd() -> Value { + serde_yml::from_str(include_str!("../../../../../butane/includes/passwd.bu")).unwrap() +} +#[test] +fn physical_profiles_preserve_vm_source() { + let original = host(); + for profile in [RootProfile::Unencrypted, RootProfile::Tpm] { + let mut config = original.clone(); + specialize(&mut config, "/dev/disk/by-id/ata-example", profile).unwrap(); + assert_eq!( + config["storage"]["disks"][0]["device"].as_str(), + Some("/dev/disk/by-id/ata-example") + ); + assert_eq!( + config["storage"]["filesystems"][0]["device"].as_str(), + Some(if profile == RootProfile::Tpm { + "/dev/mapper/root" + } else { + "/dev/disk/by-partlabel/root" + }) + ); + assert_eq!( + config["storage"]["disks"][0]["partitions"][0]["resize"].as_bool(), + Some(true) + ); + } + assert_eq!(host(), original); + assert_eq!( + original["storage"]["disks"][0]["device"].as_str(), + Some("/dev/vda") + ); +} +#[test] +fn disk_selection_rejects_partitions_ambient_devices_and_conflicts() { + for disk in [ + "/dev/sda", + "/dev/vda", + "/dev/disk/by-id/x-part1", + "/dev/disk/by-id/../x", + "/dev/disk/by-id/", + "/dev/disk/by-id/x\n", + ] { + assert!(specialize(&mut host(), disk, RootProfile::Unencrypted).is_err()); + } + let mut config = host(); + config["storage"]["disks"][0]["device"] = Value::String("/dev/sda".into()); + assert!(specialize(&mut config, "/dev/disk/by-id/ata-example", RootProfile::Tpm).is_err()); +} +#[test] +fn production_access_replaces_inherited_keys_and_hash() { + let key = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG5BCzEZkae5DssrD+FFi0a4YGYT55b57LbXW8SveytN operator"; + let mut value = passwd(); + let hash = format!("$6$testSalt${}", "A".repeat(86)); + configure_access(&mut value, key, &hash).unwrap(); + for user in value["passwd"]["users"].as_sequence().unwrap() { + assert_eq!(user["ssh_authorized_keys"].as_sequence().unwrap().len(), 1); + assert_eq!(user["ssh_authorized_keys"][0].as_str(), Some(key)); + } + assert!(value["passwd"]["users"][1]["password_hash"] + .as_str() + .is_some_and(|value| value == hash)); + assert!(configure_access(&mut passwd(), key, "$6$malformed").is_err()); + assert!(value.get("storage").is_none()); + assert!(configure_access(&mut passwd(), "not-a-public-key", "password").is_err()); +} +#[test] +fn numeric_modes_and_live_media_require_manual_installation() { + let mut value = host(); + skillet_vm::install::normalize_butane_modes(&mut value).unwrap(); + assert_eq!(value["storage"]["files"][0]["mode"].as_u64(), Some(420)); + let live = live_ignition( + "/dev/disk/by-id/ata-example", + RootProfile::Tpm, + b"private ignition", + b"helper", + "ssh-ed25519 test-public-key", + Some(b"certificate"), + ); + assert!(live.get("systemd").is_none()); + assert_eq!(live["passwd"]["users"][0]["name"], "core"); + assert_eq!( + live["passwd"]["users"][0]["sshAuthorizedKeys"][0], + "ssh-ed25519 test-public-key" + ); + assert!(serde_json::to_vec(&live).unwrap().len() < 262_144); + assert_eq!(live["storage"]["files"][0]["mode"], 384); + assert_eq!(live["storage"]["files"][3]["mode"], 448); + assert_eq!(live["storage"]["files"].as_array().unwrap().len(), 5); + assert_eq!( + live["storage"]["files"][0]["contents"]["source"], + format!( + "data:;base64,{}", + STANDARD.encode(hex::encode(Sha256::digest(b"private ignition"))) + ) + ); +} + +#[test] +fn requests_reject_missing_tpm_certificate_unknown_hosts_and_public_outputs() { + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let butane = workspace.join("../butane"); + let temporary = tempfile::tempdir().unwrap(); + let output = temporary.path().join("bundle"); + let mut request = Request { + host: "clamps", + disk: "/dev/disk/by-id/ata-example", + root_profile: RootProfile::Unencrypted, + iso: Path::new("missing.iso"), + output: &output, + public_key: Path::new("missing.pub"), + password_hash: Path::new("missing.hash"), + mok_certificate: None, + butane: &butane, + workspace: &workspace, + }; + validate_request(&request).unwrap(); + request.root_profile = RootProfile::Tpm; + assert!(validate_request(&request).is_err()); + request.root_profile = RootProfile::Unencrypted; + request.host = "unknown-profile"; + assert!(validate_request(&request).is_err()); + request.host = "clamps"; + let public_output = fs::canonicalize(&workspace).unwrap().join("install-bundle"); + request.output = &public_output; + assert!(validate_request(&request).is_err()); + request.output = &output; + fs::create_dir(&output).unwrap(); + assert!(validate_request(&request).is_err()); +} diff --git a/ublue/skillet/crates/workstation/src/lib.rs b/ublue/skillet/crates/workstation/src/lib.rs new file mode 100644 index 00000000..a7736f33 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/lib.rs @@ -0,0 +1,19 @@ +//! Workstation-only provisioning capabilities. + +pub mod cloudflare; +pub mod configuration_templates; +pub mod credential_delivery; +pub mod ddns_provisioning; +pub mod provisioning_policy; +pub mod provisioning_state; +pub mod secrets; +pub mod smtp_provisioning; +pub mod tailscale; +pub mod tailscale_enrollment; +pub mod ui_provisioning; +pub mod vault; +pub mod zone_token; + +pub mod root_recovery; + +pub mod installation; diff --git a/ublue/skillet/crates/workstation/src/provisioning_policy.rs b/ublue/skillet/crates/workstation/src/provisioning_policy.rs new file mode 100644 index 00000000..46db81a2 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/provisioning_policy.rs @@ -0,0 +1,79 @@ +use std::time::Duration; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Environment { + Production, + Test, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DeviceClass { + ProductionHost, + DisposableVm, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProvisioningPolicy { + environment: Environment, +} + +impl ProvisioningPolicy { + pub const fn new(environment: Environment) -> Self { + Self { environment } + } + + pub const fn environment(self) -> Environment { + self.environment + } + + pub const fn name(self) -> &'static str { + match self.environment { + Environment::Production => "production", + Environment::Test => "test", + } + } + + pub const fn vault_name(self) -> &'static str { + match self.environment { + Environment::Production => "prod", + Environment::Test => "test", + } + } + + pub fn ui_domain_entry(self) -> String { + format!("skillet/environments/{}/dns/ui-domain", self.vault_name()) + } + + pub fn cloudflare_zone_entry(self) -> String { + format!( + "skillet/environments/{}/dns/cloudflare-zone-id", + self.vault_name() + ) + } + + pub const fn acme_staging(self) -> bool { + matches!(self.environment, Environment::Test) + } + + pub const fn cloudflare_token_lifetime(self) -> Option { + match self.environment { + Environment::Production => None, + Environment::Test => Some(Duration::from_hours(12)), + } + } + + pub const fn cleanup_token_lifetime(self) -> Duration { + Duration::from_mins(15) + } + + pub const fn tailscale_tag(self, device: DeviceClass) -> &'static str { + match device { + DeviceClass::ProductionHost => crate::tailscale::SERVER_TAG, + DeviceClass::DisposableVm => crate::tailscale::SMOKE_TAG, + } + } +} + +#[cfg(test)] +#[path = "provisioning_policy/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs b/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs new file mode 100644 index 00000000..c5251806 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/provisioning_policy/tests.rs @@ -0,0 +1,50 @@ +use super::{DeviceClass, Environment, ProvisioningPolicy}; +use std::time::Duration; + +#[test] +fn production_policy_selects_durable_credentials_and_live_acme() { + let policy = ProvisioningPolicy::new(Environment::Production); + + assert_eq!(policy.name(), "production"); + assert_eq!(policy.vault_name(), "prod"); + assert_eq!( + policy.ui_domain_entry(), + "skillet/environments/prod/dns/ui-domain" + ); + assert_eq!( + policy.cloudflare_zone_entry(), + "skillet/environments/prod/dns/cloudflare-zone-id" + ); + assert_eq!(policy.cloudflare_token_lifetime(), None); + assert!(!policy.acme_staging()); + assert_eq!( + policy.tailscale_tag(DeviceClass::ProductionHost), + "tag:skillet-server" + ); +} + +#[test] +fn disposable_policy_selects_short_lived_credentials_and_staging_acme() { + let policy = ProvisioningPolicy::new(Environment::Test); + + assert_eq!(policy.name(), "test"); + assert_eq!(policy.vault_name(), "test"); + assert_eq!( + policy.ui_domain_entry(), + "skillet/environments/test/dns/ui-domain" + ); + assert_eq!( + policy.cloudflare_zone_entry(), + "skillet/environments/test/dns/cloudflare-zone-id" + ); + assert_eq!( + policy.cloudflare_token_lifetime(), + Some(Duration::from_hours(12)) + ); + assert_eq!(policy.cleanup_token_lifetime(), Duration::from_mins(15)); + assert!(policy.acme_staging()); + assert_eq!( + policy.tailscale_tag(DeviceClass::DisposableVm), + "tag:skillet-smoke" + ); +} diff --git a/ublue/skillet/crates/workstation/src/provisioning_state.rs b/ublue/skillet/crates/workstation/src/provisioning_state.rs new file mode 100644 index 00000000..9e9f083a --- /dev/null +++ b/ublue/skillet/crates/workstation/src/provisioning_state.rs @@ -0,0 +1,422 @@ +//! Durable ownership records for disposable provider resources. + +use crate::tailscale::DeviceRecord; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File, Permissions}, + io, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, +}; +use tempfile::NamedTempFile; +use thiserror::Error; + +const PRIVATE_FILE_MODE: u32 = 0o600; + +#[derive(Debug, Error)] +pub enum ProvisioningStateError { + #[error("provisioning state I/O failed: {0}")] + Io(#[from] io::Error), + #[error("provisioning state JSON is invalid: {0}")] + Json(#[from] serde_json::Error), + #[error("refusing invalid provisioning state path: {0}")] + InvalidPath(&'static str), + #[error("could not atomically persist provisioning state at {path}: {source}")] + Persist { path: PathBuf, source: io::Error }, + #[error("pending Tailscale identity does not match the recorded VM")] + PendingIdentityMismatch, + #[error( + "provider ownership identity does not match the requested host, environment, and instance" + )] + IdentityMismatch, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProvisioningIdentity { + pub host: String, + pub environment: String, + pub instance: String, +} + +impl ProvisioningIdentity { + pub fn new( + host: impl Into, + environment: impl Into, + instance: impl Into, + ) -> Self { + Self { + host: host.into(), + environment: environment.into(), + instance: instance.into(), + } + } +} + +/// External DNS/token ownership needed to recover or clean up a test VM. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudflareVmOwnership { + /// Present in newly written journals; absent only in the legacy JSON shape. + #[serde(default)] + pub identity: Option, + pub environment: String, + pub marker: String, + pub zone_id: String, + pub ui_domain: String, + pub token_name: String, + pub token_id: Option, + pub expires_on: Option, + pub record_ids: Vec, +} + +/// Ownership for public DDNS records created by one disposable VM. +/// The configured names are private; this file remains mode 0600 under runs/. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DdnsVmOwnership { + pub identity: ProvisioningIdentity, + pub zone_id: String, + pub marker: String, + pub token_name: String, + pub token_id: Option, + pub expires_on: Option, + pub record_names: Vec, + #[serde(default)] + pub record_ids: Vec, + pub cleanup_token_name: String, + pub cleanup_token_id: Option, +} + +pub fn save_ddns_ownership( + path: &Path, + ownership: &DdnsVmOwnership, +) -> Result<(), ProvisioningStateError> { + write_json_atomically(path, ownership) +} + +pub fn load_ddns_ownership(path: &Path) -> Result { + read_json(path) +} + +pub fn ddns_ownership_exists(path: &Path) -> Result { + regular_file_exists(path, "DDNS ownership state must be a regular file") +} + +pub fn remove_ddns_ownership(path: &Path) -> Result<(), ProvisioningStateError> { + remove_regular_file(path, "DDNS ownership state must be a regular file") +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct TailscaleVmOwnership { + identity: ProvisioningIdentity, + device: DeviceRecord, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct PendingTailscaleIdentity { + identity: ProvisioningIdentity, + hostname: String, +} + +pub fn save_cloudflare_ownership( + path: &Path, + ownership: &CloudflareVmOwnership, +) -> Result<(), ProvisioningStateError> { + write_json_atomically(path, ownership) +} + +pub fn load_cloudflare_ownership( + path: &Path, +) -> Result { + read_json(path) +} + +pub fn cloudflare_ownership_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(ProvisioningStateError::InvalidPath( + "Cloudflare ownership state must be a regular file", + )) + } + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +pub fn remove_cloudflare_ownership(path: &Path) -> Result<(), ProvisioningStateError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(ProvisioningStateError::InvalidPath( + "Cloudflare ownership state must be a regular file", + )) + } + Ok(_) => { + fs::remove_file(path)?; + if let Some(parent) = path.parent() { + File::open(parent)?.sync_all()?; + } + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +/// Confirm that a Cloudflare journal belongs to the requested typed identity. +/// Legacy journals are still validated by their exact marker and token name at +/// the call site; new journals must match every identity field here. +pub fn validate_cloudflare_identity( + ownership: &CloudflareVmOwnership, + expected: &ProvisioningIdentity, +) -> Result<(), ProvisioningStateError> { + if ownership.environment != expected.environment + || ownership + .identity + .as_ref() + .is_some_and(|identity| identity != expected) + { + return Err(ProvisioningStateError::IdentityMismatch); + } + Ok(()) +} + +pub fn save_tailscale_record( + run_directory: &Path, + identity: &ProvisioningIdentity, + record: &DeviceRecord, +) -> Result<(), ProvisioningStateError> { + write_json_atomically( + &run_directory.join("tailscale.json"), + &TailscaleVmOwnership { + identity: identity.clone(), + device: record.clone(), + }, + ) +} + +pub fn load_tailscale_record( + path: &Path, + expected_identity: &ProvisioningIdentity, + expected_hostname: &str, +) -> Result { + let bytes = read_bytes(path)?; + if let Ok(state) = serde_json::from_slice::(&bytes) { + if &state.identity != expected_identity || state.device.hostname != expected_hostname { + return Err(ProvisioningStateError::IdentityMismatch); + } + return Ok(state.device); + } + let legacy: DeviceRecord = serde_json::from_slice(&bytes)?; + if legacy.hostname != expected_hostname { + return Err(ProvisioningStateError::IdentityMismatch); + } + Ok(legacy) +} + +pub fn tailscale_record_exists(run_directory: &Path) -> Result { + regular_file_exists( + &run_directory.join("tailscale.json"), + "Tailscale ownership state", + ) +} + +pub fn remove_tailscale_record(run_directory: &Path) -> Result<(), ProvisioningStateError> { + remove_regular_file( + &run_directory.join("tailscale.json"), + "Tailscale ownership state", + ) +} + +pub fn tailscale_pending_exists(run_directory: &Path) -> Result { + regular_file_exists( + &run_directory.join("tailscale-pending"), + "pending Tailscale state", + ) +} + +pub fn validate_tailscale_pending( + run_directory: &Path, + expected_identity: &ProvisioningIdentity, + expected_hostname: &str, +) -> Result { + let path = run_directory.join("tailscale-pending"); + if !regular_file_exists(&path, "pending Tailscale state")? { + return Ok(false); + } + let contents = fs::read(path)?; + if let Ok(pending) = serde_json::from_slice::(&contents) { + if pending.identity != *expected_identity || pending.hostname != expected_hostname { + return Err(ProvisioningStateError::IdentityMismatch); + } + } else if String::from_utf8_lossy(&contents) != expected_hostname { + return Err(ProvisioningStateError::IdentityMismatch); + } + Ok(true) +} + +/// Persist cleanup intent before creating a disposable Tailscale device. +/// Repeated calls must describe the same VM identity. +pub fn mark_tailscale_pending( + run_directory: &Path, + identity: &ProvisioningIdentity, + hostname: &str, +) -> Result<(), ProvisioningStateError> { + let path = run_directory.join("tailscale-pending"); + ensure_parent(&path)?; + match fs::symlink_metadata(&path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ProvisioningStateError::InvalidPath( + "pending Tailscale state must be a regular file", + )); + } + let contents = fs::read(&path)?; + if let Ok(pending) = serde_json::from_slice::(&contents) { + if pending.identity == *identity && pending.hostname == hostname { + return Ok(()); + } + return Err(ProvisioningStateError::PendingIdentityMismatch); + } + if String::from_utf8_lossy(&contents) == hostname { + // Legacy markers only contain the deterministic VM hostname. + // The caller also validates host/environment/instance from + // the VM manifest before acting on this retained run. + return Ok(()); + } + return Err(ProvisioningStateError::PendingIdentityMismatch); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + + let mut file = NamedTempFile::new_in(run_directory)?; + serde_json::to_writer( + file.as_file_mut(), + &PendingTailscaleIdentity { + identity: identity.clone(), + hostname: hostname.to_string(), + }, + )?; + file.as_file() + .set_permissions(Permissions::from_mode(PRIVATE_FILE_MODE))?; + file.as_file().sync_all()?; + match file.persist_noclobber(&path) { + Ok(_) => {} + Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ProvisioningStateError::PendingIdentityMismatch); + } + let contents = fs::read(&path)?; + let matches_new = serde_json::from_slice::(&contents) + .is_ok_and(|pending| pending.identity == *identity && pending.hostname == hostname); + let matches_legacy = String::from_utf8_lossy(&contents) == hostname; + if !matches_new && !matches_legacy { + return Err(ProvisioningStateError::PendingIdentityMismatch); + } + } + Err(error) => { + return Err(ProvisioningStateError::Persist { + path, + source: error.error, + }); + } + } + File::open(run_directory)?.sync_all()?; + Ok(()) +} + +pub fn remove_tailscale_pending(run_directory: &Path) -> Result<(), ProvisioningStateError> { + let path = run_directory.join("tailscale-pending"); + remove_regular_file(&path, "pending Tailscale state") +} + +fn regular_file_exists(path: &Path, label: &'static str) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(ProvisioningStateError::InvalidPath(label)) + } + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +fn remove_regular_file(path: &Path, label: &'static str) -> Result<(), ProvisioningStateError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(ProvisioningStateError::InvalidPath(label)) + } + Ok(_) => { + fs::remove_file(path)?; + if let Some(parent) = path.parent() { + File::open(parent)?.sync_all()?; + } + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn write_json_atomically( + path: &Path, + value: &T, +) -> Result<(), ProvisioningStateError> { + let parent = path.parent().ok_or(ProvisioningStateError::InvalidPath( + "metadata file has no parent", + ))?; + ensure_parent(path)?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(ProvisioningStateError::InvalidPath( + "metadata destination must be a regular file", + )); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let mut file = NamedTempFile::new_in(parent)?; + serde_json::to_writer(file.as_file_mut(), value)?; + file.as_file() + .set_permissions(Permissions::from_mode(PRIVATE_FILE_MODE))?; + file.as_file().sync_all()?; + file.persist(path) + .map_err(|error| ProvisioningStateError::Persist { + path: path.to_path_buf(), + source: error.error, + })?; + File::open(parent)?.sync_all()?; + Ok(()) +} + +fn read_json Deserialize<'de>>(path: &Path) -> Result { + Ok(serde_json::from_slice(&read_bytes(path)?)?) +} + +fn read_bytes(path: &Path) -> Result, ProvisioningStateError> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ProvisioningStateError::InvalidPath( + "metadata source must be a regular file", + )); + } + Ok(fs::read(path)?) +} + +fn ensure_parent(path: &Path) -> Result<(), ProvisioningStateError> { + let parent = path.parent().ok_or(ProvisioningStateError::InvalidPath( + "metadata file has no parent", + ))?; + let metadata = fs::symlink_metadata(parent)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(ProvisioningStateError::InvalidPath( + "metadata parent must be a real directory", + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "provisioning_state/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs b/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs new file mode 100644 index 00000000..ff3065ca --- /dev/null +++ b/ublue/skillet/crates/workstation/src/provisioning_state/tests.rs @@ -0,0 +1,212 @@ +use super::*; +use crate::tailscale::DeviceRecord; +use std::{ + collections::BTreeSet, + fs, + os::unix::fs::{symlink, PermissionsExt}, +}; + +fn ownership() -> CloudflareVmOwnership { + CloudflareVmOwnership { + environment: "test".to_string(), + identity: Some(identity()), + marker: "skillet:test:host:instance".to_string(), + zone_id: "zone-id".to_string(), + ui_domain: "ui.example.invalid".to_string(), + token_name: "skillet:test:host-instance".to_string(), + token_id: Some("token-id".to_string()), + expires_on: Some("2030-01-01T00:00:00Z".to_string()), + record_ids: vec!["record-id".to_string()], + } +} + +fn identity() -> ProvisioningIdentity { + ProvisioningIdentity { + host: "host".to_string(), + environment: "test".to_string(), + instance: "instance".to_string(), + } +} + +#[test] +fn ddns_ownership_journal_is_private_and_removable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ddns.json"); + let owner = DdnsVmOwnership { + identity: identity(), + zone_id: "0123456789abcdef0123456789abcdef".into(), + marker: "skillet-ddns:test:host:instance".into(), + token_name: "skillet:test:host-instance:ddns".into(), + token_id: Some("abcdef0123456789abcdef0123456789".into()), + expires_on: Some("2030-01-01T00:00:00Z".into()), + record_names: vec!["edge.example.invalid".into()], + record_ids: vec!["abcdef0123456789abcdef0123456789".into()], + cleanup_token_name: "skillet:test:host-instance:ddns:cleanup".into(), + cleanup_token_id: None, + }; + save_ddns_ownership(&path, &owner).unwrap(); + assert_eq!(load_ddns_ownership(&path).unwrap(), owner); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + PRIVATE_FILE_MODE + ); + assert!(ddns_ownership_exists(&path).unwrap()); + remove_ddns_ownership(&path).unwrap(); + assert!(!ddns_ownership_exists(&path).unwrap()); +} + +#[test] +fn cloudflare_ownership_is_private_atomic_json_state() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cloudflare.json"); + let value = ownership(); + + save_cloudflare_ownership(&path, &value).unwrap(); + + assert_eq!(load_cloudflare_ownership(&path).unwrap(), value); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + PRIVATE_FILE_MODE + ); + validate_cloudflare_identity(&value, &identity()).unwrap(); + let mut wrong = identity(); + wrong.instance = "other".to_string(); + assert!(matches!( + validate_cloudflare_identity(&value, &wrong), + Err(ProvisioningStateError::IdentityMismatch) + )); +} + +#[test] +fn cloudflare_ownership_presence_and_removal_reject_non_regular_paths() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cloudflare.json"); + assert!(!cloudflare_ownership_exists(&path).unwrap()); + save_cloudflare_ownership(&path, &ownership()).unwrap(); + assert!(cloudflare_ownership_exists(&path).unwrap()); + remove_cloudflare_ownership(&path).unwrap(); + assert!(!cloudflare_ownership_exists(&path).unwrap()); + + let target = directory.path().join("target.json"); + fs::write(&target, b"{}").unwrap(); + let link = directory.path().join("link.json"); + symlink(&target, &link).unwrap(); + assert!(matches!( + cloudflare_ownership_exists(&link), + Err(ProvisioningStateError::InvalidPath(_)) + )); + assert!(matches!( + remove_cloudflare_ownership(&link), + Err(ProvisioningStateError::InvalidPath(_)) + )); + assert!(target.exists()); +} + +#[test] +fn legacy_cloudflare_journal_remains_readable_but_new_identity_mismatches_fail() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cloudflare.json"); + let mut legacy = ownership(); + legacy.identity = None; + fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + + let loaded = load_cloudflare_ownership(&path).unwrap(); + assert_eq!(loaded, legacy); + validate_cloudflare_identity(&loaded, &identity()).unwrap(); +} + +#[test] +fn tailscale_record_is_private_and_symlinks_are_rejected() { + let directory = tempfile::tempdir().unwrap(); + let record = DeviceRecord { + id: "device-id".to_string(), + hostname: "host-instance".to_string(), + addresses: BTreeSet::from(["100.64.0.10".to_string()]), + }; + save_tailscale_record(directory.path(), &identity(), &record).unwrap(); + assert_eq!( + load_tailscale_record( + &directory.path().join("tailscale.json"), + &identity(), + "host-instance" + ) + .unwrap(), + record + ); + let mut wrong = identity(); + wrong.host = "other".to_string(); + assert!(matches!( + load_tailscale_record( + &directory.path().join("tailscale.json"), + &wrong, + "host-instance" + ), + Err(ProvisioningStateError::IdentityMismatch) + )); + + let target = directory.path().join("target.json"); + fs::write(&target, b"{}").unwrap(); + let link = directory.path().join("linked.json"); + symlink(target, &link).unwrap(); + assert!(matches!( + load_tailscale_record(&link, &identity(), "host-instance"), + Err(ProvisioningStateError::InvalidPath(_)) + )); + assert!(matches!( + save_cloudflare_ownership(&link, &ownership()), + Err(ProvisioningStateError::InvalidPath(_)) + )); +} + +#[test] +fn legacy_tailscale_record_is_accepted_only_for_its_expected_hostname() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("tailscale.json"); + let legacy = DeviceRecord { + id: "legacy-device-id".to_string(), + hostname: "host-instance".to_string(), + addresses: BTreeSet::new(), + }; + fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + + assert_eq!( + load_tailscale_record(&path, &identity(), "host-instance").unwrap(), + legacy + ); + assert!(matches!( + load_tailscale_record(&path, &identity(), "other-instance"), + Err(ProvisioningStateError::IdentityMismatch) + )); +} + +#[test] +fn pending_tailscale_marker_is_repeatable_and_identity_bound() { + let directory = tempfile::tempdir().unwrap(); + mark_tailscale_pending(directory.path(), &identity(), "host-instance").unwrap(); + mark_tailscale_pending(directory.path(), &identity(), "host-instance").unwrap(); + assert!(matches!( + mark_tailscale_pending(directory.path(), &identity(), "another-instance"), + Err(ProvisioningStateError::PendingIdentityMismatch) + )); + let mut wrong = identity(); + wrong.instance = "other".to_string(); + assert!(matches!( + mark_tailscale_pending(directory.path(), &wrong, "host-instance"), + Err(ProvisioningStateError::PendingIdentityMismatch) + )); + remove_tailscale_pending(directory.path()).unwrap(); + remove_tailscale_pending(directory.path()).unwrap(); + assert!(!directory.path().join("tailscale-pending").exists()); +} + +#[test] +fn legacy_pending_marker_remains_usable_when_hostname_matches() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("tailscale-pending"), "host-instance").unwrap(); + + mark_tailscale_pending(directory.path(), &identity(), "host-instance").unwrap(); + assert!(matches!( + mark_tailscale_pending(directory.path(), &identity(), "other-instance"), + Err(ProvisioningStateError::PendingIdentityMismatch) + )); +} diff --git a/ublue/skillet/crates/workstation/src/root_recovery.rs b/ublue/skillet/crates/workstation/src/root_recovery.rs new file mode 100644 index 00000000..c8956e00 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/root_recovery.rs @@ -0,0 +1,259 @@ +//! Portable LUKS recovery enrollment with write-ahead vault persistence. +use crate::vault::{Vault, VaultError}; +use skillet_vm::transport::{GuestCommand, GuestTransport}; +use std::{io::Read as _, path::Path}; +use thiserror::Error; +use zeroize::Zeroizing; + +#[derive(Debug, Error)] +pub enum RecoveryError { + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), + #[error("root recovery: {0}")] + Invalid(&'static str), + #[error("recovery-key randomness is unavailable: {0}")] + Randomness(#[from] std::io::Error), +} + +pub const HEADER_ATTACHMENT: &str = "root-luks.header"; + +pub struct Record { + pub key: Zeroizing, + pub volume: String, + pub verified: bool, + pub header: Option>>, +} + +/// Cohesive persistence of a key and its enrollment/recovery identity. +pub trait RecoveryStore { + fn load(&self, path: &str) -> Result, RecoveryError>; + fn save(&mut self, path: &str, record: &Record) -> Result<(), RecoveryError>; +} + +pub struct VaultStore<'a> { + pub vault: &'a mut Vault, + pub key_file: Option<&'a Path>, +} +impl RecoveryStore for VaultStore<'_> { + fn load(&self, path: &str) -> Result, RecoveryError> { + let Some(key) = self.vault.get(path)? else { + return Ok(None); + }; + let volume = self + .vault + .get_field(path, "luks-uuid")? + .ok_or(RecoveryError::Invalid( + "entry lacks luks-uuid; refusing to replace it", + ))?; + let state = self + .vault + .get_field(path, "recovery-state")? + .ok_or(RecoveryError::Invalid("entry lacks recovery-state"))?; + if !matches!(state.as_str(), "pending" | "verified") { + return Err(RecoveryError::Invalid("invalid recovery-state")); + } + Ok(Some(Record { + key: Zeroizing::new(key), + volume, + verified: state == "verified", + header: self.vault.get_attachment(path, HEADER_ATTACHMENT)?, + })) + } + fn save(&mut self, path: &str, record: &Record) -> Result<(), RecoveryError> { + self.vault.ensure_unchanged()?; + if self.vault.get(path)?.is_none() { + self.vault.insert(path, &record.key)?; + } + self.vault.set_field(path, "luks-uuid", &record.volume)?; + self.vault.set_field( + path, + "recovery-state", + if record.verified { + "verified" + } else { + "pending" + }, + )?; + if let Some(header) = &record.header { + self.vault.set_attachment(path, HEADER_ATTACHMENT, header)?; + self.vault.save_verified_attachment( + self.key_file, + path, + &record.key, + HEADER_ATTACHMENT, + header, + )?; + } else { + self.vault.save_verified(self.key_file, path, &record.key)?; + } + Ok(()) + } +} + +pub fn generate_key() -> Result { + let mut bytes = Zeroizing::new([0u8; 64]); + std::fs::File::open("/dev/urandom")?.read_exact(&mut *bytes)?; + Ok(hex::encode(bytes.as_slice())) +} + +/// Persist before enrollment; interrupted pending enrollment safely reuses its key. +pub fn provision( + host: &str, + path: &str, + helper: &Path, + store: &mut impl RecoveryStore, + guest: &impl GuestTransport, + generate: impl FnOnce() -> Result, +) -> Result<(), RecoveryError> { + if text(guest, "cat", &["/etc/skillet/host"])? != host { + return Err(RecoveryError::Invalid( + "SSH host identity differs from selected host", + )); + } + let status = text(guest, "sudo", &["-n", "cryptsetup", "status", "root"])?; + let source = text(guest, "findmnt", &["-T", "/var", "-n", "-o", "SOURCE"])?; + if !status.contains("LUKS2") + || !(source == "/dev/mapper/root" || source.starts_with("/dev/mapper/root[")) + { + return Err(RecoveryError::Invalid("requires mapper-backed LUKS2 root")); + } + let backing = status + .lines() + .find_map(|line| line.trim().strip_prefix("device:").map(str::trim)) + .ok_or(RecoveryError::Invalid("root backing device is absent"))?; + if text(guest, "readlink", &["-f", backing])? + != text(guest, "readlink", &["-f", "/dev/disk/by-partlabel/root"])? + { + return Err(RecoveryError::Invalid( + "root partition label does not identify the active root", + )); + } + let volume = text( + guest, + "sudo", + &[ + "-n", + "cryptsetup", + "luksUUID", + "/dev/disk/by-partlabel/root", + ], + )?; + if volume.len() != 36 + || !volume.bytes().enumerate().all(|(i, b)| { + if [8, 13, 18, 23].contains(&i) { + b == b'-' + } else { + b.is_ascii_hexdigit() + } + }) + { + return Err(RecoveryError::Invalid("invalid LUKS volume identity")); + } + let existing = store.load(path)?; + let created = existing.is_none(); + let mut record = match existing { + Some(record) => record, + None => Record { + key: Zeroizing::new(generate()?), + volume: volume.clone(), + verified: false, + header: None, + }, + }; + if record.volume != volume { + return Err(RecoveryError::Invalid( + "vault recovery key belongs to another volume; refusing replacement", + )); + } + if record.key.len() != 128 + || !record + .key + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(RecoveryError::Invalid( + "recovery key must be 128 lowercase hex characters", + )); + } + if created || !record.verified { + store.save(path, &record)?; + } + let remote = "/var/tmp/skillet-root-recovery.sh"; + guest.upload(helper, remote)?; + if !record.verified { + run_key(guest, remote, "enroll", &record.key)?; + } + run_key(guest, remote, "verify", &record.key)?; + let header = capture_header(guest, remote, &record.key)?; + if !record.verified || record.header.as_deref() != Some(&*header) { + record.header = Some(header); + record.verified = true; + store.save(path, &record)?; + } + Ok(()) +} + +fn capture_header( + guest: &impl GuestTransport, + remote: &str, + key: &str, +) -> Result>, RecoveryError> { + let output = guest.execute( + &GuestCommand { + program: "sudo", + arguments: &["-n", "bash", remote, "header"], + }, + Some(key.as_bytes()), + )?; + if !output.status.success() { + return Err(RecoveryError::Invalid( + "header backup verification failed; recovery key remains in vault", + )); + } + let header = Zeroizing::new(output.stdout); + if !(4096..=64 * 1024 * 1024).contains(&header.len()) + || !header.starts_with(b"LUKS\xba\xbe\x00\x02") + { + return Err(RecoveryError::Invalid("invalid LUKS2 header backup")); + } + Ok(header) +} + +fn text( + guest: &impl GuestTransport, + program: &str, + arguments: &[&str], +) -> Result { + let out = guest.execute(&GuestCommand { program, arguments }, None)?; + if !out.status.success() { + return Err(RecoveryError::Invalid("guest preflight failed")); + } + String::from_utf8(out.stdout) + .map(|s| s.trim().to_owned()) + .map_err(|_| RecoveryError::Invalid("invalid guest preflight output")) +} +fn run_key( + guest: &impl GuestTransport, + script: &str, + action: &str, + key: &str, +) -> Result<(), RecoveryError> { + let out = guest.execute( + &GuestCommand { + program: "sudo", + arguments: &["-n", "bash", script, action], + }, + Some(key.as_bytes()), + )?; + if !out.status.success() { + return Err(RecoveryError::Invalid( + "guest enrollment/verification failed; key remains in vault for retry", + )); + } + Ok(()) +} +#[cfg(test)] +#[path = "root_recovery/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/root_recovery/tests.rs b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs new file mode 100644 index 00000000..fe4eb910 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/root_recovery/tests.rs @@ -0,0 +1,268 @@ +use super::*; +use std::{cell::RefCell, os::unix::process::ExitStatusExt, process::Output}; +const UUID: &str = "12345678-1234-1234-1234-123456789abc"; +#[derive(Default)] +struct Store { + record: Option, + fail: bool, +} +impl RecoveryStore for Store { + fn load(&self, _: &str) -> Result, RecoveryError> { + Ok(self.record.as_ref().map(|r| Record { + key: Zeroizing::new(r.key.to_string()), + volume: r.volume.clone(), + verified: r.verified, + header: r.header.clone(), + })) + } + fn save(&mut self, _: &str, record: &Record) -> Result<(), RecoveryError> { + if self.fail { + return Err(RecoveryError::Invalid("save failed")); + } + self.record = Some(Record { + key: Zeroizing::new(record.key.to_string()), + volume: record.volume.clone(), + verified: record.verified, + header: record.header.clone(), + }); + Ok(()) + } +} +#[derive(Default)] +struct Guest { + calls: RefCell>, + fail: bool, +} +impl GuestTransport for Guest { + fn upload(&self, _: &Path, _: &str) -> skillet_vm::Result<()> { + self.calls.borrow_mut().push("upload".into()); + Ok(()) + } + fn execute( + &self, + command: &GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + let action = command.arguments.last().copied().unwrap_or(""); + self.calls.borrow_mut().push(action.into()); + if let Some(input) = input { + assert_eq!(input.len(), 128); + assert!(!command.arguments.iter().any(|a| a.len() == 128)); + } + let output = if command.program == "readlink" { + "/dev/sda4" + } else if command.program == "cat" { + "clamps" + } else if command.program == "findmnt" { + "/dev/mapper/root[/var]" + } else if command.arguments.contains(&"status") { + "type: LUKS2\n device: /dev/sda4" + } else if command.arguments.contains(&"luksUUID") { + UUID + } else { + "" + }; + Ok(Output { + status: std::process::ExitStatus::from_raw( + i32::from(self.fail && action == "enroll") * 256, + ), + stdout: if action == "header" { + let mut bytes = vec![0; 4096]; + bytes[..8].copy_from_slice(b"LUKS\xba\xbe\x00\x02"); + bytes + } else { + output.as_bytes().to_vec() + }, + stderr: vec![], + }) + } +} +fn run(store: &mut Store, guest: &Guest) -> Result<(), RecoveryError> { + provision("clamps", "path", Path::new("helper"), store, guest, || { + Ok("a".repeat(128)) + }) +} +#[test] +fn new_key_is_saved_verified_and_repeat_is_verify_only() { + let mut store = Store::default(); + let guest = Guest::default(); + run(&mut store, &guest).unwrap(); + assert!(store.record.as_ref().unwrap().verified); + guest.calls.borrow_mut().clear(); + run(&mut store, &guest).unwrap(); + assert!(!guest.calls.borrow().iter().any(|s| s == "enroll")); +} +#[test] +fn interrupted_enrollment_reuses_pending_key() { + let mut store = Store::default(); + assert!(run( + &mut store, + &Guest { + fail: true, + ..Guest::default() + } + ) + .is_err()); + assert!(!store.record.as_ref().unwrap().verified); + provision( + "clamps", + "path", + Path::new("helper"), + &mut store, + &Guest::default(), + || panic!("must reuse saved key"), + ) + .unwrap(); + assert!(store.record.unwrap().verified); +} +#[test] +fn failed_persistence_and_volume_mismatch_never_enroll() { + let mut store = Store { + fail: true, + ..Store::default() + }; + let guest = Guest::default(); + assert!(run(&mut store, &guest).is_err()); + assert!(!guest.calls.borrow().iter().any(|s| s == "upload")); + store.fail = false; + store.record = Some(Record { + key: Zeroizing::new("a".repeat(128)), + volume: "another".into(), + verified: true, + header: None, + }); + assert!(run(&mut store, &guest).is_err()); + assert!(!guest.calls.borrow().iter().any(|s| s == "upload")); +} + +#[test] +fn wrong_host_never_saves() { + let mut store = Store::default(); + assert!(provision( + "joey", + "path", + Path::new("helper"), + &mut store, + &Guest::default(), + || panic!("must not generate") + ) + .is_err()); + assert!(store.record.is_none()); +} + +#[test] +fn printable_generated_key_has_full_entropy_and_no_newline() { + let a = generate_key().unwrap(); + let b = generate_key().unwrap(); + assert_eq!(a.len(), 128); + assert!(a + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))); + assert_ne!(a, b); +} + +#[test] +fn verified_key_is_not_silently_reenrolled_on_verification_failure() { + struct FailedVerify(Guest); + impl GuestTransport for FailedVerify { + fn upload(&self, source: &Path, target: &str) -> skillet_vm::Result<()> { + self.0.upload(source, target) + } + fn execute( + &self, + command: &GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + let mut output = self.0.execute(command, input)?; + if command.arguments.last() == Some(&"verify") { + output.status = std::process::ExitStatus::from_raw(256); + } + Ok(output) + } + } + let guest = FailedVerify(Guest::default()); + let mut store = Store { + record: Some(Record { + key: Zeroizing::new("a".repeat(128)), + volume: UUID.into(), + verified: true, + header: None, + }), + fail: false, + }; + assert!(provision( + "clamps", + "path", + Path::new("helper"), + &mut store, + &guest, + || panic!("must reuse") + ) + .is_err()); + assert!(store.record.unwrap().verified); + assert!(!guest.0.calls.borrow().iter().any(|s| s == "enroll")); +} + +#[test] +fn missing_header_is_migrated_without_rotating_verified_key() { + let mut store = Store { + record: Some(Record { + key: Zeroizing::new("a".repeat(128)), + volume: UUID.into(), + verified: true, + header: None, + }), + fail: false, + }; + let guest = Guest::default(); + run(&mut store, &guest).unwrap(); + assert_eq!( + store + .record + .as_ref() + .unwrap() + .header + .as_ref() + .unwrap() + .len(), + 4096 + ); + assert!(!guest.calls.borrow().iter().any(|action| action == "enroll")); + store.fail = true; + run(&mut store, &guest).unwrap(); // unchanged header must not rewrite the vault +} + +#[test] +fn invalid_header_keeps_pending_recovery_for_retry() { + struct BadHeader(Guest); + impl GuestTransport for BadHeader { + fn upload(&self, source: &Path, target: &str) -> skillet_vm::Result<()> { + self.0.upload(source, target) + } + fn execute( + &self, + command: &GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + let mut output = self.0.execute(command, input)?; + if command.arguments.last() == Some(&"header") { + output.stdout.clear(); + } + Ok(output) + } + } + let mut store = Store::default(); + assert!(provision( + "clamps", + "path", + Path::new("helper"), + &mut store, + &BadHeader(Guest::default()), + || Ok("a".repeat(128)) + ) + .is_err()); + assert!(!store.record.as_ref().unwrap().verified); + assert!(store.record.as_ref().unwrap().header.is_none()); + run(&mut store, &Guest::default()).unwrap(); + assert!(store.record.unwrap().verified); +} diff --git a/ublue/skillet/crates/workstation/src/secret-requirements.json b/ublue/skillet/crates/workstation/src/secret-requirements.json new file mode 100644 index 00000000..1cb3471d --- /dev/null +++ b/ublue/skillet/crates/workstation/src/secret-requirements.json @@ -0,0 +1,75 @@ +[ + {"module":"Btrbk","required_by":["btrbk"],"description":"No vault entries for the current local snapshot configuration.","entries":[]}, + {"module":"Caddy","required_by":["ui"],"description":"Prepare the shared Cloudflare entries. [UI setup](ublue/skillet/README.md#caddy-ui-credentials).","entries":[ + {"path":"skillet/environments//hosts//cloudflare/acme-token","status":"generated","help":"Skillet creates the persistent scoped token; do not create disposable VM tokens manually."}, + {"path":"skillet/hosts//cloudflare/acme-token","status":"generated","help":"Legacy production token read during migration. Do not create new entries at this path."} + ]}, + {"module":"Cloudflare","required_by":["ui","ddns"],"description":"Shared inputs for Caddy and DDNS.","entries":[ + {"path":"skillet/cloudflare/token-creator","status":"required","help":"Create an account-owned Cloudflare API token with Account API Tokens Write and Zone Read, restricted to the selected zones. [Permissions guide](ublue/skillet/README.md#secret-setup-and-delivery)."}, + {"path":"skillet/environments//dns/cloudflare-zone-id","status":"required","help":"Copy the Zone ID from the selected zone's Cloudflare Overview page."}, + {"path":"skillet/environments//dns/ui-domain","status":"optional","help":"Choose a relative UI prefix, such as ui; absent defaults to ui. Skillet appends the zone domain. Do not enter a full domain."} + ]}, + {"module":"Datadog","required_by":["datadog"],"template":"datadog","description":"One shared key serves production and test. No application key required for ingestion. Site is public template configuration, defaulting to datadoghq.com. [Setup](ublue/skillet/README.md#datadog).","entries":[ + {"path":"skillet/datadog/api-key","status":"required","help":"Create an ingestion API key in Datadog Organization Settings → API Keys. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."}, + {"path":"skillet/datadog/monitor-setup-key","status":"optional","help":"For workstation monitor administration only: create a scoped Application Key in Datadog Organization Settings → Application Keys with monitors_read and monitors_write. Never deliver this key to hosts. [Key guide](https://docs.datadoghq.com/account_management/api-app-keys/)."} + ]}, + {"module":"DDNS","required_by":["ddns"],"template":"ddns","description":"Prepare the shared Cloudflare token creator and Zone ID. [DDNS setup](ublue/skillet/README.md#optional-cloudflare-ddns).","entries":[ + {"path":"skillet/environments//hosts//cloudflare/ddns-dns-name","status":"required","help":"Choose the relative DNS record name, not a full domain."}, + {"path":"skillet/environments/prod/hosts//cloudflare/ddns-token","status":"generated","help":"Skillet creates the persistent scoped token; disposable tokens are lifecycle-owned."} + ]}, + {"module":"Mikochi","required_by":["mikochi"],"description":"No application password; uses the shared Tailnet UI allowlist. [Setup](ublue/skillet/README.md#private-file-browsing).","entries":[]}, + {"module":"NUT","required_by":["nut"],"description":"No vault entry required. Skillet generates a local-only monitoring password on each opted-in host; no operator login. [UPS setup](ublue/skillet/README.md#local-ups-protection).","entries":[]}, + {"module":"Pi-hole","required_by":["pihole"],"description":"[Delivery](ublue/skillet/README.md#secret-setup-and-delivery).","entries":[ + {"path":"skillet/hosts//pihole/web-password","status":"generated","help":"Skillet generates and saves the web admin password for a fresh host; optionally create it to choose a password. For an existing host, restore its entry rather than inventing a replacement."} + ]}, + { + "module": "SMTP", + "environments": ["prod"], + "required_by": [ + "smtp" + ], + "description": "Production-only native Postfix relay credentials; smoke machines use a local sink without these values. [SMTP plan](ublue/plan/CLAMPS-EMAIL.md).", + "entries": [ + { + "path": "skillet/smtp", + "status": "required", + "help": "Create one entry using your Mailjet SMTP credentials: Username = SMTP username; Password = SMTP password. Custom fields: host = in-v3.mailjet.com; port = 587; tls = starttls (required TLS with certificate verification); sender = a Mailjet-verified sender email address. [Mailjet credentials](https://documentation.mailjet.com/hc/en-us/articles/360043229473-How-can-I-configure-my-SMTP-parameters).", + "fields": [ + { + "name": "sender", + "rule": "sender" + }, + { + "name": "Password" + }, + { + "name": "UserName" + }, + { + "name": "host", + "rule": "hostname" + }, + { + "name": "port", + "rule": "port" + }, + { + "name": "tls", + "rule": "choice", + "values": [ + "starttls" + ] + } + ] + } + ] + }, + {"module":"Storage","required_by":["storage"],"description":"Generated independent LUKS root recovery passphrases. [Enrollment](ublue/skillet/README.md#root-recovery-keys). Password contains the printable passphrase; custom luks-uuid and recovery-state fields and the protected root-luks.header attachment are managed by Skillet.","entries":[{"path":"skillet/hosts//storage/root-recovery-key","status":"generated","help":"Run skillet secrets root-recovery-key before finishing production provisioning. The command unlocks the vault, saves before enrollment, and verifies the key against this volume.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]},{"path":"skillet/environments/test/hosts//instances//storage/root-recovery-key","status":"generated","help":"Run skillet test vm recovery-key HOST INSTANCE. Archived test keys remain in the vault after VM disposal; delete the instance entry before reusing its name for a new disk.","fields":[{"name":"Password"},{"name":"luks-uuid"},{"name":"recovery-state","rule":"choice","values":["pending","verified"]}]}]}, + {"module":"Syncthing","required_by":["syncthing"],"description":"No vault entries currently required; Skillet manages the private UI configuration.","entries":[]}, + {"module":"Tailnet UI","required_by":["tailnet-ui"],"description":"Identity authorization for caller-declared private UIs. Uses the existing host-network daemon; no second login. [Setup](ublue/skillet/README.md#private-file-browsing).","entries":[{"path":"skillet/tailscale/ui-users","status":"required","help":"Password = complete Tailscale LoginName values, separated by commas or newlines; Username is unused. Obtain the identities from tailscale status --json on an authenticated client. Both environments share this explicit allowlist.","fields":[{"name":"Password","rule":"tailnetusers"}]}]}, + {"module":"Tailscale","required_by":["tailscale"],"description":"Create an OAuth client in Tailscale Trust credentials with Auth Keys Write and Devices Core Read/Write, restricted to the provisioner tag. [Tag and scope guide](ublue/skillet/README.md#tailscale-setup-for-skillet). Skillet mints enrollment keys; do not create per-host auth-key entries.","entries":[ + {"path":"skillet/tailscale/provisioner-client-id","status":"required","help":"Copy the OAuth Client ID from the creation page; use Password, not Username."}, + {"path":"skillet/tailscale/provisioner-client-secret","status":"required","help":"Copy the OAuth Client secret from the creation page."} + ]}, + {"module":"UniFi","required_by":["unifi"],"description":"No vault entries currently required. Controller/UI accounts are managed in UniFi, separately from Skillet provisioning.","entries":[]} +] diff --git a/ublue/skillet/crates/workstation/src/secrets.rs b/ublue/skillet/crates/workstation/src/secrets.rs new file mode 100644 index 00000000..742d5705 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/secrets.rs @@ -0,0 +1,362 @@ +//! Read-only vault requirement auditing and its shared human documentation. +use crate::{configuration_templates, provisioning_policy::ProvisioningPolicy, vault::VaultError}; +use serde::Deserialize; +use skillet_hosts::HostProfile; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt::Write as _, +}; +use thiserror::Error; + +const CATALOG: &str = include_str!("secret-requirements.json"); +const INTRO: &str = "# Skillet secrets checklist\n\nVault: `$XDG_DATA_HOME/skillet/secrets.kdbx` (default:\n`~/.local/share/skillet/secrets.kdbx`). Paths are **group path + entry title**.\nUnless an entry documents other fields, put its value in **Password** and\nleave **Username** empty. `` is `prod` or `test`; `` is the\ncanonical host name. Only prepare entries for enabled modules.\n\nAudit with `skillet secrets check` (all declared hosts, production), or\n`skillet secrets check --host clamps --environment test`. It checks required fields and their formats, without provider calls or generating secrets.\nMissing optional/generated/planned entries do not fail the check.\n\n\n"; + +#[derive(Debug, Error)] +pub enum CheckError { + #[error("invalid secret requirements catalog: {0}")] + Catalog(#[from] serde_json::Error), + #[error("secret requirements catalog is inconsistent: {0}")] + InvalidCatalog(String), + #[error(transparent)] + Template(#[from] configuration_templates::ConfigurationTemplateError), + #[error(transparent)] + Vault(#[from] VaultError), +} + +#[derive(Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum Status { + Required, + Optional, + Generated, + Planned, +} + +impl Status { + const fn label(self) -> &'static str { + match self { + Self::Required => "Required", + Self::Optional => "Optional", + Self::Generated => "Generated", + Self::Planned => "Planned", + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RequirementGroup { + module: String, + required_by: Vec, + #[serde(default)] + environments: Vec, + description: String, + template: Option, + entries: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct Entry { + path: String, + status: Status, + help: String, + #[serde(default)] + fields: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct FieldRequirement { + name: String, + #[serde(default)] + rule: FieldRule, + #[serde(default)] + values: Vec, +} + +#[derive(Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "lowercase")] +enum FieldRule { + #[default] + Nonempty, + Hostname, + Port, + Choice, + Sender, + Tailnetusers, +} + +impl FieldRequirement { + fn valid(&self, value: &str) -> bool { + if value.trim().is_empty() { + return false; + } + match self.rule { + FieldRule::Nonempty => true, + FieldRule::Port => value.parse::().is_ok_and(|port| port != 0), + FieldRule::Choice => self.values.iter().any(|allowed| allowed == value), + FieldRule::Hostname => valid_hostname(value), + FieldRule::Sender => skillet_smtp::valid_sender(value), + FieldRule::Tailnetusers => skillet_caddy::auth::Policy::parse_users(value).is_ok(), + } + } +} + +fn valid_hostname(value: &str) -> bool { + if value.parse::().is_ok() { + return true; + } + let value = value.strip_suffix('.').unwrap_or(value); + !value.is_empty() + && value.len() <= 253 + && value.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) +} + +fn catalog() -> Result, CheckError> { + let modules: Vec = serde_json::from_str(CATALOG)?; + if modules + .windows(2) + .any(|pair| pair[0].module.to_lowercase() >= pair[1].module.to_lowercase()) + { + return Err(CheckError::InvalidCatalog( + "modules must be alphabetical".into(), + )); + } + Ok(modules) +} + +/// Missing/invalid entry metadata only; never retains or renders secret values. +pub struct MissingEntry { + pub module: String, + pub path: String, + pub guide: String, + pub invalid: bool, +} + +pub struct CheckReport { + pub checked: usize, + pub missing: Vec, +} + +/// Find unreferenced Skillet entry paths across all supplied profiles and both +/// environments. Optional/generated entries and legacy readers count as used; +/// planned requirements do not. Personal vault entries are outside this audit. +pub fn unused_paths( + profiles: &[HostProfile], + stored: &[String], +) -> Result, CheckError> { + let mut used = BTreeSet::new(); + for module in catalog()? { + for profile in profiles.iter().filter(|profile| selected(&module, profile)) { + for environment in ["prod", "test"] { + let references = module + .template + .as_deref() + .map(|service| { + configuration_templates::secret_paths( + service, + profile.id.as_str(), + environment, + ) + }) + .transpose()?; + for entry in &module.entries { + if entry.status == Status::Planned { + continue; + } + let path = expand(&entry.path, profile.id.as_str(), environment); + if entry.status != Status::Required + || references + .as_ref() + .is_none_or(|paths| paths.contains(&path)) + { + used.insert(path); + } + } + if let Some(paths) = references { + used.extend(paths); + } + } + } + } + Ok(stored + .iter() + .filter(|path| { + path.starts_with("skillet/") && !used.iter().any(|pattern| matches_entry(pattern, path)) + }) + .cloned() + .collect::>() + .into_iter() + .collect()) +} + +fn matches_entry(pattern: &str, path: &str) -> bool { + let Some((prefix, suffix)) = pattern.split_once("") else { + return pattern == path; + }; + path.strip_prefix(prefix) + .and_then(|v| v.strip_suffix(suffix)) + .is_some_and(|instance| skillet_vm::RunIdentity::new("fixture", instance).is_ok()) +} + +fn selected(module: &RequirementGroup, profile: &HostProfile) -> bool { + module.required_by.iter().any(|service| { + if service == "storage" { + true + } else if service == "tailnet-ui" { + profile.ui_services().iter().any(|ui| ui.tailnet_identity) + } else if service == "ui" { + !profile.ui_services().is_empty() + } else { + profile.supports_service(service) + } + }) +} + +fn expand(path: &str, host: &str, environment: &str) -> String { + path.replace("", host) + .replace("", environment) +} + +/// Check required manual entries for the selected canonical profiles. +/// Generated values may be absent; their presence alone does not prove recovery. +pub fn check( + profiles: &[HostProfile], + policy: ProvisioningPolicy, + lookup: &impl Fn(&str, &str) -> Result, VaultError>, +) -> Result { + let mut required = BTreeMap::new(); + for module in catalog()? { + if !module.environments.is_empty() + && !module + .environments + .iter() + .any(|env| env == policy.vault_name()) + { + continue; + } + for profile in profiles.iter().filter(|profile| selected(&module, profile)) { + let host = profile.id.as_str(); + let paths = module + .template + .as_deref() + .map(|service| { + configuration_templates::secret_paths(service, host, policy.vault_name()) + }) + .transpose()?; + let entries = module + .entries + .iter() + .filter(|entry| entry.status == Status::Required); + let documented = entries + .map(|entry| (expand(&entry.path, host, policy.vault_name()), entry)) + .collect::>(); + let paths = paths.unwrap_or_else(|| documented.keys().cloned().collect()); + for path in paths { + let entry = documented.get(&path).ok_or_else(|| { + CheckError::InvalidCatalog(format!( + "document template reference {path} in {}", + module.module + )) + })?; + required.entry(path).or_insert_with(|| { + ( + module.module.clone(), + format!("{} {}", module.description, entry.help), + entry.fields.clone(), + ) + }); + } + } + } + let mut report = CheckReport { + checked: required.len(), + missing: Vec::new(), + }; + for (path, (module, mut guide, mut fields)) in required { + if fields.is_empty() { + fields.push(FieldRequirement { + name: "Password".into(), + rule: FieldRule::Nonempty, + values: Vec::new(), + }); + } + let mut invalid_fields = Vec::new(); + let mut present = false; + let mut invalid = false; + for field in fields { + match lookup(&path, &field.name) { + Ok(Some(value)) => { + present = true; + if !field.valid(&value) { + invalid_fields.push(field.name); + } + } + Ok(None) => invalid_fields.push(field.name), + Err(VaultError::Invalid(_)) => { + invalid = true; + invalid_fields.push(field.name); + } + Err(error) => return Err(error.into()), + } + } + if invalid_fields.is_empty() { + continue; + } + let _ = write!( + guide, + " Missing or invalid fields: {}.", + invalid_fields.join(", ") + ); + report.missing.push(MissingEntry { + module, + path, + guide, + invalid: invalid || present, + }); + } + report.missing.sort_by(|a, b| { + a.module + .to_lowercase() + .cmp(&b.module.to_lowercase()) + .then(a.path.cmp(&b.path)) + }); + Ok(report) +} + +/// Render the same instructions used by the checker as the root checklist. +pub fn documentation() -> Result { + let mut output = INTRO.to_string(); + for module in catalog()? { + // Writing to String is infallible. + let _ = write!(output, "\n## {}\n\n{}\n", module.module, module.description); + if !module.entries.is_empty() { + output.push_str( + "\n| Entry | Status | Fields / how to obtain them |\n| --- | --- | --- |\n", + ); + for entry in module.entries { + let _ = writeln!( + output, + "| `{}` | {} | {} |", + entry.path, + entry.status.label(), + entry.help + ); + } + } + } + Ok(output) +} + +#[cfg(test)] +#[path = "secrets/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/secrets/tests.rs b/ublue/skillet/crates/workstation/src/secrets/tests.rs new file mode 100644 index 00000000..dffd09f5 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/secrets/tests.rs @@ -0,0 +1,280 @@ +use super::*; +use crate::provisioning_policy::Environment; + +#[test] +fn documentation_matches_the_shared_requirement_metadata() { + assert_eq!( + documentation().unwrap(), + include_str!("../../../../../../SECRETS.md") + ); + let modules = catalog().unwrap(); + for profile in skillet_hosts::profile::declared_profiles() { + for service in profile.services { + assert!( + modules + .iter() + .any(|module| module.required_by.iter().any(|name| name == service.name())), + "undocumented service {}", + service.name() + ); + } + } +} + +#[test] +fn manual_requirements_follow_profiles_environment_and_template_references() { + let profile = skillet_hosts::profile_for_name("clamps").unwrap(); + let report = check( + &[profile], + ProvisioningPolicy::new(Environment::Test), + &|_, _| Ok(None), + ) + .unwrap(); + assert_eq!(report.checked, 7); + assert_eq!(report.missing.len(), 7); + assert!(report.missing.iter().any( + |entry| entry.path == "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name" + )); + assert!(report + .missing + .iter() + .all(|entry| !entry.path.contains("/prod/") + && !entry.path.ends_with("web-password") + && !entry.path.ends_with("acme-token"))); + let profile = skillet_hosts::profile_for_name("joey").unwrap(); + let report = check( + &[profile], + ProvisioningPolicy::new(Environment::Production), + &|_, field| { + Ok(Some( + match field { + "port" => "587", + "tls" => "starttls", + "sender" => "server@example.com", + _ => "not-displayed", + } + .into(), + )) + }, + ) + .unwrap(); + assert_eq!(report.checked, 8); + assert!(report.missing.is_empty()); +} + +#[test] +fn invalid_and_empty_entries_are_reported_without_values_or_lookup_error_text() { + let profiles = skillet_hosts::profile::declared_profiles(); + let policy = ProvisioningPolicy::new(Environment::Production); + for lookup in [false, true] { + let report = check(&profiles, policy, &|_, _| { + if lookup { + Err(VaultError::Invalid("private-value-do-not-print".into())) + } else { + Ok(Some(String::new())) + } + }) + .unwrap(); + assert!(report + .missing + .iter() + .all(|entry| entry.invalid && !entry.guide.contains("private-value-do-not-print"))); + assert_eq!(report.checked, report.missing.len()); + } +} + +#[test] +fn unused_audit_keeps_both_environments_hosts_generated_and_legacy_paths() { + let stored = [ + "skillet/datadog/api-key", + "skillet/environments/test/datadog/api-key", + "skillet/environments/test/datadog/site", + "skillet/environments/test/hosts/clamps/cloudflare/ddns-dns-name", + "skillet/environments/prod/hosts/joey/cloudflare/acme-token", + "skillet/environments/test/hosts/joey/cloudflare/acme-token", + "skillet/hosts/clamps/cloudflare/acme-token", + "skillet/hosts/clamps/pihole/web-password", + "personal/not-for-skillet", + "skillet/environments/dns/cloudflare-zone-id", + "skillet/environments/prod/hosts/unknown/cloudflare/acme-token", + "skillet/environments/test/hosts/joey/cloudflare/ddns-dns-name", + ] + .map(str::to_string); + let unused = unused_paths(&skillet_hosts::profile::declared_profiles(), &stored).unwrap(); + assert_eq!(unused.len(), 4); + assert!(unused.contains(&"skillet/environments/test/datadog/api-key".into())); + assert!(unused.contains(&"skillet/environments/test/datadog/site".into())); + assert!(unused.contains(&"skillet/environments/dns/cloudflare-zone-id".into())); + assert!( + unused.contains(&"skillet/environments/prod/hosts/unknown/cloudflare/acme-token".into()) + ); + assert!( + !unused.contains(&"skillet/environments/test/hosts/joey/cloudflare/ddns-dns-name".into()) + ); +} + +#[test] +fn template_reference_discovery_is_vault_free() { + for environment in ["prod", "test"] { + let paths = + configuration_templates::secret_paths("datadog", "clamps", environment).unwrap(); + assert_eq!(paths, ["skillet/datadog/api-key"]); + } +} + +#[test] +fn smtp_fields_are_required_validated_and_never_rendered() { + let profiles = [skillet_hosts::profile_for_name("clamps").unwrap()]; + for environment in [Environment::Production] { + for bad_field in ["UserName", "Password", "host", "port", "tls", "sender"] { + for missing in [false, true] { + let report = check( + &profiles, + ProvisioningPolicy::new(environment), + &|path, field| { + if path != "skillet/smtp" { + return Ok(Some("fixture-only".into())); + } + if field == bad_field { + return Ok(if missing { + None + } else { + Some( + if matches!(field, "UserName" | "Password") { + " " + } else { + "PRIVATE-INVALID-VALUE!" + } + .into(), + ) + }); + } + Ok(Some( + match field { + "host" => "smtp.example.com", + "port" => "587", + "tls" => "starttls", + "sender" => "server@example.com", + _ => "PRIVATE-CREDENTIAL", + } + .into(), + )) + }, + ) + .unwrap(); + assert_eq!(report.missing.len(), 1); + let failure = &report.missing[0]; + assert_eq!(failure.path, "skillet/smtp"); + assert!(failure.invalid); + assert!(failure.guide.contains(bad_field)); + assert!(!failure.guide.contains("PRIVATE-")); + } + } + } +} + +#[test] +fn field_rules_reject_unsafe_hosts_ports_and_tls_modes() { + for value in [ + "", + " ", + "smtp://example.com", + "user@example.com", + "-bad.example", + "bad..example", + "bad\n.example", + ] { + assert!(!valid_hostname(value), "{value:?}"); + } + for value in ["smtp.example.com", "127.0.0.1", "::1"] { + assert!(valid_hostname(value)); + } + let port = FieldRequirement { + name: "port".into(), + rule: FieldRule::Port, + values: vec![], + }; + for value in ["0", "65536", "-1", "587\n", "587:25"] { + assert!(!port.valid(value)); + } + assert!(port.valid("587")); + let tls = FieldRequirement { + name: "tls".into(), + rule: FieldRule::Choice, + values: vec!["starttls".into()], + }; + for value in ["", "none", "optional", "STARTTLS"] { + assert!(!tls.valid(value)); + } + assert!(tls.valid("starttls")); +} + +#[test] +fn shared_smtp_is_counted_once_and_is_not_unused() { + let profiles = skillet_hosts::declared_profiles(); + let report = check( + &profiles, + ProvisioningPolicy::new(Environment::Production), + &|_, _| Ok(None), + ) + .unwrap(); + assert_eq!( + report + .missing + .iter() + .filter(|entry| entry.path == "skillet/smtp") + .count(), + 1 + ); + assert_eq!( + unused_paths(&profiles, &["skillet/smtp".into()]).unwrap(), + Vec::::new() + ); + let agent = skillet_hosts::profile_for_name("agent").unwrap(); + assert_eq!( + check( + &[agent], + ProvisioningPolicy::new(Environment::Test), + &|_, _| Ok(None) + ) + .unwrap() + .checked, + 0 + ); +} + +#[test] +fn capture_environment_does_not_require_or_read_smtp_provider_fields() { + let profiles = skillet_hosts::declared_profiles(); + let report = check( + &profiles, + ProvisioningPolicy::new(Environment::Test), + &|path, _| { + assert_ne!(path, "skillet/smtp"); + Ok(None) + }, + ) + .unwrap(); + assert!(report + .missing + .iter() + .all(|entry| entry.path != "skillet/smtp")); +} + +#[test] +fn recovery_inventory_recognizes_only_declared_hosts_and_valid_instances() { + let profiles = skillet_hosts::profile::declared_profiles(); + let paths: Vec = [ + "skillet/hosts/clamps/storage/root-recovery-key", + "skillet/environments/test/hosts/clamps/instances/smoke/storage/root-recovery-key", + "skillet/environments/test/hosts/unknown/instances/smoke/storage/root-recovery-key", + "skillet/environments/test/hosts/clamps/instances/a/b/storage/root-recovery-key", + ] + .into_iter() + .map(String::from) + .collect(); + assert_eq!( + unused_paths(&profiles, &paths).unwrap(), + vec![paths[3].clone(), paths[2].clone()] + ); +} diff --git a/ublue/skillet/crates/workstation/src/smtp_provisioning.rs b/ublue/skillet/crates/workstation/src/smtp_provisioning.rs new file mode 100644 index 00000000..0d0b755c --- /dev/null +++ b/ublue/skillet/crates/workstation/src/smtp_provisioning.rs @@ -0,0 +1,67 @@ +//! Shared SMTP payload construction; test policy never reads provider credentials. +use crate::{ + provisioning_policy::{Environment, ProvisioningPolicy}, + vault::VaultError, +}; +use skillet_smtp::{Input, SmtpError}; +use skillet_vm::{ + credential::{self, ActivationPolicy}, + transport::GuestTransport, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("missing SMTP vault field: {0}")] + Missing(&'static str), + #[error("host does not declare SMTP")] + Host, + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Config(#[from] SmtpError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), +} + +pub fn input( + policy: ProvisioningPolicy, + lookup: &impl Fn(&str, &str) -> Result, VaultError>, +) -> Result { + if policy.environment() == Environment::Test { + return Ok(Input::Capture {}); + } + let field = |name| lookup("skillet/smtp", name)?.ok_or(Error::Missing(name)); + let port = field("port")? + .parse::() + .map_err(|_| SmtpError::Invalid)?; + let input = Input::Production { + host: field("host")?, + port, + tls: field("tls")?, + username: field("UserName")?, + password: field("Password")?, + sender: field("sender")?, + }; + input.validate()?; + Ok(input) +} + +pub fn deliver(host: &str, input: &Input, guest: &impl GuestTransport) -> Result<(), Error> { + let profile = skillet_hosts::profile_for_name(host) + .filter(|p| p.supports_service("smtp")) + .ok_or(Error::Host)?; + credential::install( + guest, + profile.id.as_str(), + skillet_smtp::CREDENTIAL, + "skillet-smtp-apply.service", + ActivationPolicy::StartConsumer, + input.payload()?.as_bytes(), + )?; + Ok(()) +} + +#[cfg(test)] +#[path = "smtp_provisioning/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/smtp_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/smtp_provisioning/tests.rs new file mode 100644 index 00000000..c2fe6fd7 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/smtp_provisioning/tests.rs @@ -0,0 +1,35 @@ +use super::*; +#[test] +fn test_payload_never_reads_provider_credentials() { + let input = input(ProvisioningPolicy::new(Environment::Test), &|_, _| { + panic!("test must not open the vault") + }) + .unwrap(); + assert_eq!(input.payload().unwrap(), r#"{"mode":"capture"}"#); +} +#[test] +fn production_requires_all_fields_without_echoing_values() { + let policy = ProvisioningPolicy::new(Environment::Production); + for missing in ["host", "port", "tls", "UserName", "Password", "sender"] { + let result = input(policy, &|_, field| { + Ok(if field == missing { + None + } else { + Some( + match field { + "host" => "smtp.example.com", + "port" => "587", + "tls" => "starttls", + "sender" => "server@example.com", + _ => "fixture-secret", + } + .into(), + ) + }) + }); + let Err(error) = result else { + panic!("missing field accepted") + }; + assert!(!error.to_string().contains("fixture-secret")); + } +} diff --git a/ublue/skillet/crates/workstation/src/tailscale/mod.rs b/ublue/skillet/crates/workstation/src/tailscale/mod.rs new file mode 100644 index 00000000..acb3ec94 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/tailscale/mod.rs @@ -0,0 +1,486 @@ +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::BTreeSet, + error::Error as StdError, + fmt::Write as _, + time::{Duration, Instant}, +}; +use thiserror::Error; + +const API_BASE: &str = "https://api.tailscale.com/api/v2"; +pub const PROVISIONER_TAG: &str = "tag:skillet-provisioner"; +pub const SERVER_TAG: &str = "tag:skillet-server"; +pub const SMOKE_TAG: &str = "tag:skillet-smoke"; + +#[derive(Debug, Error)] +pub enum TailscaleError { + #[error("{context}: {source}")] + Source { + context: String, + #[source] + source: Box, + }, + #[error("{0}")] + Invalid(String), +} + +pub type Result = std::result::Result; + +fn source(context: impl Into, error: E) -> TailscaleError +where + E: StdError + Send + Sync + 'static, +{ + TailscaleError::Source { + context: context.into(), + source: Box::new(error), + } +} + +trait ResultContext { + fn context(self, context: impl Into) -> Result; +} + +impl ResultContext for std::result::Result +where + E: StdError + Send + Sync + 'static, +{ + fn context(self, context: impl Into) -> Result { + self.map_err(|error| source(context, error)) + } +} + +impl From for TailscaleError { + fn from(error: serde_json::Error) -> Self { + source("Tailscale JSON", error) + } +} + +pub struct OAuthCredentials { + client_id: String, + client_secret: String, + api: ApiClient, +} + +struct ApiClient { + base_url: String, + http: Client, +} + +impl ApiClient { + fn new(base_url: &str) -> Result { + let base_url = base_url.trim_end_matches('/'); + if !base_url.starts_with("http://") && !base_url.starts_with("https://") { + return Err(TailscaleError::Invalid( + "invalid Tailscale API base URL".into(), + )); + } + let http = Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .context("building Tailscale HTTP client")?; + Ok(Self { + base_url: base_url.to_string(), + http, + }) + } + + fn request( + &self, + method: reqwest::Method, + path: &str, + bearer: Option<&str>, + body: Option<(&str, &str)>, + ) -> Result { + let mut request = self + .http + .request(method, format!("{}{path}", self.base_url)); + if let Some(token) = bearer { + request = request.bearer_auth(token); + } + if let Some((content_type, payload)) = body { + request = request + .header(reqwest::header::CONTENT_TYPE, content_type) + .body(payload.to_string()); + } + let response = request.send().context("sending Tailscale API request")?; + let status = response.status(); + let payload = response.text().context("reading Tailscale API response")?; + if !status.is_success() { + let message = serde_json::from_str::(&payload) + .ok() + .and_then(|value| { + value + .get("message") + .and_then(Value::as_str) + .map(str::to_owned) + }); + return Err(match message { + Some(message) => TailscaleError::Invalid(format!( + "Tailscale API returned HTTP {}: {message}", + status.as_u16() + )), + None => TailscaleError::Invalid(format!( + "Tailscale API request failed with HTTP {}", + status.as_u16() + )), + }); + } + if payload.is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(&payload).context("decoding Tailscale API response") + } +} + +impl OAuthCredentials { + pub fn new(client_id: String, client_secret: String) -> Result { + if client_id.trim().is_empty() || client_secret.trim().is_empty() { + return Err(TailscaleError::Invalid( + "Tailscale OAuth credentials are empty".into(), + )); + } + Ok(Self { + client_id, + client_secret, + api: ApiClient::new(API_BASE)?, + }) + } + + #[cfg(test)] + fn with_api_base(client_id: String, client_secret: String, base_url: &str) -> Result { + if client_id.trim().is_empty() || client_secret.trim().is_empty() { + return Err(TailscaleError::Invalid( + "Tailscale OAuth credentials are empty".into(), + )); + } + Ok(Self { + client_id, + client_secret, + api: ApiClient::new(base_url)?, + }) + } + + fn access_token(&self, scope: &str) -> Result { + let form = [ + ("grant_type", "client_credentials"), + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("scope", scope), + ("tags", PROVISIONER_TAG), + ] + .into_iter() + .map(|(key, value)| format!("{}={}", form_encode(key), form_encode(value))) + .collect::>() + .join("&"); + let response = self.api.request( + reqwest::Method::POST, + "/oauth/token", + None, + Some(("application/x-www-form-urlencoded", &form)), + )?; + response + .get("access_token") + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + TailscaleError::Invalid( + "Tailscale OAuth response did not contain an access token".into(), + ) + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DeviceRecord { + pub id: String, + pub hostname: String, + pub addresses: BTreeSet, +} + +/// Parse the local `tailscale status --json` response used by the guest +/// enrollment probe. A non-running backend has no usable addresses yet. +pub fn status_addresses(output: &[u8]) -> Result> { + let status: Value = serde_json::from_slice(output) + .map_err(|error| source("decoding Tailscale status", error))?; + if status.get("BackendState").and_then(Value::as_str) != Some("Running") { + return Ok(BTreeSet::new()); + } + let addresses = status + .get("Self") + .and_then(|value| value.get("TailscaleIPs")) + .and_then(Value::as_array) + .ok_or_else(|| TailscaleError::Invalid("Tailscale status has no self addresses".into()))? + .iter() + .filter_map(Value::as_str) + .filter(|address| address.parse::().is_ok()) + .map(ToOwned::to_owned) + .collect(); + Ok(addresses) +} + +/// Poll a guest status probe until it reports at least one address or times +/// out. Probe errors and empty status are transient during enrollment. +pub fn wait_for_addresses( + mut probe: impl FnMut() -> std::result::Result, E>, + timeout: Duration, + interval: Duration, +) -> Result> { + let started = Instant::now(); + loop { + if let Ok(addresses) = probe() { + if !addresses.is_empty() { + return Ok(addresses); + } + } + let elapsed = started.elapsed(); + if elapsed >= timeout { + break; + } + std::thread::sleep(interval.min(timeout.saturating_sub(elapsed))); + } + Err(TailscaleError::Invalid( + "Tailscale did not connect on the VM before the enrollment timeout".into(), + )) +} + +pub struct AuthKey { + pub key: String, +} + +pub fn create_auth_key( + credentials: &OAuthCredentials, + target_tag: &str, + description: &str, +) -> Result { + if !matches!(target_tag, SERVER_TAG | SMOKE_TAG) { + return Err(TailscaleError::Invalid( + "unsupported Tailscale device tag".into(), + )); + } + let token = credentials.access_token("auth_keys")?; + let payload = json!({ + "capabilities": {"devices": {"create": { + "reusable": false, + "ephemeral": false, + "preauthorized": true, + "tags": [target_tag] + }}}, + "expirySeconds": 3600, + "description": description + }); + let body = serde_json::to_string(&payload).context("encoding Tailscale auth key request")?; + let response = credentials.api.request( + reqwest::Method::POST, + "/tailnet/-/keys", + Some(&token), + Some(("application/json", &body)), + )?; + let key = response + .get("key") + .and_then(Value::as_str) + .filter(|key| !key.is_empty()) + .ok_or_else(|| { + TailscaleError::Invalid("Tailscale auth key response did not contain a key".into()) + })? + .to_owned(); + Ok(AuthKey { key }) +} + +pub fn find_device( + credentials: &OAuthCredentials, + expected_hostname: &str, + expected_tag: &str, + addresses: &BTreeSet, +) -> Result { + if !matches!(expected_tag, SERVER_TAG | SMOKE_TAG) || addresses.is_empty() { + return Err(TailscaleError::Invalid( + "invalid Tailscale device lookup constraints".into(), + )); + } + let token = credentials.access_token("devices:core")?; + let response = credentials.api.request( + reqwest::Method::GET, + "/tailnet/-/devices", + Some(&token), + None, + )?; + let devices = response + .get("devices") + .and_then(Value::as_array) + .ok_or_else(|| { + TailscaleError::Invalid("Tailscale device list response is malformed".into()) + })?; + let matches = devices + .iter() + .filter_map(|device| device_record(device, expected_tag)) + .filter(|device| { + device.hostname == expected_hostname + && device + .addresses + .iter() + .any(|address| addresses.contains(address)) + }) + .collect::>(); + match matches.as_slice() { + [device] => Ok(device.clone()), + [] => Err(TailscaleError::Invalid( + "Tailscale has not registered the expected device yet".into(), + )), + _ => Err(TailscaleError::Invalid( + "multiple Tailscale devices match this VM; refusing ambiguity".into(), + )), + } +} + +pub fn find_device_by_hostname( + credentials: &OAuthCredentials, + expected_hostname: &str, + expected_tag: &str, +) -> Result { + if !matches!(expected_tag, SERVER_TAG | SMOKE_TAG) || expected_hostname.is_empty() { + return Err(TailscaleError::Invalid( + "invalid Tailscale device lookup constraints".into(), + )); + } + let token = credentials.access_token("devices:core")?; + let response = credentials.api.request( + reqwest::Method::GET, + "/tailnet/-/devices", + Some(&token), + None, + )?; + let devices = response + .get("devices") + .and_then(Value::as_array) + .ok_or_else(|| { + TailscaleError::Invalid("Tailscale device list response is malformed".into()) + })?; + let matches = devices + .iter() + .filter_map(|device| device_record(device, expected_tag)) + .filter(|device| device.hostname == expected_hostname) + .collect::>(); + match matches.as_slice() { + [device] if !device.addresses.is_empty() => Ok(device.clone()), + [] => Err(TailscaleError::Invalid(format!( + "Tailscale has no device named {expected_hostname} with tag {expected_tag}" + ))), + [_] => Err(TailscaleError::Invalid("Tailscale device has no registered addresses".into())), + _ => Err(TailscaleError::Invalid(format!( + "multiple tagged Tailscale devices have hostname {expected_hostname}; refusing ambiguity" + ))), + } +} + +pub fn remove_device_for_hostname( + credentials: &OAuthCredentials, + hostname: &str, + expected_tag: &str, + expected: Option<&DeviceRecord>, +) -> Result> { + if !matches!(expected_tag, SERVER_TAG | SMOKE_TAG) || hostname.is_empty() { + return Err(TailscaleError::Invalid( + "invalid Tailscale device removal constraints".into(), + )); + } + let token = credentials.access_token("devices:core")?; + let response = credentials.api.request( + reqwest::Method::GET, + "/tailnet/-/devices", + Some(&token), + None, + )?; + let devices = response + .get("devices") + .and_then(Value::as_array) + .ok_or_else(|| { + TailscaleError::Invalid("Tailscale device list response is malformed".into()) + })?; + let matches = devices + .iter() + .filter_map(|device| device_record(device, expected_tag)) + .filter(|device| device.hostname == hostname) + .collect::>(); + let actual = match matches.as_slice() { + [] => return Ok(None), + [actual] => actual, + _ => { + return Err(TailscaleError::Invalid(format!( + "multiple tagged Tailscale devices have hostname {hostname}; refusing removal" + ))) + } + }; + if let Some(expected) = expected { + if actual.id != expected.id + || !actual + .addresses + .iter() + .any(|address| expected.addresses.contains(address)) + { + return Err(TailscaleError::Invalid( + "recorded Tailscale ID no longer matches this VM; refusing removal".into(), + )); + } + } + credentials.api.request( + reqwest::Method::DELETE, + &format!("/device/{}", path_segment(&actual.id)?), + Some(&token), + None, + )?; + Ok(Some(actual.clone())) +} + +fn device_record(value: &Value, expected_tag: &str) -> Option { + let tags = value.get("tags")?.as_array()?; + if !tags.iter().any(|tag| tag.as_str() == Some(expected_tag)) { + return None; + } + let id = value.get("id")?.as_str()?.to_owned(); + let hostname = value.get("hostname")?.as_str()?.to_owned(); + let addresses = value + .get("addresses")? + .as_array()? + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect(); + Some(DeviceRecord { + id, + hostname, + addresses, + }) +} + +fn form_encode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(char::from(byte)); + } else { + let _ = write!(encoded, "%{byte:02X}"); + } + } + encoded +} + +fn path_segment(value: &str) -> Result { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(TailscaleError::Invalid( + "invalid Tailscale device ID".into(), + )); + } + Ok(value.to_owned()) +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/tailscale/tests.rs b/ublue/skillet/crates/workstation/src/tailscale/tests.rs new file mode 100644 index 00000000..bd71d00a --- /dev/null +++ b/ublue/skillet/crates/workstation/src/tailscale/tests.rs @@ -0,0 +1,202 @@ +use super::{ + create_auth_key, device_record, find_device_by_hostname, form_encode, + remove_device_for_hostname, status_addresses, wait_for_addresses, OAuthCredentials, SMOKE_TAG, +}; + +#[test] +fn guest_status_addresses_require_running_backend_and_filter_invalid_values() { + assert_eq!( + status_addresses(br#"{"BackendState":"NeedsLogin"}"#).expect("waiting state"), + std::collections::BTreeSet::::new() + ); + assert_eq!( + status_addresses( + br#"{"BackendState":"Running","Self":{"TailscaleIPs":["100.64.0.10","fd7a:115c:a1e0::10","bad"]}}"# + ) + .expect("running status"), + std::collections::BTreeSet::from([ + "100.64.0.10".to_string(), + "fd7a:115c:a1e0::10".to_string() + ]) + ); +} + +#[test] +fn guest_status_addresses_reject_malformed_or_incomplete_running_responses() { + assert!(status_addresses(b"not json").is_err()); + assert!(status_addresses(br#"{"BackendState":"Running"}"#).is_err()); +} + +#[test] +fn guest_enrollment_wait_retries_transient_probe_errors_and_empty_status() { + let mut probes = 0; + let addresses = wait_for_addresses( + || { + probes += 1; + match probes { + 1 => Err("SSH is not ready"), + 2 => Ok(std::collections::BTreeSet::new()), + _ => Ok(std::collections::BTreeSet::from( + ["100.64.0.10".to_string()], + )), + } + }, + std::time::Duration::from_secs(1), + std::time::Duration::ZERO, + ) + .expect("eventually connected"); + assert_eq!(probes, 3); + assert_eq!(addresses, ["100.64.0.10".to_string()].into()); +} + +#[test] +fn guest_enrollment_wait_reports_timeout_after_a_final_probe() { + let probes = std::cell::Cell::new(0); + let error = wait_for_addresses( + || { + probes.set(probes.get() + 1); + Ok::<_, &str>(std::collections::BTreeSet::new()) + }, + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ) + .unwrap_err(); + assert_eq!(probes.get(), 1); + assert!(error.to_string().contains("enrollment timeout")); +} +use serde_json::json; +use std::{ + io::{Read, Write}, + net::TcpListener, + thread, +}; + +#[test] +fn form_encoding_escapes_oauth_credentials() { + assert_eq!(form_encode("a b+c/%"), "a%20b%2Bc%2F%25"); +} + +#[test] +fn device_record_requires_expected_tag_and_identity_fields() { + let value = json!({ + "id": "node-id", + "hostname": "clamps-test-smoke", + "addresses": ["100.64.0.5", "fd7a:115c:a1e0::5"], + "tags": [SMOKE_TAG] + }); + let record = device_record(&value, SMOKE_TAG).expect("valid tagged device"); + assert_eq!(record.id, "node-id"); + assert_eq!(record.hostname, "clamps-test-smoke"); + assert_eq!(record.addresses.len(), 2); + assert!(device_record(&value, "tag:skillet-server").is_none()); +} + +fn fixture(responses: Vec<(u16, &'static str)>) -> (String, thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); + let address = listener.local_addr().expect("fixture address"); + let handle = thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in responses { + let (mut stream, _) = listener.accept().expect("accept HTTP request"); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .expect("set read timeout"); + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let read = stream.read(&mut buffer).expect("read request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + let text = String::from_utf8_lossy(&request); + let Some((headers, payload)) = text.split_once("\r\n\r\n") else { + continue; + }; + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(0); + if payload.len() >= content_length { + break; + } + } + requests.push(String::from_utf8_lossy(&request).into_owned()); + let reason = if status == 200 { "OK" } else { "Forbidden" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + } + requests + }); + (format!("http://{address}/api/v2"), handle) +} + +#[test] +fn auth_key_requests_use_the_injected_http_endpoint() { + let (base, server) = fixture(vec![ + (200, r#"{"access_token":"oauth-access"}"#), + (200, r#"{"key":"tskey-test"}"#), + ]); + let credentials = + OAuthCredentials::with_api_base("client-id".into(), "client-secret".into(), &base) + .expect("test credentials"); + let issued = create_auth_key(&credentials, SMOKE_TAG, "local fixture").expect("auth key"); + assert_eq!(issued.key, "tskey-test"); + let requests = server.join().expect("fixture thread"); + assert!(requests[0].starts_with("POST /api/v2/oauth/token ")); + assert!(requests[0].contains("client_secret=client-secret")); + assert!(requests[1].starts_with("POST /api/v2/tailnet/-/keys ")); + assert!(requests[1] + .to_ascii_lowercase() + .contains("authorization: bearer oauth-access")); +} + +#[test] +fn device_lookup_and_removal_use_the_same_http_client() { + let (base, server) = fixture(vec![ + (200, r#"{"access_token":"oauth-access"}"#), + ( + 200, + r#"{"devices":[{"id":"device-1","hostname":"clamps-test-smoke","addresses":["100.64.0.5"],"tags":["tag:skillet-smoke"]}]}"#, + ), + (200, r#"{"access_token":"oauth-access"}"#), + ( + 200, + r#"{"devices":[{"id":"device-1","hostname":"clamps-test-smoke","addresses":["100.64.0.5"],"tags":["tag:skillet-smoke"]}]}"#, + ), + (200, "{}"), + ]); + let credentials = + OAuthCredentials::with_api_base("client-id".into(), "client-secret".into(), &base) + .expect("test credentials"); + let found = find_device_by_hostname(&credentials, "clamps-test-smoke", SMOKE_TAG) + .expect("device lookup"); + assert_eq!(found.id, "device-1"); + let removed = remove_device_for_hostname(&credentials, "clamps-test-smoke", SMOKE_TAG, None) + .expect("device removal"); + assert_eq!(removed.expect("removed record").id, "device-1"); + let requests = server.join().expect("fixture thread"); + assert!(requests[1].starts_with("GET /api/v2/tailnet/-/devices ")); + assert!(requests[4].starts_with("DELETE /api/v2/device/device-1 ")); +} + +#[test] +fn api_errors_keep_status_context() { + let (base, server) = fixture(vec![(403, r#"{"message":"denied"}"#)]); + let credentials = + OAuthCredentials::with_api_base("client-id".into(), "client-secret".into(), &base) + .expect("test credentials"); + let error = credentials.access_token("auth_keys").unwrap_err(); + assert!(error.to_string().contains("HTTP 403")); + assert!(error.to_string().contains("denied")); + server.join().expect("fixture thread"); +} diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs new file mode 100644 index 00000000..948f117c --- /dev/null +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment.rs @@ -0,0 +1,249 @@ +//! Recoverable Tailscale enrollment for disposable guest machines. + +use crate::{ + provisioning_policy::{DeviceClass, Environment, ProvisioningPolicy}, + provisioning_state::{self, ProvisioningIdentity}, + tailscale::{self, AuthKey, DeviceRecord, OAuthCredentials, TailscaleError}, +}; +use skillet_vm::{credential, transport::GuestTransport}; +use std::{collections::BTreeSet, path::Path, time::Duration}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum EnrollmentError { + #[error("invalid disposable Tailscale enrollment: {0}")] + Invalid(String), + #[error(transparent)] + Provider(#[from] TailscaleError), + #[error(transparent)] + State(#[from] provisioning_state::ProvisioningStateError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), +} + +/// Narrow provider capability needed by the enrollment workflow. +pub trait EnrollmentProvider { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result; + fn find_device( + &self, + expected_hostname: &str, + expected_tag: &str, + addresses: &BTreeSet, + ) -> tailscale::Result; +} + +/// Device removal is kept separate from enrollment so callers and tests only +/// provide the provider capability used by a particular lifecycle action. +pub trait DeviceCleanupProvider { + fn remove_device( + &self, + hostname: &str, + expected_tag: &str, + expected: Option<&DeviceRecord>, + ) -> tailscale::Result>; +} + +impl EnrollmentProvider for OAuthCredentials { + fn create_auth_key(&self, tag: &str, description: &str) -> tailscale::Result { + tailscale::create_auth_key(self, tag, description) + } + + fn find_device( + &self, + expected_hostname: &str, + expected_tag: &str, + addresses: &BTreeSet, + ) -> tailscale::Result { + tailscale::find_device(self, expected_hostname, expected_tag, addresses) + } +} + +impl DeviceCleanupProvider for OAuthCredentials { + fn remove_device( + &self, + hostname: &str, + expected_tag: &str, + expected: Option<&DeviceRecord>, + ) -> tailscale::Result> { + tailscale::remove_device_for_hostname(self, hostname, expected_tag, expected) + } +} + +/// Separate host, environment, instance, runtime hostname and transport inputs +/// so the caller's VM identity is never reconstructed from a composite name. +pub struct DisposableEnrollment<'a> { + pub host: &'a str, + pub instance: &'a str, + pub vm_hostname: &'a str, + pub run_directory: &'a Path, + pub policy: ProvisioningPolicy, +} + +pub fn enroll_disposable_vm( + request: &DisposableEnrollment<'_>, + provider: &impl EnrollmentProvider, + guest: &impl GuestTransport, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(EnrollmentError::Invalid( + "disposable enrollment requires the test environment policy".into(), + )); + } + let profile = skillet_hosts::profile_for_name(request.host).ok_or_else(|| { + EnrollmentError::Invalid(format!("unknown host profile {}", request.host)) + })?; + if !profile.supports_service("tailscale") { + return Err(EnrollmentError::Invalid(format!( + "host {} does not declare Tailscale", + request.host + ))); + } + if request.vm_hostname.is_empty() || request.instance.is_empty() { + return Err(EnrollmentError::Invalid( + "VM hostname and instance must not be empty".into(), + )); + } + + let identity = ProvisioningIdentity::new(request.host, request.policy.name(), request.instance); + provisioning_state::mark_tailscale_pending( + request.run_directory, + &identity, + request.vm_hostname, + )?; + + let mut addresses = guest_addresses(guest)?; + let tag = request.policy.tailscale_tag(DeviceClass::DisposableVm); + if addresses.is_empty() { + let auth_key = provider.create_auth_key( + tag, + &format!("Skillet disposable VM {}", request.vm_hostname), + )?; + credential::install( + guest, + request.host, + "tailscale_auth_key", + "skillet-full-apply.service", + credential::ActivationPolicy::StartConsumer, + skillet_hosts::tailscale::EnrollmentInput::new(auth_key.key, false) + .payload() + .map_err(|e| EnrollmentError::Invalid(e.to_string()))? + .as_bytes(), + )?; + addresses = tailscale::wait_for_addresses( + || guest_addresses(guest), + Duration::from_mins(2), + Duration::from_secs(2), + )?; + } + + let record = provider.find_device(request.vm_hostname, tag, &addresses)?; + provisioning_state::save_tailscale_record(request.run_directory, &identity, &record)?; + provisioning_state::remove_tailscale_pending(request.run_directory)?; + Ok(record) +} + +pub struct DisposableCleanup<'a> { + pub host: &'a str, + pub instance: &'a str, + pub vm_hostname: &'a str, + pub run_directory: &'a Path, + pub policy: ProvisioningPolicy, +} + +/// Remove the provider device verified by its ownership journal, then remove +/// local recovery records. If local cleanup fails after provider removal, a +/// retry safely observes the absent provider device and finishes journal cleanup. +pub fn cleanup_disposable_vm( + request: &DisposableCleanup<'_>, + provider: &impl DeviceCleanupProvider, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(EnrollmentError::Invalid( + "disposable Tailscale cleanup requires the test environment policy".into(), + )); + } + let profile = skillet_hosts::profile_for_name(request.host).ok_or_else(|| { + EnrollmentError::Invalid(format!("unknown host profile {}", request.host)) + })?; + if !profile.supports_service("tailscale") { + return Err(EnrollmentError::Invalid(format!( + "host {} does not declare Tailscale", + request.host + ))); + } + let identity = ProvisioningIdentity::new(request.host, request.policy.name(), request.instance); + let record_exists = provisioning_state::tailscale_record_exists(request.run_directory)?; + let pending_exists = provisioning_state::validate_tailscale_pending( + request.run_directory, + &identity, + request.vm_hostname, + )?; + if !record_exists && !pending_exists { + return Ok(false); + } + let expected = if record_exists { + Some(provisioning_state::load_tailscale_record( + &request.run_directory.join("tailscale.json"), + &identity, + request.vm_hostname, + )?) + } else { + None + }; + provider.remove_device( + request.vm_hostname, + request.policy.tailscale_tag(DeviceClass::DisposableVm), + expected.as_ref(), + )?; + provisioning_state::remove_tailscale_record(request.run_directory)?; + provisioning_state::remove_tailscale_pending(request.run_directory)?; + Ok(true) +} + +fn guest_addresses(guest: &impl GuestTransport) -> Result, EnrollmentError> { + let container = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "podman", "container", "exists", "tailscale"], + }, + None, + )?; + if container.status.code() == Some(1) { + // A newly created VM has not run full apply yet. Absence is a normal + // unenrolled state: credential installation starts full apply, which + // creates and enrolls the Tailscale container. + return Ok(BTreeSet::new()); + } + if !container.status.success() { + return Err(EnrollmentError::Invalid(format!( + "could not determine whether the Tailscale container exists (status {}); refusing to treat probe failure as an unenrolled VM", + container.status + ))); + } + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &[ + "-n", + "podman", + "exec", + "tailscale", + "tailscale", + "status", + "--json", + ], + }, + None, + )?; + if !output.status.success() { + return Err(EnrollmentError::Invalid(format!( + "Tailscale guest status command failed with status {}; refusing to treat probe failure as an unenrolled VM", + output.status + ))); + } + Ok(tailscale::status_addresses(&output.stdout)?) +} + +#[cfg(test)] +#[path = "tailscale_enrollment/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs new file mode 100644 index 00000000..dd9f236c --- /dev/null +++ b/ublue/skillet/crates/workstation/src/tailscale_enrollment/tests.rs @@ -0,0 +1,326 @@ +use super::*; +use std::{ + cell::Cell, + collections::VecDeque, + process::{Command, Output}, + sync::Mutex, +}; + +#[derive(Default)] +struct FakeProvider { + keys_created: Cell, + devices_found: Cell, +} + +#[derive(Default)] +struct FakeCleanupProvider { + removed: Cell, + fail: Cell, + expected_id: Mutex>, +} + +impl DeviceCleanupProvider for FakeCleanupProvider { + fn remove_device( + &self, + hostname: &str, + expected_tag: &str, + expected: Option<&DeviceRecord>, + ) -> tailscale::Result> { + self.removed.set(self.removed.get() + 1); + assert_eq!(expected_tag, tailscale::SMOKE_TAG); + *self.expected_id.lock().unwrap() = expected.map(|device| device.id.clone()); + if self.fail.get() { + return Err(tailscale::TailscaleError::Invalid( + "fixture removal failure".into(), + )); + } + Ok(expected.cloned().map(|mut device| { + device.hostname = hostname.to_string(); + device + })) + } +} + +impl EnrollmentProvider for FakeProvider { + fn create_auth_key(&self, _tag: &str, _description: &str) -> tailscale::Result { + self.keys_created.set(self.keys_created.get() + 1); + Ok(AuthKey { + key: "dummy-auth-key".to_string(), + }) + } + + fn find_device( + &self, + expected_hostname: &str, + _expected_tag: &str, + _addresses: &BTreeSet, + ) -> tailscale::Result { + self.devices_found.set(self.devices_found.get() + 1); + Ok(DeviceRecord { + id: "device-id".to_string(), + hostname: expected_hostname.to_string(), + addresses: BTreeSet::from(["100.64.0.10".to_string()]), + }) + } +} + +struct FakeGuest { + statuses: Mutex>, + installs: Mutex>, + container_exists: bool, +} + +type CapturedInstall = (Vec, Option>); + +impl FakeGuest { + fn new(statuses: impl IntoIterator) -> Self { + Self { + statuses: Mutex::new(statuses.into_iter().collect()), + installs: Mutex::new(Vec::new()), + container_exists: true, + } + } + + fn without_tailscale_container(statuses: impl IntoIterator) -> Self { + Self { + statuses: Mutex::new(statuses.into_iter().collect()), + installs: Mutex::new(Vec::new()), + container_exists: false, + } + } +} + +impl GuestTransport for FakeGuest { + fn execute( + &self, + command: &skillet_vm::transport::GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + if command + .arguments + .ends_with(&["container", "exists", "tailscale"]) + { + let created_after_credential_delivery = !self.installs.lock().unwrap().is_empty(); + return Ok(output( + self.container_exists || created_after_credential_delivery, + Vec::new(), + )); + } + if command.arguments.contains(&"credential") { + self.installs.lock().unwrap().push(( + command + .arguments + .iter() + .map(|argument| (*argument).to_string()) + .collect(), + input.map(<[u8]>::to_vec), + )); + return Ok(output(true, Vec::new())); + } + Ok(self + .statuses + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| status("Running", &["100.64.0.10"]))) + } + + fn upload(&self, _source: &std::path::Path, _destination: &str) -> skillet_vm::Result<()> { + Ok(()) + } +} + +fn output(success: bool, stdout: Vec) -> Output { + Output { + status: Command::new(if success { "true" } else { "false" }) + .status() + .expect("create local exit status"), + stdout, + stderr: Vec::new(), + } +} + +fn status(state: &str, addresses: &[&str]) -> Output { + output( + true, + serde_json::json!({ + "BackendState": state, + "Self": {"TailscaleIPs": addresses} + }) + .to_string() + .into_bytes(), + ) +} + +fn request(run_directory: &std::path::Path) -> DisposableEnrollment<'_> { + DisposableEnrollment { + host: "clamps", + instance: "smoke", + vm_hostname: "clamps-test-smoke", + run_directory, + policy: ProvisioningPolicy::new(Environment::Test), + } +} + +fn cleanup_request(run_directory: &std::path::Path) -> DisposableCleanup<'_> { + DisposableCleanup { + host: "clamps", + instance: "smoke", + vm_hostname: "clamps-test-smoke", + run_directory, + policy: ProvisioningPolicy::new(Environment::Test), + } +} + +fn recorded_device() -> DeviceRecord { + DeviceRecord { + id: "device-id".to_string(), + hostname: "clamps-test-smoke".to_string(), + addresses: BTreeSet::from(["100.64.0.10".to_string()]), + } +} + +#[test] +fn already_enrolled_vm_is_recorded_without_creating_an_auth_key() { + let run = tempfile::tempdir().unwrap(); + let guest = FakeGuest::new([status("Running", &["100.64.0.10"])]); + let provider = FakeProvider::default(); + let identity = ProvisioningIdentity::new("clamps", "test", "smoke"); + + let device = enroll_disposable_vm(&request(run.path()), &provider, &guest).unwrap(); + + assert_eq!(device.hostname, "clamps-test-smoke"); + assert_eq!(provider.keys_created.get(), 0); + assert_eq!(provider.devices_found.get(), 1); + assert!(guest.installs.lock().unwrap().is_empty()); + assert!(!run.path().join("tailscale-pending").exists()); + assert_eq!( + provisioning_state::load_tailscale_record( + &run.path().join("tailscale.json"), + &identity, + "clamps-test-smoke" + ) + .unwrap(), + device + ); +} + +#[test] +fn unenrolled_vm_receives_auth_key_before_device_lookup_and_state_is_saved() { + let run = tempfile::tempdir().unwrap(); + let guest = FakeGuest::new([ + status("NeedsLogin", &[]), + status("Running", &["100.64.0.10"]), + ]); + let provider = FakeProvider::default(); + + enroll_disposable_vm(&request(run.path()), &provider, &guest).unwrap(); + + assert_eq!(provider.keys_created.get(), 1); + assert_eq!(provider.devices_found.get(), 1); + let installs = guest.installs.lock().unwrap(); + assert_eq!(installs.len(), 1); + let input = skillet_hosts::tailscale::EnrollmentInput::parse( + std::str::from_utf8(installs[0].1.as_deref().unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(input.auth_key, "dummy-auth-key"); + assert!(!input.advertise_exit_node); + assert!(installs[0] + .0 + .iter() + .any(|argument| argument == "skillet-full-apply.service")); + assert!(!run.path().join("tailscale-pending").exists()); + assert!(run.path().join("tailscale.json").is_file()); +} + +#[test] +fn fresh_vm_without_tailscale_container_is_enrolled_after_credential_delivery() { + let run = tempfile::tempdir().unwrap(); + let guest = FakeGuest::without_tailscale_container([status("Running", &["100.64.0.10"])]); + let provider = FakeProvider::default(); + + enroll_disposable_vm(&request(run.path()), &provider, &guest).unwrap(); + + assert_eq!(provider.keys_created.get(), 1); + assert_eq!(provider.devices_found.get(), 1); + assert_eq!(guest.installs.lock().unwrap().len(), 1); +} + +#[test] +fn failed_guest_status_keeps_recovery_marker_and_does_not_issue_key() { + let run = tempfile::tempdir().unwrap(); + let guest = FakeGuest::new([output(false, Vec::new())]); + let provider = FakeProvider::default(); + + let error = enroll_disposable_vm(&request(run.path()), &provider, &guest).unwrap_err(); + + assert!(error + .to_string() + .contains("refusing to treat probe failure")); + assert_eq!(provider.keys_created.get(), 0); + assert!(run.path().join("tailscale-pending").is_file()); + assert!(!run.path().join("tailscale.json").exists()); +} + +#[test] +fn production_policy_is_rejected_before_guest_or_provider_effects() { + let run = tempfile::tempdir().unwrap(); + let mut request = request(run.path()); + request.policy = ProvisioningPolicy::new(Environment::Production); + let guest = FakeGuest::new([]); + let provider = FakeProvider::default(); + + assert!(enroll_disposable_vm(&request, &provider, &guest).is_err()); + assert_eq!(provider.keys_created.get(), 0); + assert!(guest.installs.lock().unwrap().is_empty()); + assert!(!run.path().join("tailscale-pending").exists()); +} + +#[test] +fn disposable_cleanup_removes_verified_device_then_local_recovery_state() { + let run = tempfile::tempdir().unwrap(); + let identity = ProvisioningIdentity::new("clamps", "test", "smoke"); + provisioning_state::save_tailscale_record(run.path(), &identity, &recorded_device()).unwrap(); + provisioning_state::mark_tailscale_pending(run.path(), &identity, "clamps-test-smoke").unwrap(); + let provider = FakeCleanupProvider::default(); + + assert!(cleanup_disposable_vm(&cleanup_request(run.path()), &provider).unwrap()); + + assert_eq!(provider.removed.get(), 1); + assert_eq!( + provider.expected_id.lock().unwrap().as_deref(), + Some("device-id") + ); + assert!(!run.path().join("tailscale.json").exists()); + assert!(!run.path().join("tailscale-pending").exists()); + assert!(!cleanup_disposable_vm(&cleanup_request(run.path()), &provider).unwrap()); +} + +#[test] +fn provider_failure_preserves_tailscale_cleanup_journal_for_retry() { + let run = tempfile::tempdir().unwrap(); + let identity = ProvisioningIdentity::new("clamps", "test", "smoke"); + provisioning_state::save_tailscale_record(run.path(), &identity, &recorded_device()).unwrap(); + let provider = FakeCleanupProvider::default(); + provider.fail.set(true); + + assert!(cleanup_disposable_vm(&cleanup_request(run.path()), &provider).is_err()); + assert!(run.path().join("tailscale.json").is_file()); + + provider.fail.set(false); + assert!(cleanup_disposable_vm(&cleanup_request(run.path()), &provider).unwrap()); + assert!(!run.path().join("tailscale.json").exists()); +} + +#[test] +fn tailscale_cleanup_identity_mismatch_stops_before_provider_mutation() { + let run = tempfile::tempdir().unwrap(); + let wrong = ProvisioningIdentity::new("other-host", "test", "smoke"); + provisioning_state::save_tailscale_record(run.path(), &wrong, &recorded_device()).unwrap(); + let provider = FakeCleanupProvider::default(); + + assert!(cleanup_disposable_vm(&cleanup_request(run.path()), &provider).is_err()); + assert_eq!(provider.removed.get(), 0); + assert!(run.path().join("tailscale.json").is_file()); +} diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning.rs b/ublue/skillet/crates/workstation/src/ui_provisioning.rs new file mode 100644 index 00000000..851e0e96 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/ui_provisioning.rs @@ -0,0 +1,703 @@ +//! Shared host/environment UI hostname, Caddy, and DNS planning. + +use crate::{ + cloudflare::{self, CloudflareError, DesiredRecord, IssuedToken, OwnedDns, Zone}, + provisioning_policy::{Environment, ProvisioningPolicy}, + provisioning_state::{self, CloudflareVmOwnership, ProvisioningIdentity}, + tailscale::{self, OAuthCredentials, TailscaleError}, + vault::{SecretStore, VaultError}, +}; +use skillet_caddy::{CaddyError, CaddySites, UiEnvironment}; +use skillet_vm::transport::GuestTransport; +use std::{collections::BTreeSet, path::Path, time::Duration}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum UiProvisioningError { + #[error("host profile {0} declares no private UI services")] + NoUiServices(String), + #[error(transparent)] + Caddy(#[from] CaddyError), + #[error(transparent)] + Cloudflare(#[from] CloudflareError), + #[error(transparent)] + State(#[from] provisioning_state::ProvisioningStateError), + #[error(transparent)] + Vault(#[from] VaultError), + #[error("Cloudflare token persistence failed: {0}")] + Token(#[from] crate::zone_token::TokenError), + #[error(transparent)] + Tailscale(#[from] TailscaleError), + #[error(transparent)] + Guest(#[from] skillet_vm::Error), + #[error("invalid UI provisioning request: {0}")] + Invalid(String), +} + +/// Desired public DNS and Caddy configuration for one declared host profile. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiProvisioningPlan { + pub sites: CaddySites, + pub dns_records: Vec, +} + +/// The narrow Cloudflare operations used by disposable UI provisioning. +pub trait UiCloudflareProvider { + fn zone(&self, token: &str, zone_id: &str) -> Result; + fn create_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result; + fn replace_named_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result; + fn reconcile_dns( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + desired: &[DesiredRecord], + ) -> Result; + fn remove_dns_marker( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + ) -> Result<(), CloudflareError>; + fn token_ids_by_name( + &self, + creator_token: &str, + account_id: &str, + name: &str, + ) -> Result, CloudflareError>; + fn revoke_token( + &self, + creator_token: &str, + account_id: &str, + token_id: &str, + ) -> Result<(), CloudflareError>; +} + +impl UiCloudflareProvider for cloudflare::Cloudflare { + fn zone(&self, token: &str, zone_id: &str) -> Result { + cloudflare::Cloudflare::zone(self, token, zone_id) + } + + fn create_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + cloudflare::Cloudflare::create_zone_token( + self, + creator_token, + zone_id, + account_id, + name, + lifetime, + ) + } + + fn replace_named_zone_token( + &self, + creator_token: &str, + zone_id: &str, + account_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + cloudflare::Cloudflare::replace_named_zone_token( + self, + creator_token, + zone_id, + account_id, + name, + lifetime, + ) + } + + fn reconcile_dns( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + desired: &[DesiredRecord], + ) -> Result { + cloudflare::Cloudflare::reconcile_dns(self, token, zone_id, marker, ui_domain, desired) + } + + fn remove_dns_marker( + &self, + token: &str, + zone_id: &str, + marker: &str, + ui_domain: &str, + ) -> Result<(), CloudflareError> { + cloudflare::Cloudflare::remove_dns_marker(self, token, zone_id, marker, ui_domain) + } + + fn token_ids_by_name( + &self, + creator_token: &str, + account_id: &str, + name: &str, + ) -> Result, CloudflareError> { + cloudflare::Cloudflare::token_ids_by_name(self, creator_token, account_id, name) + } + + fn revoke_token( + &self, + creator_token: &str, + account_id: &str, + token_id: &str, + ) -> Result<(), CloudflareError> { + cloudflare::Cloudflare::revoke_token(self, creator_token, account_id, token_id) + } +} + +pub trait UiTailscaleProvider { + fn find_device_by_hostname( + &self, + hostname: &str, + tag: &str, + ) -> Result; +} + +impl UiTailscaleProvider for OAuthCredentials { + fn find_device_by_hostname( + &self, + hostname: &str, + tag: &str, + ) -> Result { + tailscale::find_device_by_hostname(self, hostname, tag) + } +} + +pub struct DisposableUiRequest<'a> { + pub host: &'a str, + pub instance: &'a str, + pub policy: ProvisioningPolicy, + pub zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub creator_token: &'a str, + pub run_directory: &'a Path, + pub addresses: &'a BTreeSet, + pub tailnet_users: &'a [String], +} + +pub struct DisposableUiCredentials { + pub sites: CaddySites, + pub token: IssuedToken, + pub account_id: String, + pub token_name: String, +} + +/// Install the complete disposable Caddy input, activate it, verify the +/// non-tailnet rejection response, and then remove superseded named tokens. +pub fn deliver_disposable_ui( + host: &str, + credentials: &DisposableUiCredentials, + creator_token: &str, + provider: &impl UiCloudflareProvider, + guest: &impl GuestTransport, + acceptance_timeout: Duration, + acceptance_interval: Duration, +) -> Result<(), UiProvisioningError> { + if credentials.sites.host != host { + return Err(UiProvisioningError::Invalid( + "Caddy payload host does not match the requested guest".into(), + )); + } + let sites_payload = serde_json::to_vec(&credentials.sites) + .map_err(|error| UiProvisioningError::Invalid(format!("encoding Caddy sites: {error}")))?; + skillet_vm::credential::install_set( + guest, + host, + "skillet-caddy-apply.service", + skillet_vm::credential::ActivationPolicy::DeferConsumer, + &[ + ("caddy_sites", &sites_payload), + ("cloudflare_acme_token", credentials.token.value.as_bytes()), + ], + )?; + run_guest( + guest, + host, + &["-n", "systemctl", "start", "skillet-caddy-apply.service"], + "activating Caddy after installing its credentials", + )?; + verify_non_tailnet_denial( + guest, + &credentials.sites, + acceptance_timeout, + acceptance_interval, + )?; + for old_id in provider.token_ids_by_name( + creator_token, + &credentials.account_id, + &credentials.token_name, + )? { + if old_id != credentials.token.id { + provider.revoke_token(creator_token, &credentials.account_id, &old_id)?; + } + } + Ok(()) +} + +fn run_guest( + guest: &impl GuestTransport, + host: &str, + arguments: &[&str], + description: &str, +) -> Result<(), UiProvisioningError> { + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments, + }, + None, + )?; + if !output.status.success() { + return Err(UiProvisioningError::Invalid(format!( + "{description} failed for {host} with status {}", + output.status + ))); + } + Ok(()) +} + +fn verify_non_tailnet_denial( + guest: &impl GuestTransport, + sites: &CaddySites, + timeout: Duration, + interval: Duration, +) -> Result<(), UiProvisioningError> { + let caddy_address = caddy_container_address(guest, &sites.host)?; + let mut verified_names = 0; + for site in &sites.services { + for hostname in std::iter::once(&site.hostname).chain(&site.aliases) { + let started = std::time::Instant::now(); + let resolve = format!("{hostname}:443:{caddy_address}"); + let url = format!("https://{hostname}/"); + loop { + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/curl", + arguments: &[ + "--insecure", + "--silent", + "--show-error", + "--max-time", + "8", + "--resolve", + &resolve, + "--write-out", + "\\n%{http_code}", + &url, + ], + }, + None, + )?; + if output.status.success() + && output.stdout == b"Access denied by Skillet tailnet policy\n403" + { + verified_names += 1; + break; + } + if started.elapsed() >= timeout { + return Err(UiProvisioningError::Invalid(format!( + "Caddy did not return its explicit tailnet-denial response for service {} (curl exit {:?}); inspect caddy.service for TLS/ACME diagnostics", + site.name, output.status.code() + ))); + } + std::thread::sleep(interval); + } + } + } + if verified_names == 0 { + return Err(UiProvisioningError::Invalid( + "Caddy payload declares no hostnames to verify".into(), + )); + } + Ok(()) +} + +fn caddy_container_address( + guest: &impl GuestTransport, + network: &str, +) -> Result { + let format = format!("{{{{(index .NetworkSettings.Networks \"{network}\").IPAddress}}}}"); + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "podman", "inspect", "--format", &format, "caddy"], + }, + None, + )?; + if !output.status.success() { + return Err(UiProvisioningError::Invalid( + "could not inspect the Caddy container address for the local denial probe".into(), + )); + } + let address = String::from_utf8(output.stdout).map_err(|error| { + UiProvisioningError::Invalid(format!("Caddy address is not UTF-8: {error}")) + })?; + address.trim().parse().map_err(|error| { + UiProvisioningError::Invalid(format!( + "Caddy returned an invalid container IPv4 address: {error}" + )) + }) +} + +pub struct PersistentUiDelivery<'a> { + pub host: &'a str, + pub policy: ProvisioningPolicy, + pub zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub creator_token: &'a str, + pub tailnet_users: &'a [String], +} + +/// Resolve production or named-host UI configuration, reconcile its DNS, and +/// deliver both Caddy credentials before activating the consumer. +pub fn deliver_persistent_ui( + request: &PersistentUiDelivery<'_>, + token_store: &mut impl SecretStore, + cloudflare: &impl UiCloudflareProvider, + tailnet: &impl UiTailscaleProvider, + guest: &impl GuestTransport, +) -> Result { + let profile = skillet_hosts::profile_for_name(request.host) + .ok_or_else(|| UiProvisioningError::NoUiServices(request.host.to_string()))?; + if profile.ui_services().is_empty() { + return Err(UiProvisioningError::NoUiServices(request.host.to_string())); + } + cloudflare::validate_zone_id(request.zone_id)?; + let zone = cloudflare.zone(request.creator_token, request.zone_id)?; + let tag = request + .policy + .tailscale_tag(crate::provisioning_policy::DeviceClass::ProductionHost); + let device = tailnet.find_device_by_hostname(request.host, tag)?; + let mut plan = build_ui_provisioning_plan( + request.host, + request.policy, + &zone.name, + request.relative_ui_domain, + &device.addresses, + )?; + plan.sites.tailnet_users = request.tailnet_users.to_vec(); + skillet_caddy::auth::Policy::new(plan.sites.tailnet_users.clone()).map_err(CaddyError::from)?; + if plan.sites.needs_identity_gate() && plan.sites.tailnet_users.is_empty() { + return Err(UiProvisioningError::Invalid( + "tailnet UI allowlist is required; create skillet/tailscale/ui-users in the vault" + .into(), + )); + } + let account_id = crate::cloudflare::Cloudflare::account_id(&zone)?.to_string(); + let token_path = format!( + "skillet/environments/{}/hosts/{}/cloudflare/acme-token", + request.policy.vault_name(), + request.host + ); + let token_name = format!("skillet:{}:{}", request.policy.name(), request.host); + let token = if let Some(token) = token_store.get(&token_path)? { + token + } else { + let legacy_path = format!("skillet/hosts/{}/cloudflare/acme-token", request.host); + let legacy = if request.policy.environment() == Environment::Production { + token_store.get(&legacy_path)? + } else { + None + }; + if let Some(token) = legacy { + token_store.ensure_unchanged()?; + token_store.save_verified(&token_path, &token)?; + token + } else { + crate::zone_token::use_or_create( + token_store, + &token_path, + || { + cloudflare.replace_named_zone_token( + request.creator_token, + request.zone_id, + &account_id, + &token_name, + request.policy.cloudflare_token_lifetime(), + ) + }, + |id| cloudflare.revoke_token(request.creator_token, &account_id, id), + )? + } + }; + cloudflare.zone(&token, request.zone_id)?; + token_store.ensure_unchanged()?; + let marker = format!("skillet:{}:{}", request.policy.name(), request.host); + cloudflare.reconcile_dns( + &token, + request.zone_id, + &marker, + &plan.sites.ui_domain, + &plan.dns_records, + )?; + token_store.ensure_unchanged()?; + let sites_payload = serde_json::to_vec(&plan.sites) + .map_err(|error| UiProvisioningError::Invalid(format!("encoding Caddy sites: {error}")))?; + skillet_vm::credential::install_set( + guest, + request.host, + "skillet-caddy-apply.service", + skillet_vm::credential::ActivationPolicy::DeferConsumer, + &[ + ("caddy_sites", &sites_payload), + ("cloudflare_acme_token", token.as_bytes()), + ], + )?; + let output = guest.execute( + &skillet_vm::transport::GuestCommand { + program: "/usr/bin/sudo", + arguments: &["-n", "systemctl", "start", "skillet-caddy-apply.service"], + }, + None, + )?; + if !output.status.success() { + return Err(UiProvisioningError::Invalid(format!( + "Caddy apply failed with status {}", + output.status + ))); + } + Ok(plan.sites) +} + +pub struct DisposableUiCleanupRequest<'a> { + pub host: &'a str, + pub instance: &'a str, + pub policy: ProvisioningPolicy, + pub ownership_path: std::path::PathBuf, + pub configured_zone_id: &'a str, + pub relative_ui_domain: Option<&'a str>, + pub creator_token: &'a str, +} + +/// Remove only DNS and tokens owned by the typed disposable VM journal. The +/// journal remains until provider cleanup and temporary-token revocation pass. +pub fn cleanup_disposable_ui( + request: &DisposableUiCleanupRequest<'_>, + provider: &impl UiCloudflareProvider, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(UiProvisioningError::Invalid( + "disposable UI cleanup requires the test environment policy".into(), + )); + } + if !provisioning_state::cloudflare_ownership_exists(&request.ownership_path)? { + return Ok(false); + } + let ownership = provisioning_state::load_cloudflare_ownership(&request.ownership_path)?; + let expected_identity = + ProvisioningIdentity::new(request.host, request.policy.name(), request.instance); + provisioning_state::validate_cloudflare_identity(&ownership, &expected_identity)?; + let expected_marker = format!( + "skillet:{}:{}:{}", + request.policy.name(), + request.host, + request.instance + ); + let expected_token_name = format!( + "skillet:{}:{}-{}", + request.policy.name(), + request.host, + request.instance + ); + if ownership.environment != request.policy.name() + || ownership.marker != expected_marker + || ownership.token_name != expected_token_name + { + return Err(UiProvisioningError::Invalid( + "Cloudflare metadata does not match this VM identity; refusing cleanup".into(), + )); + } + cloudflare::validate_zone_id(&ownership.zone_id)?; + if request.configured_zone_id.trim() != ownership.zone_id { + return Err(UiProvisioningError::Invalid( + "test Cloudflare zone differs from the recorded VM owner; restore the original vault values before cleanup".into(), + )); + } + let zone = provider.zone(request.creator_token, &ownership.zone_id)?; + let configured_domain = + skillet_caddy::resolve_ui_domain(&zone.name, request.relative_ui_domain)?; + if configured_domain != ownership.ui_domain { + return Err(UiProvisioningError::Invalid( + "test UI namespace differs from the recorded VM owner; restore the original relative prefix before cleanup".into(), + )); + } + skillet_caddy::validate_domain_in_zone(&ownership.ui_domain, &zone.name)?; + let account_id = cloudflare::Cloudflare::account_id(&zone)?.to_string(); + let cleanup_name = format!("{}:cleanup", ownership.token_name); + let cleanup_token = provider.create_zone_token( + request.creator_token, + &ownership.zone_id, + &account_id, + &cleanup_name, + Some(request.policy.cleanup_token_lifetime()), + )?; + let cleanup = (|| { + provider.zone(&cleanup_token.value, &ownership.zone_id)?; + provider.remove_dns_marker( + &cleanup_token.value, + &ownership.zone_id, + &ownership.marker, + &ownership.ui_domain, + )?; + for id in + provider.token_ids_by_name(request.creator_token, &account_id, &ownership.token_name)? + { + provider.revoke_token(request.creator_token, &account_id, &id)?; + } + Ok::<(), CloudflareError>(()) + })(); + let revoke_cleanup = provider + .token_ids_by_name(request.creator_token, &account_id, &cleanup_name) + .and_then(|ids| { + for id in ids { + provider.revoke_token(request.creator_token, &account_id, &id)?; + } + Ok(()) + }); + cleanup?; + revoke_cleanup?; + provisioning_state::remove_cloudflare_ownership(&request.ownership_path)?; + Ok(true) +} + +/// Persist each ownership transition around disposable token and DNS +/// mutations. The vault guard runs immediately before token issuance. +pub fn provision_disposable_ui( + request: &DisposableUiRequest<'_>, + provider: &impl UiCloudflareProvider, + ensure_vault_unchanged: impl FnOnce() -> Result<(), VaultError>, +) -> Result { + if request.policy.environment() != Environment::Test { + return Err(UiProvisioningError::Invalid( + "disposable UI provisioning requires the test environment policy".into(), + )); + } + cloudflare::validate_zone_id(request.zone_id)?; + let zone = provider.zone(request.creator_token, request.zone_id)?; + let mut plan = build_ui_provisioning_plan( + request.host, + request.policy, + &zone.name, + request.relative_ui_domain, + request.addresses, + )?; + plan.sites.tailnet_users = request.tailnet_users.to_vec(); + skillet_caddy::auth::Policy::new(plan.sites.tailnet_users.clone()).map_err(CaddyError::from)?; + if plan.sites.needs_identity_gate() && plan.sites.tailnet_users.is_empty() { + return Err(UiProvisioningError::Invalid( + "tailnet UI allowlist is required; create skillet/tailscale/ui-users in the vault" + .into(), + )); + } + let account_id = cloudflare::Cloudflare::account_id(&zone)?.to_string(); + let marker = format!( + "skillet:{}:{}:{}", + request.policy.name(), + request.host, + request.instance + ); + let token_name = format!( + "skillet:{}:{}-{}", + request.policy.name(), + request.host, + request.instance + ); + let metadata_path = request.run_directory.join("cloudflare.json"); + let mut ownership = CloudflareVmOwnership { + identity: Some(ProvisioningIdentity::new( + request.host, + request.policy.name(), + request.instance, + )), + environment: request.policy.name().to_string(), + marker, + zone_id: request.zone_id.to_string(), + ui_domain: plan.sites.ui_domain.clone(), + token_name: token_name.clone(), + token_id: None, + expires_on: None, + record_ids: Vec::new(), + }; + provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; + ensure_vault_unchanged()?; + let token = provider.create_zone_token( + request.creator_token, + request.zone_id, + &account_id, + &token_name, + request.policy.cloudflare_token_lifetime(), + )?; + ownership.token_id = Some(token.id.clone()); + ownership.expires_on.clone_from(&token.expires_on); + provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; + let owned = provider.reconcile_dns( + &token.value, + request.zone_id, + &ownership.marker, + &ownership.ui_domain, + &plan.dns_records, + )?; + ownership.record_ids = owned.records.into_iter().map(|record| record.id).collect(); + provisioning_state::save_cloudflare_ownership(&metadata_path, &ownership)?; + Ok(DisposableUiCredentials { + sites: plan.sites, + token, + account_id, + token_name, + }) +} + +pub fn build_ui_provisioning_plan( + host: &str, + policy: ProvisioningPolicy, + zone_domain: &str, + relative_ui_domain: Option<&str>, + addresses: &BTreeSet, +) -> Result { + let profile = skillet_hosts::profile_for_name(host) + .ok_or_else(|| UiProvisioningError::NoUiServices(host.to_string()))?; + let services = profile.ui_services(); + if services.is_empty() { + return Err(UiProvisioningError::NoUiServices(host.to_string())); + } + let ui_domain = skillet_caddy::resolve_ui_domain(zone_domain, relative_ui_domain)?; + let sites = CaddySites::from_host( + host, + &UiEnvironment { + ui_domain, + acme_staging: policy.acme_staging(), + }, + &services, + )?; + let dns_records = cloudflare::desired_records(&sites.machine_hostname, addresses, &sites)?; + Ok(UiProvisioningPlan { sites, dns_records }) +} + +#[cfg(test)] +#[path = "ui_provisioning/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs new file mode 100644 index 00000000..85c3a225 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/ui_provisioning/tests.rs @@ -0,0 +1,780 @@ +use super::*; +use crate::provisioning_policy::{Environment, ProvisioningPolicy}; +use crate::{ + cloudflare::{ + CloudflareError, DesiredRecord, IssuedToken, OwnedDns, RecordRef, Zone, ZoneAccount, + }, + provisioning_state::CloudflareVmOwnership, +}; +use std::os::unix::process::ExitStatusExt; +use std::{ + cell::{Cell, RefCell}, + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, + process::{ExitStatus, Output}, + sync::Mutex, +}; + +#[derive(Default)] +struct FakeTokenStore { + entries: RefCell>, + saves: Cell, + unchanged_checks: Cell, + changed: Cell, + fail_save: Cell, +} + +impl crate::vault::SecretStore for FakeTokenStore { + fn get(&self, path: &str) -> Result, VaultError> { + Ok(self.entries.borrow().get(path).cloned()) + } + + fn ensure_unchanged(&self) -> Result<(), VaultError> { + self.unchanged_checks.set(self.unchanged_checks.get() + 1); + if self.changed.get() { + Err(VaultError::Invalid("fixture vault conflict".into())) + } else { + Ok(()) + } + } + + fn save_verified(&mut self, path: &str, token: &str) -> Result<(), VaultError> { + self.saves.set(self.saves.get() + 1); + if self.fail_save.get() { + return Err(VaultError::Invalid("fixture save failure".into())); + } + self.entries + .borrow_mut() + .insert(path.to_string(), token.to_string()); + Ok(()) + } +} + +struct FakeTailnet; + +impl UiTailscaleProvider for FakeTailnet { + fn find_device_by_hostname( + &self, + hostname: &str, + _tag: &str, + ) -> Result { + Ok(crate::tailscale::DeviceRecord { + id: "device-id".into(), + hostname: hostname.to_string(), + addresses: addresses(), + }) + } +} + +#[derive(Default)] +struct FakeGuest { + calls: Mutex>, + denies_curl: Cell, +} + +type CapturedGuestCall = (Vec, Option>); + +impl GuestTransport for FakeGuest { + fn execute( + &self, + command: &skillet_vm::transport::GuestCommand<'_>, + input: Option<&[u8]>, + ) -> skillet_vm::Result { + self.calls.lock().unwrap().push(( + std::iter::once(command.program.to_string()) + .chain( + command + .arguments + .iter() + .map(std::string::ToString::to_string), + ) + .collect(), + input.map(<[u8]>::to_vec), + )); + let stdout = if command.program == "/usr/bin/sudo" && command.arguments.contains(&"inspect") + { + b"172.26.26.5\n".to_vec() + } else if command.program == "/usr/bin/curl" && self.denies_curl.get() { + b"Access denied by Skillet tailnet policy\n403".to_vec() + } else if command.program == "/usr/bin/curl" { + b"unexpected response".to_vec() + } else { + Vec::new() + }; + Ok(Output { + status: ExitStatus::from_raw(0), + stdout, + stderr: Vec::new(), + }) + } + + fn upload(&self, _source: &Path, _destination: &str) -> skillet_vm::Result<()> { + Ok(()) + } +} + +fn addresses() -> BTreeSet { + BTreeSet::from(["100.64.0.10".to_string(), "fd7a:115c:a1e0::10".to_string()]) +} + +struct FakeCloudflare { + run_directory: PathBuf, + fail_dns: bool, + issued: std::cell::Cell, + removed: std::cell::Cell, + revoked: std::cell::RefCell>, + reconciled: std::cell::Cell, +} + +impl UiCloudflareProvider for FakeCloudflare { + fn zone(&self, _token: &str, zone_id: &str) -> Result { + Ok(Zone { + id: zone_id.to_string(), + name: "example.invalid".to_string(), + account: Some(ZoneAccount { + id: "11111111111111111111111111111111".to_string(), + }), + }) + } + + fn create_zone_token( + &self, + _creator_token: &str, + _zone_id: &str, + _account_id: &str, + name: &str, + lifetime: Option, + ) -> Result { + let ownership = read_ownership(&self.run_directory); + assert!( + name == ownership.token_name || name == format!("{}:cleanup", ownership.token_name) + ); + if name == ownership.token_name { + assert!(ownership.token_id.is_none()); + assert_eq!(lifetime, Some(std::time::Duration::from_hours(12))); + } else { + assert_eq!(lifetime, Some(std::time::Duration::from_mins(15))); + } + self.issued.set(self.issued.get() + 1); + Ok(IssuedToken { + id: "22222222222222222222222222222222".to_string(), + value: "dummy token value".to_string(), + expires_on: Some("2030-01-01T00:00:00Z".to_string()), + }) + } + + fn replace_named_zone_token( + &self, + _creator_token: &str, + _zone_id: &str, + _account_id: &str, + _name: &str, + _lifetime: Option, + ) -> Result { + self.issued.set(self.issued.get() + 1); + Ok(IssuedToken { + id: "33333333333333333333333333333333".to_string(), + value: "persistent dummy token".to_string(), + expires_on: None, + }) + } + + fn reconcile_dns( + &self, + _token: &str, + _zone_id: &str, + marker: &str, + _ui_domain: &str, + desired: &[DesiredRecord], + ) -> Result { + if provisioning_state::cloudflare_ownership_exists( + &self.run_directory.join("cloudflare.json"), + ) + .unwrap() + { + let ownership = read_ownership(&self.run_directory); + assert!(ownership.token_id.is_some()); + assert_eq!(ownership.marker, marker); + } + self.reconciled.set(self.reconciled.get() + 1); + if self.fail_dns { + return Err(CloudflareError::Invalid("fixture DNS failure".into())); + } + Ok(OwnedDns { + marker: marker.to_string(), + records: desired + .iter() + .enumerate() + .map(|(index, desired)| RecordRef { + id: format!("{index:032x}"), + name: desired.name.clone(), + record_type: desired.record_type.clone(), + content: desired.content.clone(), + comment: Some(marker.to_string()), + }) + .collect(), + }) + } + + fn remove_dns_marker( + &self, + _token: &str, + _zone_id: &str, + _marker: &str, + _ui_domain: &str, + ) -> Result<(), CloudflareError> { + if self.fail_dns { + return Err(CloudflareError::Invalid("fixture DNS failure".into())); + } + self.removed.set(self.removed.get() + 1); + Ok(()) + } + + fn token_ids_by_name( + &self, + _creator_token: &str, + _account_id: &str, + name: &str, + ) -> Result, CloudflareError> { + Ok(vec![format!("id-for:{name}")]) + } + + fn revoke_token( + &self, + _creator_token: &str, + _account_id: &str, + token_id: &str, + ) -> Result<(), CloudflareError> { + self.revoked.borrow_mut().push(token_id.to_string()); + Ok(()) + } +} + +fn fake_cloudflare(run_directory: &Path, fail_dns: bool) -> FakeCloudflare { + FakeCloudflare { + run_directory: run_directory.to_path_buf(), + fail_dns, + issued: std::cell::Cell::new(0), + removed: std::cell::Cell::new(0), + revoked: std::cell::RefCell::new(Vec::new()), + reconciled: std::cell::Cell::new(0), + } +} + +fn read_ownership(run_directory: &Path) -> CloudflareVmOwnership { + serde_json::from_slice(&std::fs::read(run_directory.join("cloudflare.json")).unwrap()).unwrap() +} + +fn disposable_request<'a>( + run_directory: &'a Path, + addresses: &'a BTreeSet, +) -> DisposableUiRequest<'a> { + DisposableUiRequest { + host: "clamps", + instance: "smoke", + policy: ProvisioningPolicy::new(Environment::Test), + zone_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + relative_ui_domain: Some("smoke.ui"), + tailnet_users: test_users(), + creator_token: "dummy creator token", + run_directory, + addresses, + } +} + +fn cleanup_request(run_directory: &Path) -> DisposableUiCleanupRequest<'_> { + DisposableUiCleanupRequest { + host: "clamps", + instance: "smoke", + policy: ProvisioningPolicy::new(Environment::Test), + ownership_path: run_directory.join("cloudflare.json"), + configured_zone_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + relative_ui_domain: Some("smoke.ui"), + creator_token: "dummy creator token", + } +} + +fn persistent_request() -> PersistentUiDelivery<'static> { + PersistentUiDelivery { + host: "clamps", + policy: ProvisioningPolicy::new(Environment::Production), + zone_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + relative_ui_domain: Some("ui"), + tailnet_users: test_users(), + creator_token: "dummy creator token", + } +} + +fn disposable_credentials() -> DisposableUiCredentials { + let addresses = addresses(); + let plan = build_ui_provisioning_plan( + "clamps", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + Some("smoke.ui"), + &addresses, + ) + .unwrap(); + DisposableUiCredentials { + sites: plan.sites, + token: IssuedToken { + id: "44444444444444444444444444444444".to_string(), + value: "disposable ACME token".to_string(), + expires_on: Some("2030-01-01T00:00:00Z".to_string()), + }, + account_id: "11111111111111111111111111111111".to_string(), + token_name: "skillet:test:clamps-smoke".to_string(), + } +} + +fn create_cleanup_fixture(run_directory: &Path, provider: &FakeCloudflare) { + let addresses = addresses(); + provision_disposable_ui( + &disposable_request(run_directory, &addresses), + provider, + || Ok(()), + ) + .unwrap(); +} + +#[test] +fn production_and_test_share_host_dns_plan_but_select_different_acme_policy() { + let production = build_ui_provisioning_plan( + "joey", + ProvisioningPolicy::new(Environment::Production), + "example.invalid", + Some("ui"), + &addresses(), + ) + .unwrap(); + let test = build_ui_provisioning_plan( + "joey", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + Some("smoke.ui"), + &addresses(), + ) + .unwrap(); + + assert_eq!(production.sites.machine_hostname, "joey.ui.example.invalid"); + assert_eq!(test.sites.machine_hostname, "joey.smoke.ui.example.invalid"); + assert!(!production.sites.acme_staging); + assert!(test.sites.acme_staging); + assert_eq!(production.sites.services.len(), test.sites.services.len()); + assert!(production.dns_records.iter().any(|record| record.name + == production.sites.machine_hostname + && record.record_type == "A")); + assert!(production.dns_records.iter().any(|record| { + record.name == production.sites.machine_hostname && record.record_type == "AAAA" + })); +} + +#[test] +fn each_plan_uses_only_the_calling_profile_services_and_aliases() { + let plan = build_ui_provisioning_plan( + "clamps", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + None, + &addresses(), + ) + .unwrap(); + + let declared = skillet_hosts::profile_for_name("clamps") + .unwrap() + .ui_services(); + let declared_names = declared + .iter() + .map(|service| service.name.as_str()) + .collect::>(); + let planned_names = plan + .sites + .services + .iter() + .map(|service| service.name.as_str()) + .collect::>(); + assert_eq!(planned_names, declared_names); + for alias in plan + .sites + .services + .iter() + .flat_map(|service| &service.aliases) + { + assert!(plan.dns_records.iter().any(|record| record.name == *alias)); + } +} + +#[test] +fn profiles_without_ui_and_incomplete_address_families_fail_closed() { + assert!(matches!( + build_ui_provisioning_plan( + "agent", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + None, + &addresses() + ), + Err(UiProvisioningError::NoUiServices(_)) + )); + let only_v4 = BTreeSet::from(["100.64.0.10".to_string()]); + assert!(matches!( + build_ui_provisioning_plan( + "joey", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + None, + &only_v4 + ), + Err(UiProvisioningError::Cloudflare(_)) + )); +} + +#[test] +fn disposable_cloudflare_ownership_precedes_token_and_dns_mutations() { + let run = tempfile::tempdir().unwrap(); + let addresses = addresses(); + let expected_record_count = build_ui_provisioning_plan( + "clamps", + ProvisioningPolicy::new(Environment::Test), + "example.invalid", + Some("smoke.ui"), + &addresses, + ) + .unwrap() + .dns_records + .len(); + let provider = fake_cloudflare(run.path(), false); + let guard_calls = std::cell::Cell::new(0); + + let provisioned = provision_disposable_ui( + &disposable_request(run.path(), &addresses), + &provider, + || { + guard_calls.set(guard_calls.get() + 1); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(guard_calls.get(), 1); + assert_eq!(provider.issued.get(), 1); + assert_eq!(provisioned.sites.host, "clamps"); + assert_eq!(provisioned.token.value, "dummy token value"); + assert_eq!(provisioned.token_name, "skillet:test:clamps-smoke"); + let ownership = read_ownership(run.path()); + assert_eq!( + ownership.identity, + Some(ProvisioningIdentity::new("clamps", "test", "smoke")) + ); + assert_eq!( + ownership.token_id.as_deref(), + Some("22222222222222222222222222222222") + ); + assert_eq!(ownership.record_ids.len(), expected_record_count); +} + +#[test] +fn disposable_cloudflare_journal_keeps_issued_token_after_dns_failure() { + let run = tempfile::tempdir().unwrap(); + let addresses = addresses(); + let provider = fake_cloudflare(run.path(), true); + + let result = provision_disposable_ui( + &disposable_request(run.path(), &addresses), + &provider, + || Ok(()), + ); + let Err(error) = result else { + panic!("fixture DNS failure unexpectedly succeeded") + }; + + assert!(error.to_string().contains("fixture DNS failure")); + assert_eq!(provider.issued.get(), 1); + let ownership = read_ownership(run.path()); + assert!(ownership.token_id.is_some()); + assert_eq!(ownership.record_ids, Vec::::new()); +} + +#[test] +fn disposable_cloudflare_checks_vault_before_issuing_token() { + let run = tempfile::tempdir().unwrap(); + let addresses = addresses(); + let provider = fake_cloudflare(run.path(), false); + + let result = provision_disposable_ui( + &disposable_request(run.path(), &addresses), + &provider, + || Err(VaultError::Invalid("vault changed".into())), + ); + let Err(error) = result else { + panic!("changed vault unexpectedly issued a token") + }; + + assert!(error.to_string().contains("vault changed")); + assert_eq!(provider.issued.get(), 0); + assert!(read_ownership(run.path()).token_id.is_none()); +} + +#[test] +fn disposable_cloudflare_cleanup_removes_only_journaled_owner_after_revocations() { + let run = tempfile::tempdir().unwrap(); + let provider = fake_cloudflare(run.path(), false); + create_cleanup_fixture(run.path(), &provider); + + assert!(cleanup_disposable_ui(&cleanup_request(run.path()), &provider).unwrap()); + + assert_eq!(provider.removed.get(), 1); + assert_eq!(provider.issued.get(), 2); + assert_eq!( + provider.revoked.borrow().as_slice(), + [ + "id-for:skillet:test:clamps-smoke", + "id-for:skillet:test:clamps-smoke:cleanup" + ] + ); + assert!(!run.path().join("cloudflare.json").exists()); + assert!(!cleanup_disposable_ui(&cleanup_request(run.path()), &provider).unwrap()); +} + +#[test] +fn disposable_cloudflare_cleanup_mismatch_has_no_provider_mutations() { + let run = tempfile::tempdir().unwrap(); + let provider = fake_cloudflare(run.path(), false); + create_cleanup_fixture(run.path(), &provider); + let mut request = cleanup_request(run.path()); + request.instance = "other"; + + assert!(cleanup_disposable_ui(&request, &provider).is_err()); + assert_eq!(provider.issued.get(), 1); + assert_eq!(provider.removed.get(), 0); + assert!(provider.revoked.borrow().is_empty()); + assert!(run.path().join("cloudflare.json").exists()); +} + +#[test] +fn disposable_cloudflare_cleanup_failure_retains_journal_for_retry() { + let run = tempfile::tempdir().unwrap(); + let mut provider = fake_cloudflare(run.path(), false); + create_cleanup_fixture(run.path(), &provider); + provider.fail_dns = true; + + assert!(cleanup_disposable_ui(&cleanup_request(run.path()), &provider).is_err()); + assert_eq!(provider.issued.get(), 2); + assert_eq!(provider.removed.get(), 0); + assert_eq!( + provider.revoked.borrow().as_slice(), + ["id-for:skillet:test:clamps-smoke:cleanup"] + ); + assert!(run.path().join("cloudflare.json").exists()); +} + +#[test] +fn persistent_ui_reuses_token_and_defers_caddy_until_credential_set_is_installed() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + store.entries.borrow_mut().insert( + "skillet/environments/prod/hosts/clamps/cloudflare/acme-token".into(), + "existing ACME token".into(), + ); + let guest = FakeGuest::default(); + + let sites = deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &guest, + ) + .unwrap(); + + assert_eq!(sites.host, "clamps"); + assert_eq!(cloudflare.issued.get(), 0); + assert_eq!(cloudflare.reconciled.get(), 1); + let calls = guest.calls.lock().unwrap(); + assert_eq!(calls.len(), 3); + assert!(calls[0] + .1 + .as_ref() + .is_some_and(|bytes| { serde_json::from_slice::(bytes).is_ok() })); + assert_eq!( + calls[1].1.as_deref(), + Some(b"existing ACME token".as_slice()) + ); + assert_eq!( + calls[2].0.last().map(String::as_str), + Some("skillet-caddy-apply.service") + ); +} + +#[test] +fn persistent_ui_mints_and_persists_token_before_dns_reconciliation() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + + deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &FakeGuest::default(), + ) + .unwrap(); + + assert_eq!(cloudflare.issued.get(), 1); + assert_eq!(store.saves.get(), 1); + assert_eq!(cloudflare.reconciled.get(), 1); + assert_eq!( + store + .entries + .borrow() + .get("skillet/environments/prod/hosts/clamps/cloudflare/acme-token") + .map(String::as_str), + Some("persistent dummy token") + ); +} + +#[test] +fn persistent_ui_migrates_legacy_production_token_without_reissuing_it() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + store.entries.borrow_mut().insert( + "skillet/hosts/clamps/cloudflare/acme-token".into(), + "legacy ACME token".into(), + ); + + deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &FakeGuest::default(), + ) + .unwrap(); + + assert_eq!(cloudflare.issued.get(), 0); + assert_eq!(store.saves.get(), 1); + assert_eq!( + store + .entries + .borrow() + .get("skillet/environments/prod/hosts/clamps/cloudflare/acme-token") + .map(String::as_str), + Some("legacy ACME token") + ); +} + +#[test] +fn persistent_ui_vault_conflict_prevents_token_issuance() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + store.changed.set(true); + + assert!(deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &FakeGuest::default(), + ) + .is_err()); + assert_eq!(cloudflare.issued.get(), 0); + assert_eq!(cloudflare.reconciled.get(), 0); +} + +#[test] +fn persistent_ui_failed_vault_save_revokes_just_issued_token() { + let run = tempfile::tempdir().unwrap(); + let cloudflare = fake_cloudflare(run.path(), false); + let mut store = FakeTokenStore::default(); + store.fail_save.set(true); + + assert!(deliver_persistent_ui( + &persistent_request(), + &mut store, + &cloudflare, + &FakeTailnet, + &FakeGuest::default(), + ) + .is_err()); + assert_eq!(cloudflare.issued.get(), 1); + assert_eq!(cloudflare.reconciled.get(), 0); + assert_eq!( + cloudflare.revoked.borrow().as_slice(), + ["33333333333333333333333333333333"] + ); +} + +#[test] +fn disposable_ui_delivery_installs_both_inputs_activates_probes_all_names_then_retires_old_tokens() +{ + let run = tempfile::tempdir().unwrap(); + let provider = fake_cloudflare(run.path(), false); + let credentials = disposable_credentials(); + let guest = FakeGuest::default(); + guest.denies_curl.set(true); + + deliver_disposable_ui( + "clamps", + &credentials, + "dummy creator token", + &provider, + &guest, + std::time::Duration::from_secs(1), + std::time::Duration::ZERO, + ) + .unwrap(); + + let calls = guest.calls.lock().unwrap(); + assert!(serde_json::from_slice::(calls[0].1.as_deref().unwrap()).is_ok()); + assert_eq!( + calls[1].1.as_deref(), + Some(b"disposable ACME token".as_slice()) + ); + assert!(calls.iter().any(|call| { + call.0 + .first() + .is_some_and(|program| program == "/usr/bin/curl") + && call.0.iter().any(|argument| argument == "--resolve") + && call + .0 + .iter() + .any(|argument| argument.ends_with(":172.26.26.5")) + })); + assert_eq!( + provider.revoked.borrow().as_slice(), + ["id-for:skillet:test:clamps-smoke"] + ); +} + +#[test] +fn disposable_ui_denial_failure_keeps_old_token_until_guest_acceptance_passes() { + let run = tempfile::tempdir().unwrap(); + let provider = fake_cloudflare(run.path(), false); + let credentials = disposable_credentials(); + + assert!(deliver_disposable_ui( + "clamps", + &credentials, + "dummy creator token", + &provider, + &FakeGuest::default(), + std::time::Duration::ZERO, + std::time::Duration::ZERO, + ) + .is_err()); + assert!(provider.revoked.borrow().is_empty()); +} + +fn test_users() -> &'static [String] { + static USERS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + vec![ + "alice@example.invalid".into(), + "irene@example.invalid".into(), + ] + }); + &USERS +} diff --git a/ublue/skillet/crates/workstation/src/vault.rs b/ublue/skillet/crates/workstation/src/vault.rs new file mode 100644 index 00000000..a7351a5a --- /dev/null +++ b/ublue/skillet/crates/workstation/src/vault.rs @@ -0,0 +1,590 @@ +//! `KeePassXC` database access, atomic persistence, and session password cache. + +use keepass::{Database, DatabaseKey}; +use keyutils::{keytypes::User, Keyring, Permission, SpecialKeyring}; +use sha2::{Digest, Sha256}; +use std::{ + error::Error as StdError, + fs, + io::Write as _, + os::unix::fs::OpenOptionsExt as _, + path::{Path, PathBuf}, + sync::Mutex, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; + +const CACHE_LIFETIME: Duration = Duration::from_hours(3); +const SESSION_KEYRING_NAME: &str = "skillet-vault-cache"; +static SESSION_KEYRING: Mutex> = Mutex::new(None); + +#[derive(Debug, Error)] +pub enum VaultError { + #[error("{context}: {source}")] + Source { + context: String, + #[source] + source: Box, + }, + #[error("{0}")] + Invalid(String), +} + +fn source(context: impl Into, error: E) -> VaultError +where + E: StdError + Send + Sync + 'static, +{ + VaultError::Source { + context: context.into(), + source: Box::new(error), + } +} + +pub struct Vault { + path: PathBuf, + original: Vec, + database: Database, + password: String, + password_cached: bool, +} + +impl Vault { + /// Enumerate entry paths without accessing Password or Username values. + pub fn entry_paths(&self) -> Vec { + let mut paths = Vec::new(); + let mut pending = vec![(self.database.root().id(), String::new())]; + while let Some((id, prefix)) = pending.pop() { + let Some(group) = self.database.group(id) else { + continue; + }; + for entry in group.entry_ids().filter_map(|id| self.database.entry(id)) { + paths.push(format!( + "{prefix}{}", + entry.get_title().unwrap_or("") + )); + } + for child in group.group_ids().filter_map(|id| self.database.group(id)) { + pending.push((child.id(), format!("{prefix}{}/", child.name))); + } + } + paths.sort(); + paths + } + + pub fn default_path() -> Result { + let xdg = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from); + let home = std::env::var_os("HOME").map(PathBuf::from); + database_path_from(xdg.as_deref(), home.as_deref()) + } + + pub fn open(path: &Path, key_file: Option<&Path>) -> Result { + // Resolve the symlink before any atomic replacement so the synced target + // is updated rather than replacing the symlink itself. + let path = fs::canonicalize(path).map_err(|error| { + source( + format!("KeePassXC database is missing or unreadable at {}; place the synced vault there or pass --database", path.display()), + error, + ) + })?; + let original = + fs::read(&path).map_err(|error| source("reading KeePassXC database", error))?; + if let Some(cached) = cache_read(&path)? { + if let Ok(database) = open_with_password(&original, &cached, key_file) { + return Ok(Self { + path, + original, + database, + password: cached, + password_cached: true, + }); + } + cache_clear(&path)?; + } + let password = rpassword::prompt_password("KeePassXC database password: ") + .map_err(|error| source("reading database password from terminal", error))?; + let database = open_with_password(&original, &password, key_file)?; + let password_cached = cache_store(&path, &password)?; + Ok(Self { + path, + original, + database, + password, + password_cached, + }) + } + + /// Whether the verified password is currently stored in the session keyring. + pub fn password_cached(&self) -> bool { + self.password_cached + } + + pub fn get(&self, path: &str) -> Result, VaultError> { + lookup(&self.database, path) + } + + /// Read a standard or custom field from an exact entry without logging values. + pub fn get_field(&self, path: &str, field: &str) -> Result, VaultError> { + lookup_field(&self.database, path, field) + } + + pub fn insert(&mut self, path: &str, secret: &str) -> Result<(), VaultError> { + create_entry(&mut self.database, path, secret) + } + + /// Add metadata to an existing exact entry before an atomic verified save. + pub fn set_field(&mut self, path: &str, field: &str, value: &str) -> Result<(), VaultError> { + let id = find_entry(&self.database, path)? + .ok_or_else(|| VaultError::Invalid("vault metadata entry is absent".into()))? + .id(); + let mut entry = self + .database + .entry_mut(id) + .ok_or_else(|| VaultError::Invalid("vault entry disappeared".into()))?; + entry.set_protected(field, value); + Ok(()) + } + + /// Encrypted binary material attached to an exact vault entry. + pub fn get_attachment( + &self, + path: &str, + name: &str, + ) -> Result>>, VaultError> { + Ok(find_entry(&self.database, path)?.and_then(|entry| { + entry + .attachment_by_name(name) + .map(|attachment| zeroize::Zeroizing::new(attachment.data.to_vec())) + })) + } + + pub fn set_attachment( + &mut self, + path: &str, + name: &str, + bytes: &[u8], + ) -> Result<(), VaultError> { + let id = find_entry(&self.database, path)? + .ok_or_else(|| VaultError::Invalid("vault attachment entry is absent".into()))? + .id(); + self.database + .entry_mut(id) + .ok_or_else(|| VaultError::Invalid("vault entry disappeared".into()))? + .add_attachment(name, keepass::db::Value::protected(bytes.to_vec())); + Ok(()) + } + + pub fn ensure_unchanged(&self) -> Result<(), VaultError> { + if fs::read(&self.path).map_err(|error| source("rechecking vault before write", error))? + != self.original + { + return Err(VaultError::Invalid( + "KeePassXC database changed while Skillet was running; reopen it and retry".into(), + )); + } + Ok(()) + } + + pub fn save_verified( + &mut self, + key_file: Option<&Path>, + entry_path: &str, + secret: &str, + ) -> Result<(), VaultError> { + self.save_checked(key_file, entry_path, secret, None) + } + + /// Reopen and verify both credential and attachment before atomic publication. + pub fn save_verified_attachment( + &mut self, + key_file: Option<&Path>, + entry_path: &str, + secret: &str, + name: &str, + bytes: &[u8], + ) -> Result<(), VaultError> { + self.save_checked(key_file, entry_path, secret, Some((name, bytes))) + } + + fn save_checked( + &mut self, + key_file: Option<&Path>, + entry_path: &str, + secret: &str, + attachment: Option<(&str, &[u8])>, + ) -> Result<(), VaultError> { + let parent = self + .path + .parent() + .ok_or_else(|| VaultError::Invalid("vault has no parent directory".into()))?; + let lock_path = parent.join(".skillet-vault.lock"); + let lock = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(lock_path) + .map_err(|error| source("opening vault write lock", error))?; + lock.lock() + .map_err(|error| source("locking vault for update", error))?; + self.ensure_unchanged()?; + + let mut candidate = tempfile::NamedTempFile::new_in(parent) + .map_err(|error| source("creating encrypted vault update", error))?; + self.database + .save( + candidate.as_file_mut(), + database_key(&self.password, key_file)?, + ) + .map_err(|error| source("saving KeePassXC database", error))?; + candidate + .as_file_mut() + .flush() + .map_err(|error| source("flushing encrypted vault update", error))?; + candidate + .as_file() + .set_permissions( + fs::metadata(&self.path) + .map_err(|error| source("reading vault metadata", error))? + .permissions(), + ) + .map_err(|error| source("preserving vault permissions", error))?; + candidate + .as_file() + .sync_all() + .map_err(|error| source("syncing encrypted vault update", error))?; + let candidate_bytes = fs::read(candidate.path()) + .map_err(|error| source("reading encrypted vault update", error))?; + let reopened = open_with_password(&candidate_bytes, &self.password, key_file)?; + if lookup(&reopened, entry_path)?.as_deref() != Some(secret) { + return Err(VaultError::Invalid( + "saved vault did not retain the generated credential".into(), + )); + } + + if let Some((name, bytes)) = attachment { + let retained = find_entry(&reopened, entry_path)?.and_then(|entry| { + entry + .attachment_by_name(name) + .map(|item| zeroize::Zeroizing::new(item.data.to_vec())) + }); + if retained.as_deref().map(Vec::as_slice) != Some(bytes) { + return Err(VaultError::Invalid( + "saved vault did not retain the recovery attachment".into(), + )); + } + } + + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| source("reading system time", error))? + .as_nanos(); + let backup_name = format!( + "{}.skillet-{stamp}.bak", + self.path + .file_name() + .ok_or_else(|| VaultError::Invalid("vault has no filename".into()))? + .to_string_lossy() + ); + let mut backup = tempfile::NamedTempFile::new_in(parent) + .map_err(|error| source("creating vault backup", error))?; + backup + .write_all(&self.original) + .map_err(|error| source("writing encrypted vault backup", error))?; + backup + .as_file() + .set_permissions( + fs::metadata(&self.path) + .map_err(|error| source("reading vault metadata", error))? + .permissions(), + ) + .map_err(|error| source("preserving backup permissions", error))?; + backup + .as_file() + .sync_all() + .map_err(|error| source("syncing encrypted vault backup", error))?; + backup + .persist_noclobber(parent.join(backup_name)) + .map_err(|error| source("preserving previous encrypted vault", error.error))?; + self.ensure_unchanged()?; + candidate + .persist(&self.path) + .map_err(|error| source("atomically replacing KeePassXC database", error.error))?; + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| source("syncing vault directory", error))?; + self.original = fs::read(&self.path) + .map_err(|error| source("refreshing vault snapshot after save", error))?; + Ok(()) + } +} + +/// Minimal exact-entry persistence boundary shared by workstation provisioners. +pub trait SecretStore { + fn get(&self, path: &str) -> Result, VaultError>; + fn ensure_unchanged(&self) -> Result<(), VaultError>; + fn save_verified(&mut self, path: &str, secret: &str) -> Result<(), VaultError>; +} + +pub struct VaultSecretStore<'a> { + vault: &'a mut Vault, + key_file: Option<&'a Path>, +} + +impl<'a> VaultSecretStore<'a> { + pub fn new(vault: &'a mut Vault, key_file: Option<&'a Path>) -> Self { + Self { vault, key_file } + } +} + +impl SecretStore for VaultSecretStore<'_> { + fn get(&self, path: &str) -> Result, VaultError> { + self.vault.get(path) + } + + fn ensure_unchanged(&self) -> Result<(), VaultError> { + self.vault.ensure_unchanged() + } + + fn save_verified(&mut self, path: &str, secret: &str) -> Result<(), VaultError> { + self.vault.insert(path, secret)?; + self.vault.save_verified(self.key_file, path, secret) + } +} + +pub fn lock(path: Option<&Path>) -> Result<(), VaultError> { + let path = match path { + Some(path) => path.to_path_buf(), + None => Vault::default_path()?, + }; + let canonical = + fs::canonicalize(path).map_err(|error| source("locating KeePassXC database", error))?; + cache_clear(&canonical) +} + +pub fn database_path_from( + xdg_data_home: Option<&Path>, + home: Option<&Path>, +) -> Result { + let data_home = if let Some(path) = xdg_data_home.filter(|path| path.is_absolute()) { + path.to_path_buf() + } else { + let home = home.filter(|path| path.is_absolute()).ok_or_else(|| { + VaultError::Invalid("set an absolute XDG_DATA_HOME or HOME, or pass --database".into()) + })?; + home.join(".local/share") + }; + Ok(data_home.join("skillet/secrets.kdbx")) +} + +fn database_key(password: &str, key_file: Option<&Path>) -> Result { + let mut key = DatabaseKey::new().with_password(password); + if let Some(path) = key_file { + let mut file = + fs::File::open(path).map_err(|error| source("opening KeePassXC key file", error))?; + key = key + .with_keyfile(&mut file) + .map_err(|error| source("reading KeePassXC key file", error))?; + } + Ok(key) +} + +fn open_with_password( + bytes: &[u8], + password: &str, + key_file: Option<&Path>, +) -> Result { + let mut reader = bytes; + Database::open(&mut reader, database_key(password, key_file)?).map_err(|error| { + source( + "opening KeePassXC database; check password and key file", + error, + ) + }) +} + +fn lookup(database: &Database, path: &str) -> Result, VaultError> { + let Some(secret) = lookup_field(database, path, "Password")? else { + if find_entry(database, path)?.is_some() { + return Err(VaultError::Invalid(format!( + "KeePassXC entry {path} has no Password field" + ))); + } + return Ok(None); + }; + if secret.is_empty() { + return Err(VaultError::Invalid(format!( + "KeePassXC entry {path} has an empty Password field" + ))); + } + Ok(Some(secret)) +} + +fn lookup_field( + database: &Database, + path: &str, + field: &str, +) -> Result, VaultError> { + Ok(find_entry(database, path)?.and_then(|entry| entry.get(field).map(str::to_owned))) +} + +fn find_entry<'a>( + database: &'a Database, + path: &str, +) -> Result>, VaultError> { + let parts: Vec<_> = path.split('/').collect(); + if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { + return Err(VaultError::Invalid("invalid KeePassXC entry path".into())); + } + let mut groups = vec![database.root().id()]; + for part in &parts[..parts.len() - 1] { + groups = groups + .into_iter() + .filter_map(|id| database.group(id)) + .flat_map(|group| group.group_ids().collect::>()) + .filter(|id| database.group(*id).is_some_and(|group| group.name == *part)) + .collect(); + } + let mut matches = groups + .iter() + .filter_map(|id| database.group(*id)) + .flat_map(|group| group.entry_ids().collect::>()) + .filter_map(|id| database.entry(id)) + .filter(|entry| entry.get_title() == parts.last().copied()) + .collect::>(); + if matches.len() > 1 { + return Err(VaultError::Invalid(format!( + "KeePassXC entry {path} is ambiguous" + ))); + } + Ok(matches.pop()) +} + +fn create_entry(database: &mut Database, path: &str, password: &str) -> Result<(), VaultError> { + let parts: Vec<_> = path.split('/').collect(); + if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) { + return Err(VaultError::Invalid("invalid KeePassXC entry path".into())); + } + let mut parent_id = database.root().id(); + for name in &parts[..parts.len() - 1] { + let parent = database + .group(parent_id) + .ok_or_else(|| VaultError::Invalid("vault group disappeared".into()))?; + let matching = parent + .group_ids() + .filter(|id| database.group(*id).is_some_and(|group| group.name == *name)) + .collect::>(); + parent_id = match matching.as_slice() { + [id] => *id, + [] => { + let mut parent = database + .group_mut(parent_id) + .ok_or_else(|| VaultError::Invalid("vault group disappeared".into()))?; + let mut group = parent.add_group(); + (*name).clone_into(&mut group.name); + group.id() + } + _ => { + return Err(VaultError::Invalid(format!( + "KeePassXC group {name} is ambiguous" + ))) + } + }; + } + if lookup(database, path)?.is_some() { + return Err(VaultError::Invalid(format!( + "KeePassXC entry {path} already exists" + ))); + } + let mut parent = database + .group_mut(parent_id) + .ok_or_else(|| VaultError::Invalid("vault group disappeared".into()))?; + let mut entry = parent.add_entry(); + entry.set_unprotected("Title", parts[parts.len() - 1]); + entry.set_protected("Password", password); + Ok(()) +} + +fn cache_description(database: &Path) -> String { + let digest = Sha256::digest(database.as_os_str().as_encoded_bytes()); + format!("skillet:vault:{}", hex::encode(digest)) +} + +fn session_keyring() -> Result { + let mut cached = SESSION_KEYRING + .lock() + .map_err(|_| VaultError::Invalid("vault session keyring lock was poisoned".into()))?; + if let Some(ring) = cached.as_ref() { + return Ok(ring.clone()); + } + let current = Keyring::attach_or_create(SpecialKeyring::Session) + .ok() + .and_then(|ring| { + ring.description() + .ok() + .filter(|description| description.description == SESSION_KEYRING_NAME) + .map(|_| ring) + }); + let mut ring = match current { + Some(ring) => ring, + None => Keyring::join_session(SESSION_KEYRING_NAME) + .map_err(|error| source("opening the named Linux session keyring", error))?, + }; + ring.set_permissions(Permission::POSSESSOR_ALL | Permission::USER_SEARCH) + .map_err(|error| source("setting access on the named vault session keyring", error))?; + let mut user = Keyring::attach_or_create(SpecialKeyring::User) + .map_err(|error| source("opening the Linux user keyring", error))?; + user.link_keyring(&ring) + .map_err(|error| source("linking the named vault session keyring", error))?; + *cached = Some(ring.clone()); + Ok(ring) +} + +fn cache_read(database: &Path) -> Result, VaultError> { + let Ok(ring) = session_keyring() else { + return Ok(None); + }; + let Ok(key) = ring.search_for_key::(cache_description(database), None) else { + return Ok(None); + }; + String::from_utf8( + key.read() + .map_err(|error| source("reading cached vault password", error))?, + ) + .map(Some) + .map_err(|error| source("decoding cached vault password", error)) +} + +fn cache_store(database: &Path, password: &str) -> Result { + let mut ring = match session_keyring() { + Ok(ring) => ring, + Err(error) => { + eprintln!("Vault password was not cached: Linux session keyring unavailable: {error}"); + return Ok(false); + } + }; + let Ok(mut key) = ring.add_key::(cache_description(database), password.as_bytes()) + else { + eprintln!("Vault password was not cached: could not add a kernel key"); + return Ok(false); + }; + if key.set_timeout(CACHE_LIFETIME).is_err() { + ring.unlink_key(&key) + .map_err(|error| source("removing a vault key whose expiry could not be set", error))?; + eprintln!("Vault password was not cached: kernel denied the three-hour expiry"); + return Ok(false); + } + Ok(true) +} + +fn cache_clear(database: &Path) -> Result<(), VaultError> { + let mut ring = session_keyring()?; + if let Ok(key) = ring.search_for_key::(cache_description(database), None) { + ring.unlink_key(&key) + .map_err(|error| source("removing cached vault password", error))?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "vault/tests.rs"] +mod tests; diff --git a/ublue/skillet/crates/workstation/src/vault/tests.rs b/ublue/skillet/crates/workstation/src/vault/tests.rs new file mode 100644 index 00000000..ea5f4dc5 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/vault/tests.rs @@ -0,0 +1,351 @@ +use super::{ + cache_clear, cache_read, cache_store, database_path_from, lookup, Vault, VaultError, + CACHE_LIFETIME, +}; +use keepass::{Database, DatabaseKey}; + +#[test] +fn entry_inventory_returns_only_paths_without_password_fields() { + let mut database = Database::new(); + super::create_entry( + &mut database, + "skillet/datadog/api-key", + "private-do-not-display", + ) + .unwrap(); + super::create_entry( + &mut database, + "personal/account/password", + "another-private-value", + ) + .unwrap(); + let vault = Vault { + path: std::path::PathBuf::new(), + original: Vec::new(), + database, + password: "unlock-value".into(), + password_cached: false, + }; + assert_eq!( + vault.entry_paths(), + ["personal/account/password", "skillet/datadog/api-key"] + ); +} + +#[test] +fn exact_entry_lookup_preserves_whitespace_and_reports_absence() { + let mut database = Database::new(); + let mut root = database.root_mut(); + let mut skillet = root.add_group(); + skillet.name = "skillet".to_string(); + let mut hosts = skillet.add_group(); + hosts.name = "hosts".to_string(); + let mut clamps = hosts.add_group(); + clamps.name = "clamps".to_string(); + let mut pihole = clamps.add_group(); + pihole.name = "pihole".to_string(); + let mut entry = pihole.add_entry(); + entry.set_unprotected("Title", "web-password"); + entry.set_protected("Password", " value\n"); + assert_eq!( + lookup(&database, "skillet/hosts/clamps/pihole/web-password") + .unwrap() + .as_deref(), + Some(" value\n") + ); + assert_eq!( + lookup(&database, "skillet/hosts/other/pihole/web-password").unwrap(), + None + ); +} + +#[test] +fn xdg_vault_path_uses_data_home_and_absolute_home_fallback() { + let xdg = std::path::Path::new("/tmp/xdg-data"); + let home = std::path::Path::new("/tmp/user-home"); + assert_eq!( + database_path_from(Some(xdg), Some(home)).unwrap(), + xdg.join("skillet/secrets.kdbx") + ); + assert_eq!( + database_path_from(Some(std::path::Path::new("relative")), Some(home)).unwrap(), + home.join(".local/share/skillet/secrets.kdbx") + ); + assert!(matches!( + database_path_from(None, None), + Err(VaultError::Invalid(_)) + )); +} + +#[test] +fn kdbx_round_trip_requires_the_correct_password() { + let mut database = Database::new(); + let mut root = database.root_mut(); + let mut group = root.add_group(); + group.name = "skillet".to_string(); + let mut entry = group.add_entry(); + entry.set_unprotected("Title", "fixture"); + entry.set_protected("Password", "dummy only"); + let mut encrypted = Vec::new(); + database + .save( + &mut encrypted, + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + assert!(Database::open( + &mut encrypted.as_slice(), + DatabaseKey::new().with_password("wrong") + ) + .is_err()); + let opened = Database::open( + &mut encrypted.as_slice(), + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + assert_eq!( + lookup(&opened, "skillet/fixture").unwrap().as_deref(), + Some("dummy only") + ); +} + +#[test] +fn verified_save_updates_symlink_target_and_keeps_recovery_copy() { + let dir = tempfile::tempdir().unwrap(); + let database_path = dir.path().join("synced.kdbx"); + let link_path = dir.path().join("secrets.kdbx"); + let database = Database::new(); + let password = "fixture unlock"; + let mut original = Vec::new(); + database + .save(&mut original, DatabaseKey::new().with_password(password)) + .unwrap(); + std::fs::write(&database_path, &original).unwrap(); + std::os::unix::fs::symlink(&database_path, &link_path).unwrap(); + let mut vault = Vault { + path: std::fs::canonicalize(&link_path).unwrap(), + original, + database, + password: password.to_string(), + password_cached: false, + }; + let entry_path = "skillet/hosts/clamps/pihole/web-password"; + vault.insert(entry_path, "generated test value").unwrap(); + vault + .set_field(entry_path, "luks-uuid", "fixture-volume") + .unwrap(); + vault + .set_field(entry_path, "recovery-state", "pending") + .unwrap(); + vault + .save_verified(None, entry_path, "generated test value") + .unwrap(); + assert!(link_path.is_symlink()); + let changed = std::fs::read(&database_path).unwrap(); + let reopened = Database::open( + &mut changed.as_slice(), + DatabaseKey::new().with_password(password), + ) + .unwrap(); + assert_eq!( + lookup(&reopened, entry_path).unwrap().as_deref(), + Some("generated test value") + ); + assert_eq!( + super::lookup_field(&reopened, entry_path, "luks-uuid") + .unwrap() + .as_deref(), + Some("fixture-volume") + ); + assert_eq!( + super::lookup_field(&reopened, entry_path, "recovery-state") + .unwrap() + .as_deref(), + Some("pending") + ); + assert!(std::fs::read_dir(dir.path()).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".bak"))); +} + +#[test] +fn verified_save_refuses_a_concurrent_vault_change() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.kdbx"); + let database = Database::new(); + let password = "fixture unlock"; + let mut original = Vec::new(); + database + .save(&mut original, DatabaseKey::new().with_password(password)) + .unwrap(); + std::fs::write(&path, &original).unwrap(); + let mut vault = Vault { + path: path.clone(), + original, + database, + password: password.to_string(), + password_cached: false, + }; + vault.insert("skillet/new-token", "dummy").unwrap(); + std::fs::write(&path, b"external update").unwrap(); + assert!(vault + .save_verified(None, "skillet/new-token", "dummy") + .is_err()); +} + +#[test] +fn session_password_cache_uses_three_hour_expiry_and_can_be_cleared() { + assert_eq!(CACHE_LIFETIME, std::time::Duration::from_hours(3)); + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("secrets.kdbx"); + let cached = cache_store(&database, "dummy vault password").unwrap(); + assert_eq!( + cache_read(&database).unwrap().as_deref(), + cached.then_some("dummy vault password") + ); + cache_clear(&database).unwrap(); + assert_eq!(cache_read(&database).unwrap(), None); +} + +#[test] +fn missing_vault_fails_before_password_prompt() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("secrets.kdbx"); + let Err(error) = Vault::open(&path, None) else { + panic!("missing vault unexpectedly opened"); + }; + assert!(error.to_string().contains("missing or unreadable")); + assert!(error.to_string().contains("secrets.kdbx")); +} + +#[test] +fn exact_field_lookup_supports_standard_protected_and_custom_fields() { + let mut db = Database::new(); + let mut root = db.root_mut(); + let mut group = root.add_group(); + group.name = "skillet".into(); + let mut entry = group.add_entry(); + entry.set_unprotected("Title", "smtp"); + entry.set_protected("UserName", "fixture-user"); + entry.set_protected("Password", "fixture-password"); + entry.set_unprotected("host", "smtp.example.com"); + entry.set_protected("tls", "starttls"); + for (field, expected) in [ + ("UserName", "fixture-user"), + ("Password", "fixture-password"), + ("host", "smtp.example.com"), + ("tls", "starttls"), + ] { + assert_eq!( + super::lookup_field(&db, "skillet/smtp", field) + .unwrap() + .as_deref(), + Some(expected) + ); + } + assert!(super::lookup_field(&db, "skillet/smtp", "port") + .unwrap() + .is_none()); + assert!(super::lookup_field(&db, "skillet/missing", "Password") + .unwrap() + .is_none()); + let mut root = db.root_mut(); + let mut group = root.add_group(); + group.name = "skillet".into(); + let mut entry = group.add_entry(); + entry.set_unprotected("Title", "smtp"); + assert!(matches!( + super::lookup_field(&db, "skillet/smtp", "host"), + Err(VaultError::Invalid(_)) + )); +} + +#[test] +fn recovery_store_round_trips_pending_and_verified_metadata_without_rotation() { + use crate::root_recovery::{Record, RecoveryStore, VaultStore}; + use zeroize::Zeroizing; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("fixture.kdbx"); + let database = Database::new(); + let mut bytes = Vec::new(); + database + .save( + &mut bytes, + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let mut vault = Vault { + path: path.clone(), + original: bytes, + database, + password: "fixture unlock".into(), + password_cached: false, + }; + let mut store = VaultStore { + vault: &mut vault, + key_file: None, + }; + let entry = "skillet/hosts/clamps/storage/root-recovery-key"; + let mut record = Record { + key: Zeroizing::new("b".repeat(128)), + volume: "12345678-1234-1234-1234-123456789abc".into(), + verified: false, + header: None, + }; + store.save(entry, &record).unwrap(); + assert!(!store.load(entry).unwrap().unwrap().verified); + record.verified = true; + record.header = Some(Zeroizing::new(b"sensitive binary header fixture".to_vec())); + store.save(entry, &record).unwrap(); + assert_eq!(store.load(entry).unwrap().unwrap().header, record.header); + let before = std::fs::read(&path).unwrap(); + store + .vault + .set_attachment( + entry, + crate::root_recovery::HEADER_ATTACHMENT, + b"wrong attachment", + ) + .unwrap(); + assert!(store + .vault + .save_verified_attachment( + None, + entry, + &record.key, + crate::root_recovery::HEADER_ATTACHMENT, + record.header.as_ref().unwrap() + ) + .is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + let encrypted = std::fs::read(path).unwrap(); + assert!(!encrypted + .windows(b"sensitive binary header fixture".len()) + .any(|chunk| chunk == b"sensitive binary header fixture")); + let reopened = Database::open( + &mut encrypted.as_slice(), + DatabaseKey::new().with_password("fixture unlock"), + ) + .unwrap(); + assert_eq!( + super::lookup_field(&reopened, entry, "Password") + .unwrap() + .unwrap(), + *record.key + ); + assert_eq!( + super::lookup_field(&reopened, entry, "recovery-state") + .unwrap() + .as_deref(), + Some("verified") + ); + assert_eq!( + super::lookup_field(&reopened, entry, "luks-uuid") + .unwrap() + .unwrap(), + record.volume + ); +} diff --git a/ublue/skillet/crates/workstation/src/zone_token.rs b/ublue/skillet/crates/workstation/src/zone_token.rs new file mode 100644 index 00000000..9cff3f27 --- /dev/null +++ b/ublue/skillet/crates/workstation/src/zone_token.rs @@ -0,0 +1,38 @@ +//! Shared atomic persistence of child tokens used by distinct consumers. +use crate::{ + cloudflare::{CloudflareError, IssuedToken}, + vault::{SecretStore, VaultError}, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TokenError { + #[error(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + Cloudflare(#[from] CloudflareError), + #[error("saving the issued token and revoking it both failed; retry named-token recovery")] + Recovery, +} + +/// Issuance recovers an orphan by its deterministic consumer-specific name. +/// If vault persistence fails, revoke the new token before returning. +pub fn use_or_create( + store: &mut impl SecretStore, + path: &str, + issue: impl FnOnce() -> Result, + revoke: impl FnOnce(&str) -> Result<(), CloudflareError>, +) -> Result { + store.ensure_unchanged()?; + if let Some(token) = store.get(path)? { + return Ok(token); + } + let issued = issue()?; + if let Err(error) = store.save_verified(path, &issued.value) { + return match revoke(&issued.id) { + Ok(()) => Err(error.into()), + Err(_) => Err(TokenError::Recovery), + }; + } + Ok(issued.value) +} diff --git a/ublue/skillet/integration_tests/fixture-entrypoint.sh b/ublue/skillet/integration_tests/fixture-entrypoint.sh new file mode 100644 index 00000000..9541b7d0 --- /dev/null +++ b/ublue/skillet/integration_tests/fixture-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +cp /fixture/config /data/observed-config +sha256sum /run/secrets/skillet-smoke-dummy | cut -d' ' -f1 > /data/observed-secret-sha +while :; do sleep 3600; done diff --git a/ublue/skillet/integration_tests/interrupted-reboot-recovery.sh b/ublue/skillet/integration_tests/interrupted-reboot-recovery.sh new file mode 100644 index 00000000..58591f44 --- /dev/null +++ b/ublue/skillet/integration_tests/interrupted-reboot-recovery.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf 'Usage: %s HOST INSTANCE [--with-applications]\n' "$0" >&2 +} + +if [[ $# -lt 2 || $# -gt 3 ]]; then + usage + exit 2 +fi + +host=$1 +instance=$2 +with_applications=${3:-} +if [[ -n $with_applications && $with_applications != --with-applications ]]; then + usage + exit 2 +fi + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +skillet_dir=$(cd -- "$script_dir/.." && pwd) +cd "$skillet_dir" + +skillet() { + cargo run --quiet --release -p skillet -- "$@" +} + +application_args=() +if [[ $with_applications == --with-applications ]]; then + application_args+=(--with-applications) +fi + +printf 'Injecting interruption after the smoke checkpoint for %s/%s\n' "$host" "$instance" +output_file=$(mktemp) +trap 'rm -f -- "$output_file"' EXIT +set +e +skillet test smoke "$host" --instance "$instance" \ + "${application_args[@]}" --interrupt-before-reboot 2>&1 | tee "$output_file" +result=${PIPESTATUS[0]} +set -e +output=$(<"$output_file") +if [[ $result -eq 0 ]]; then + printf 'Fault injection unexpectedly completed successfully\n' >&2 + exit 1 +fi +if [[ $result -eq 0 ]] || [[ $output != *'injected interruption after persisting the pre-reboot checkpoint'* ]]; then + printf 'Smoke failed for a reason other than the requested injected interruption\n' >&2 + exit 1 +fi + +status=$(skillet test vm status "$host" "$instance") +printf '%s\n' "$status" +if [[ $status != *'Lifecycle: Started'* ]]; then + printf 'Interrupted run did not remain in the recoverable Started phase\n' >&2 + exit 1 +fi + +printf 'Recovering readiness after the interruption\n' +skillet test vm ready "$host" "$instance" +status=$(skillet test vm status "$host" "$instance") +printf '%s\n' "$status" +if [[ $status != *'Lifecycle: Ready'* ]]; then + printf 'Readiness did not restore the Ready phase\n' >&2 + exit 1 +fi + +printf 'Running the normal smoke reboot after recovery\n' +skillet test smoke "$host" --instance "$instance" "${application_args[@]}" diff --git a/ublue/skillet/integration_tests/root-header-restore.sh b/ublue/skillet/integration_tests/root-header-restore.sh new file mode 100755 index 00000000..bd41c634 --- /dev/null +++ b/ublue/skillet/integration_tests/root-header-restore.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Destructive only to a fresh tmpfs regular-file fixture, never a block device. +set -euo pipefail +umask 077 +work="$(mktemp -d /run/skillet-header-restore.XXXXXXXX)" +trap 'rm -rf -- "$work"' EXIT +dd bs=128 count=1 iflag=fullblock status=none of="$work/key" +[[ "$(stat -c %s "$work/key")" == 128 ]] +LC_ALL=C grep -Eq '^[0-9a-f]{128}$' "$work/key" +cat > "$work/header" +truncate -s 64M "$work/fixture" +cryptsetup luksHeaderRestore --batch-mode "$work/fixture" --header-backup-file "$work/header" +cryptsetup open --test-passphrase --key-file "$work/key" "$work/fixture" diff --git a/ublue/skillet/integration_tests/smoke-guest.sh b/ublue/skillet/integration_tests/smoke-guest.sh new file mode 100755 index 00000000..2bfce563 --- /dev/null +++ b/ublue/skillet/integration_tests/smoke-guest.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +phase=${1:?phase required} +binary=/var/usrlocal/bin/skillet-smoke-fixture +host_binary=${2:?host binary path required} +credentials_required=${3:?credential capability required} +root=/var/lib/skillet-smoke +service=skillet-smoke-fixture.service +container=skillet-smoke-fixture + +fail() { + echo "smoke failed: $*" >&2 + systemctl --no-pager status "$service" >&2 || true + journalctl -u "$service" -n 30 --no-pager >&2 || true + exit 1 +} +trap 'fail "unexpected command at line $LINENO"' ERR + +apply() { + timeout 180s "$binary" test fixture-apply || fail "fixture apply" +} + +running() { + systemctl is-active --quiet "$service" || fail "service is inactive" + podman inspect "$container" >/dev/null || fail "container is missing" +} + +wait_running() { + local end=$((SECONDS + 60)) + while (( SECONDS < end )); do + if systemctl is-active --quiet "$service" && podman inspect "$container" >/dev/null 2>&1; then + return + fi + sleep 1 + done + fail "service did not start after reboot" +} + +observed() { + local wanted=$1 + local end=$((SECONDS + 30)) + while (( SECONDS < end )); do + if [[ -f "$root/data/observed-config" ]] && cmp -s "$root/data/observed-config" "$root/desired/config"; then + if [[ $wanted == config ]]; then return; fi + if [[ -f "$root/data/observed-secret-sha" ]] && + [[ $(sha256sum "$root/desired/secret" | cut -d ' ' -f 1) == $(cat "$root/data/observed-secret-sha") ]]; then + return + fi + fi + sleep 1 + done + fail "fixture did not consume desired $wanted" +} + +snapshot() { + sha256sum /etc/skillet-smoke/config /etc/skillet-smoke/entrypoint.sh /etc/containers/systemd/skillet-smoke-fixture.container + stat -c '%n %a %u %g' /etc/skillet-smoke /etc/skillet-smoke/config /etc/skillet-smoke/entrypoint.sh /etc/containers/systemd/skillet-smoke-fixture.container "$root/data" + systemctl show "$service" -p ActiveState -p Result + podman inspect --format '{{.Id}} {{.State.StartedAt}}' "$container" +} + +case "$phase" in + before-reboot) + # The smoke fixture is namespaced and the target was explicitly opted in + # as disposable. Reset only this fixture so `test smoke` can be rerun on + # the same VM after inspecting a previous run. + systemctl stop "$service" >/dev/null 2>&1 || true + systemctl disable "$service" >/dev/null 2>&1 || true + podman rm -f "$container" >/dev/null 2>&1 || true + rm -rf "$root" /etc/skillet-smoke /etc/containers/systemd/skillet-smoke-fixture.container + systemctl daemon-reload + mkdir -p "$root/desired" "$root/data" + touch "$root/allow-start" + printf 'revision-one\n' > "$root/desired/config" + printf 'dummy-secret-one\n' > "$root/desired/secret" + chmod 0600 "$root/desired/secret" + timeout 180s podman pull docker.io/library/alpine:3.20 >/dev/null || fail "fixture image pull" + + timeout 120s "$host_binary" apply --phase base || fail "host base apply" + if [[ $credentials_required == yes ]]; then + if env -u CREDENTIALS_DIRECTORY timeout 120s "$host_binary" apply >"$root/full-apply.stdout" 2>"$root/full-apply.stderr"; then + fail "full apply accepted missing declared credentials" + fi + if ! grep -Eq 'CREDENTIALS_DIRECTORY|credential' "$root/full-apply.stderr"; then + fail "full apply failed for an unexpected reason" + fi + elif ! timeout 180s "$host_binary" apply; then + fail "full apply failed without declared credentials" + fi + + apply + running + observed secret + printf 'persistent-test-data\n' > "$root/data/sentinel" + snapshot > "$root/first.snapshot" + apply + running + observed secret + snapshot > "$root/repeat.snapshot" + cmp -s "$root/first.snapshot" "$root/repeat.snapshot" || fail "repeat apply changed managed state or container identity" + + systemctl stop "$service" || fail "stopping fixture" + apply + running + observed secret + + printf 'revision-two\n' > "$root/desired/config" + apply + running + observed secret + snapshot > "$root/config-change.snapshot" + apply + snapshot > "$root/config-repeat.snapshot" + cmp -s "$root/config-change.snapshot" "$root/config-repeat.snapshot" || fail "configuration repeat recreated container" + + printf 'dummy-secret-two\n' > "$root/desired/secret" + apply + running + observed secret + snapshot > "$root/secret-change.snapshot" + apply + snapshot > "$root/secret-repeat.snapshot" + cmp -s "$root/secret-change.snapshot" "$root/secret-repeat.snapshot" || fail "secret repeat recreated container" + + systemctl stop "$service" || fail "stopping fixture before startup failure" + rm "$root/allow-start" + if timeout 120s "$binary" test fixture-apply >"$root/startup-failure.stdout" 2>"$root/startup-failure.stderr"; then + fail "startup failure returned success" + fi + systemctl show "$service" -p ActiveState -p Result > "$root/startup-failure.systemd" + journalctl -u "$service" -n 30 --no-pager > "$root/startup-failure.journal" + grep -q 'Result=exit-code' "$root/startup-failure.systemd" || fail "startup failure did not report the injected exit code" + touch "$root/allow-start" + apply + running + observed secret + + rm "$root/allow-start" + printf 'revision-three\n' > "$root/desired/config" + if timeout 120s "$binary" test fixture-apply >"$root/interrupted.stdout" 2>"$root/interrupted.stderr"; then + fail "interrupted activation returned success" + fi + systemctl show "$service" -p ActiveState -p Result > "$root/interrupted.systemd" + journalctl -u "$service" -n 30 --no-pager > "$root/interrupted.journal" + grep -q 'Result=exit-code' "$root/interrupted.systemd" || fail "interrupted activation did not report the injected exit code" + touch "$root/allow-start" + apply + running + observed secret + snapshot > "$root/pre-reboot.snapshot" + echo "pre-reboot smoke cases passed; evidence: $root/*.snapshot, $root/*.systemd and $root/*.journal" + ;; + after-reboot) + [[ $(cat "$root/data/sentinel") == persistent-test-data ]] || fail "persistent volume lost data" + wait_running + observed secret + snapshot > "$root/post-reboot.snapshot" + apply + snapshot > "$root/post-apply.snapshot" + cmp -s "$root/post-reboot.snapshot" "$root/post-apply.snapshot" || fail "post-reboot apply recreated container" + echo "all fixture smoke cases passed; evidence: $root/*.snapshot" + ;; + *) echo "unknown phase: $phase" >&2; exit 2 ;; +esac diff --git a/ublue/skillet/integration_tests/tpm-root.sh b/ublue/skillet/integration_tests/tpm-root.sh new file mode 100644 index 00000000..1edd2f03 --- /dev/null +++ b/ublue/skillet/integration_tests/tpm-root.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Guest assertions only. Rust owns SSH, resource ownership, and power cycling. +set -euo pipefail +umask 077 +device=/dev/disk/by-partlabel/root +state=/run/skillet-encryption-test +case "${1:-}" in + prepare) + mkdir -p "$state" + cat > "$state/recovery.key" + [[ "$(stat -c %s "$state/recovery.key")" == 64 ]] + if ! cryptsetup open --test-passphrase --key-file "$state/recovery.key" "$device"; then + slot="$(clevis luks list -d "$device" | awk 'NR == 1 {sub(/:$/, "", $1); print $1}')" + [[ "$slot" =~ ^[0-9]+$ ]] + clevis luks pass -d "$device" -s "$slot" | + cryptsetup luksAddKey --key-file=- "$device" "$state/recovery.key" + fi + rm -f "$state/luks.header" + cryptsetup luksHeaderBackup "$device" --header-backup-file "$state/luks.header" + printf '%s\n' 'skillet encrypted-root persistence' > /var/lib/data/.skillet-encryption-test + sync -f /var/lib/data/.skillet-encryption-test + ;; + verify) + [[ "$(cat /var/lib/data/.skillet-encryption-test)" == 'skillet encrypted-root persistence' ]] + mokutil --sb-state | grep -Fx 'SecureBoot enabled' + cryptsetup status root | grep -F 'LUKS2' + [[ "$(findmnt -T /var -n -o SOURCE)" == /dev/mapper/root* ]] + [[ "$(findmnt -M /var/lib/data -n -o UUID)" == "$(findmnt -M /var -n -o UUID)" ]] + [[ "$(findmnt -T /var -n -o FSTYPE)" == btrfs ]] + [[ "$(findmnt -M /var/lib/data -n -o FSROOT)" == /data ]] + [[ "$(blkid -s UUID -o value /dev/disk/by-label/root)" == "$(findmnt -T /var -n -o UUID)" ]] + cryptsetup open --test-passphrase --key-file=- "$device" + ;; + *) exit 2 ;; +esac diff --git a/ublue/skillet/scripts/root-recovery.sh b/ublue/skillet/scripts/root-recovery.sh new file mode 100644 index 00000000..389d9a2f --- /dev/null +++ b/ublue/skillet/scripts/root-recovery.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Focused guest enrollment: the workstation owns vault persistence and transport. +set -euo pipefail +umask 077 +device=/dev/disk/by-partlabel/root +key="$(mktemp /run/skillet-root-recovery.XXXXXXXX)" +work="" +trap 'rm -f -- "$key"; if [[ -n "$work" ]]; then rm -rf -- "$work"; fi' EXIT +cat > "$key" +[[ "$(stat -c %s "$key")" == 128 ]] +LC_ALL=C grep -Eq '^[0-9a-f]{128}$' "$key" +case "${1:-}" in + enroll) + if ! cryptsetup open --test-passphrase --key-file "$key" "$device"; then + slot="$(clevis luks list -d "$device" | awk 'NR == 1 {sub(/:$/, "", $1); print $1}')" + [[ "$slot" =~ ^[0-9]+$ ]] + clevis luks pass -d "$device" -s "$slot" | + cryptsetup luksAddKey --key-file=- "$device" "$key" + fi + ;; + header) + work="$(mktemp -d /run/skillet-root-header.XXXXXXXX)" + header="$work/luks.header" + cryptsetup luksHeaderBackup "$device" --header-backup-file "$header" + [[ "$(cryptsetup luksUUID --header "$header" "$device")" == "$(cryptsetup luksUUID "$device")" ]] + cryptsetup open --test-passphrase --header "$header" --key-file "$key" "$device" + cat "$header" + ;; + verify) cryptsetup open --test-passphrase --key-file "$key" "$device" ;; + *) exit 2 ;; +esac diff --git a/ublue/skillet/scripts/root-tpm-rebind.sh b/ublue/skillet/scripts/root-tpm-rebind.sh new file mode 100755 index 00000000..0fc7bf74 --- /dev/null +++ b/ublue/skillet/scripts/root-tpm-rebind.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Explicit recovery after TPM replacement; caller records the original slot. +set -euo pipefail +umask 077 +device=/dev/disk/by-partlabel/root +old="${1:-}" +[[ "$old" =~ ^[0-9]+$ ]] +key="$(mktemp /run/skillet-root-rebind.XXXXXXXX)" +trap 'rm -f -- "$key"' EXIT +cat > "$key" +[[ "$(stat -c %s "$key")" == 128 ]] +LC_ALL=C grep -Eq '^[0-9a-f]{128}$' "$key" +cryptsetup open --test-passphrase --key-file "$key" "$device" +mapfile -t slots < <(clevis luks list -d "$device" | awk '{sub(/:$/, "", $1); print $1}') +[[ "${#slots[@]}" -ge 1 && "${#slots[@]}" -le 2 ]] +new="" +old_present=false +for slot in "${slots[@]}"; do + [[ "$slot" =~ ^[0-9]+$ ]] + if [[ "$slot" == "$old" ]]; then old_present=true; else new="$slot"; fi +done +if "$old_present"; then + if clevis luks pass -d "$device" -s "$old" 2>/dev/null | + cryptsetup open --test-passphrase --key-file=- "$device" 2>/dev/null; then + echo 'Original TPM still unlocks; refusing replacement' >&2 + exit 1 + fi + if [[ -z "$new" ]]; then + clevis luks bind -y -d "$device" -k "$key" tpm2 '{"pcr_bank":"sha256","pcr_ids":"7"}' + mapfile -t slots < <(clevis luks list -d "$device" | awk '{sub(/:$/, "", $1); print $1}') + [[ "${#slots[@]}" == 2 ]] + for slot in "${slots[@]}"; do + [[ "$slot" =~ ^[0-9]+$ ]] + if [[ "$slot" != "$old" ]]; then new="$slot"; fi + done + fi +fi +[[ "$new" =~ ^[0-9]+$ ]] +clevis luks pass -d "$device" -s "$new" | + cryptsetup open --test-passphrase --key-file=- "$device" +if "$old_present"; then clevis luks unbind -f -d "$device" -s "$old"; fi +cryptsetup open --test-passphrase --key-file "$key" "$device"