From 42b2ce4d1d4de20842ab8d985fbc8c41c9012df1 Mon Sep 17 00:00:00 2001 From: "0m.ax" Date: Mon, 25 May 2026 14:58:42 +0200 Subject: [PATCH] Initial nix-ota implementation Self-hostable OTA update system for NixOS fleets: a control server, device agent, publisher CLI, and NixOS modules that ship prebuilt system closures from a binary cache to devices that don't have the flake. - crates/common: signed manifest types (ed25519), store-path validator - crates/server: axum + sqlite + HTMX dashboard, channel/device API - crates/agent: poll, verify signature + revision, nix copy, switch, health check, magic-rollback on failure - crates/publisher: keygen + sign + publish CLI for operators/CI - nix/modules: NixOS modules for server and agent - nix/tests/ota.nix: end-to-end VM test exercising publish A -> B -> broken C -> rollback to B (passes) The control server never holds the signing key; manifests are signed offline and verified against a pinned public key on each device. --- .github/workflows/publish.yml | 46 + .gitignore | 6 + Cargo.lock | 2945 +++++++++++++++++++++++++++++++++ Cargo.toml | 33 + README.md | 144 ++ crates/agent/Cargo.toml | 22 + crates/agent/src/main.rs | 261 +++ crates/common/Cargo.toml | 18 + crates/common/src/lib.rs | 234 +++ crates/publisher/Cargo.toml | 22 + crates/publisher/src/main.rs | 144 ++ crates/server/Cargo.toml | 25 + crates/server/src/main.rs | 316 ++++ crates/server/src/ui.rs | 145 ++ flake.lock | 27 + flake.nix | 70 + nix/modules/agent.nix | 87 + nix/modules/server.nix | 53 + nix/tests/ota.nix | 147 ++ 19 files changed, 4745 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 crates/agent/Cargo.toml create mode 100644 crates/agent/src/main.rs create mode 100644 crates/common/Cargo.toml create mode 100644 crates/common/src/lib.rs create mode 100644 crates/publisher/Cargo.toml create mode 100644 crates/publisher/src/main.rs create mode 100644 crates/server/Cargo.toml create mode 100644 crates/server/src/main.rs create mode 100644 crates/server/src/ui.rs create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/modules/agent.nix create mode 100644 nix/modules/server.nix create mode 100644 nix/tests/ota.nix diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..3f08ba8 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,46 @@ +name: publish +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v27 + with: + extra_nix_config: | + experimental-features = nix-command flakes + - name: Configure binary cache push + # Replace with your cache of choice (attic, cachix, S3+nix-serve, ...). + run: echo "configure cache push here" + - name: Build system closure + id: build + run: | + out=$(nix build --no-link --print-out-paths \ + ".#nixosConfigurations.${HOST}.config.system.build.toplevel") + echo "store_path=$out" >> "$GITHUB_OUTPUT" + env: + HOST: my-device + - name: Push to cache + run: nix copy --to "${CACHE_URL}?secret-key=$(pwd)/cache.key" "${{ steps.build.outputs.store_path }}" + env: + CACHE_URL: ${{ secrets.NIX_OTA_CACHE_URL }} + - name: Publish manifest + run: | + nix run git+https://linus.dyrehytten.dk/max/nix-ota#nix-ota -- publish \ + --server "$NIX_OTA_SERVER" \ + --token "$NIX_OTA_PUBLISH_TOKEN" \ + --key "$NIX_OTA_SIGNING_KEY_FILE" \ + --channel prod \ + --store-path "${{ steps.build.outputs.store_path }}" \ + --substituter "$NIX_OTA_CACHE_URL" + env: + NIX_OTA_SERVER: ${{ secrets.NIX_OTA_SERVER }} + NIX_OTA_PUBLISH_TOKEN: ${{ secrets.NIX_OTA_PUBLISH_TOKEN }} + NIX_OTA_CACHE_URL: ${{ secrets.NIX_OTA_CACHE_URL }} + NIX_OTA_SIGNING_KEY_FILE: ${{ runner.temp }}/sign.key + # Note: write the signing key from a secret to NIX_OTA_SIGNING_KEY_FILE + # in a previous step (omitted; depends on your secret store). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d2fb81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +result +result-* +.direnv +*.db +*.db-* diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e5ec73c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2945 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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 = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[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 = "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.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +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 = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[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 = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[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 = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[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 = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[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-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[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 = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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 = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[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 = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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 = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix-ota-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ed25519-dalek", + "nix-ota-common", + "reqwest", + "serde", + "serde_json", + "time", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "nix-ota-common" +version = "0.1.0" +dependencies = [ + "base64", + "ed25519-dalek", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "nix-ota-publisher" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "ed25519-dalek", + "nix-ota-common", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "nix-ota-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "nix-ota-common", + "serde", + "serde_json", + "sqlx", + "time", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "ulid", +] + +[[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-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[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 = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[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 = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[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_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[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.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "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 1.0.7", +] + +[[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 = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[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" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "time", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[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 = "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 = "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.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "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 = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "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 = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "serde", + "web-time", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[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 = "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 = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +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 = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..62218df --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[workspace] +resolver = "2" +members = ["crates/common", "crates/server", "crates/agent", "crates/publisher"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://linus.dyrehytten.dk/max/nix-ota" + +[workspace.dependencies] +anyhow = "1" +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +clap = { version = "4", features = ["derive", "env"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8", "pem"] } +rand = "0.8" +base64 = "0.22" +hex = "0.4" +time = { version = "0.3", features = ["serde", "formatting", "parsing", "macros"] } +axum = "0.7" +tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "fs"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "time", "migrate"] } +askama = { version = "0.12", features = ["with-axum"] } +askama_axum = "0.4" +ulid = { version = "1", features = ["serde"] } +sha2 = "0.10" diff --git a/README.md b/README.md new file mode 100644 index 0000000..34a0de7 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# nix-ota + +Open-source OTA updates for fleets of NixOS devices. A self-hostable +control server + lightweight device agent that ship prebuilt system +closures from a binary cache to devices that don't have your flake. + +Think Cachix Deploy, but you run it. + +## Architecture + +``` +┌─────────┐ 1. nix build + nix copy ┌──────────┐ +│ CI / │ ─────────────────────────► │ Binary │ (Attic / S3 / nix-serve / Cachix) +│ Builder │ │ Cache │ +└────┬────┘ └────▲─────┘ + │ 2. publish signed manifest │ + ▼ │ 4. nix copy --from +┌─────────────┐ 3. GET current ┌────────┴─────┐ +│ Control │ ◄──────────────── │ Device │ +│ Server + UI │ 5. POST checkin │ Agent │ ──► switch-to-configuration +└─────────────┘ └──────────────┘ +``` + +The control server **never holds the signing key**. Operators (or CI) +sign manifests with an offline ed25519 key and POST them; devices +verify against a pinned public key. A server compromise cannot push +arbitrary closures. + +## Components + +| Crate | Binary | Role | +|--------------------|--------------------|------------------------------------| +| `crates/server` | `nix-ota-server` | REST API + SQLite + HTMX dashboard | +| `crates/agent` | `nix-ota-agent` | Polls, verifies, applies, rolls back| +| `crates/publisher` | `nix-ota` | Operator/CI CLI (keygen + publish) | +| `crates/common` | (lib) | Manifest types + ed25519 | + +## Quickstart (< 10 minutes) + +### 1. Generate a signing key on your workstation + +```sh +nix run git+https://linus.dyrehytten.dk/max/nix-ota#nix-ota -- keygen --out ./sign.key +# prints the public key — save it, you'll bake it into every device. +``` + +### 2. Deploy the server + +```nix +# configuration.nix +{ + imports = [ nix-ota.nixosModules.server ]; + services.nix-ota-server = { + enable = true; + openFirewall = true; + publishTokenFile = "/run/secrets/nix-ota-publish-token"; + }; +} +``` + +### 3. Install the agent on a device + +```nix +{ + imports = [ nix-ota.nixosModules.agent ]; + services.nix-ota-agent = { + enable = true; + server = "https://ota.example.com"; + channel = "prod"; + deviceId = "fridge-007"; + publicKey = ""; + cacheUrl = "https://cache.example.com"; + cachePublicKey = "cache.example.com:abc...="; + healthCmd = "systemctl is-system-running --wait"; # optional + }; +} +``` + +### 4. Publish your first update + +```sh +nix build .#nixosConfigurations.fridge-007.config.system.build.toplevel +nix copy --to s3://my-cache ./result + +nix run git+https://linus.dyrehytten.dk/max/nix-ota#nix-ota -- publish \ + --server https://ota.example.com \ + --token $(cat publish-token) \ + --key ./sign.key \ + --channel prod \ + --store-path $(readlink -f result) \ + --substituter https://cache.example.com +``` + +Open `https://ota.example.com/` to watch the fleet pick it up. + +## How updates apply + +On each poll the agent: + +1. Fetches `/channels//current`. +2. Verifies the ed25519 signature against the pinned key. +3. Rejects manifests with a revision ≤ the last one applied (replay defense). +4. `nix copy --from ` — Nix verifies cache + signatures on every store path. +5. `nix-env -p /nix/var/nix/profiles/system --set ` +6. `/bin/switch-to-configuration switch` +7. Runs the optional `healthCmd`. On failure: switches back to the + previous generation and reports `rolled_back`. + +## Threat model + +| Threat | Mitigation | +|-----------------------------------------|---------------------------------------------------------------------------| +| Compromised control server pushes evil | Manifests must be signed by offline ed25519 key pinned on every device. | +| Compromised cache serves wrong closure | Nix verifies per-path signatures against `trusted-public-keys`. | +| Replay of an older (vulnerable) closure | Manifest carries monotonic `revision`; agent persists & rejects rollbacks.| +| Random internet caller publishes | `POST /channels/:name/publish` requires bearer token. | +| Random caller reads fleet state | UI/API should be put behind your reverse proxy / SSO. (v1: no built-in auth on reads.) | +| Bad closure bricks device | Health-check + magic-rollback to previous system generation. | + +**Key management:** keep `sign.key` offline (hardware token, ops laptop, +or a sealed CI secret). The server never sees it. Rotating: generate a +new key, update `publicKey` on devices in a closure published with the +old key, then start signing with the new one. + +## Non-goals (v1) + +- The server does no Nix evaluation or building — CI does that. +- No replacement for your binary cache — use Attic, Cachix, S3, nix-serve. +- No per-device secrets (use sops-nix / agenix inside the closure). +- No web-based config editing — config lives in your flake repo. + +## Development + +```sh +nix develop +cargo build --workspace +cargo test --workspace +nix flake check # runs the full NixOS VM test +``` + +## License + +MIT OR Apache-2.0. diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml new file mode 100644 index 0000000..69543ef --- /dev/null +++ b/crates/agent/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nix-ota-agent" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "nix-ota-agent" +path = "src/main.rs" + +[dependencies] +nix-ota-common = { path = "../common" } +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +clap.workspace = true +reqwest.workspace = true +ed25519-dalek.workspace = true +time.workspace = true diff --git a/crates/agent/src/main.rs b/crates/agent/src/main.rs new file mode 100644 index 0000000..60702a1 --- /dev/null +++ b/crates/agent/src/main.rs @@ -0,0 +1,261 @@ +//! `nix-ota-agent` — runs on each device. +//! +//! Lifecycle on every poll: +//! 1. Fetch `/channels//current`. +//! 2. Verify ed25519 signature against the device's pinned public key. +//! 3. Reject manifests with a revision <= last applied (replay defense). +//! 4. `nix copy --from ` — Nix itself verifies +//! the per-path signatures against the cache's public key, so a +//! compromised control server cannot inject store contents. +//! 5. `nix-env -p /nix/var/nix/profiles/system --set ` +//! then `/bin/switch-to-configuration switch`. +//! 6. Run the configured health check. On failure, roll back by +//! switching to the previous system profile generation. +//! 7. Check in with the control server. +//! +//! The agent stores small bits of state (last applied revision and +//! previous store path for rollback) under `--state-dir`, defaulting +//! to /var/lib/nix-ota. + +use anyhow::{anyhow, bail, Context, Result}; +use clap::Parser; +use nix_ota_common as common; +use serde::{Deserialize, Serialize}; +use std::{path::{Path, PathBuf}, time::Duration}; +use tokio::process::Command; + +const AGENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Parser, Debug, Clone)] +#[command(version, about = "nix-ota device agent")] +struct Args { + /// Control server base URL, e.g. https://ota.example.com + #[arg(long, env = "NIX_OTA_SERVER")] + server: String, + /// Channel name to follow (e.g. prod, canary). + #[arg(long, env = "NIX_OTA_CHANNEL", default_value = "prod")] + channel: String, + /// Device identifier (must be unique within a deployment). + #[arg(long, env = "NIX_OTA_DEVICE_ID")] + device_id: String, + /// Path to a file containing the base64-encoded ed25519 public key + /// used to verify manifest signatures. + #[arg(long, env = "NIX_OTA_PUBLIC_KEY_FILE")] + public_key_file: PathBuf, + /// Poll interval seconds. If `--once` is set, this is ignored. + #[arg(long, env = "NIX_OTA_INTERVAL", default_value_t = 60)] + interval: u64, + /// Run a single poll and exit (used by systemd timer). + #[arg(long, env = "NIX_OTA_ONCE")] + once: bool, + /// Persistent state directory. + #[arg(long, env = "NIX_OTA_STATE_DIR", default_value = "/var/lib/nix-ota")] + state_dir: PathBuf, + /// Optional health-check command. If exit code != 0 after switch, + /// the agent rolls back. + #[arg(long, env = "NIX_OTA_HEALTH_CMD")] + health_cmd: Option, + /// Dry-run: log what would happen, don't execute nix or switch. + #[arg(long, env = "NIX_OTA_DRY_RUN")] + dry_run: bool, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct State { + last_revision: u64, + last_store_path: Option, + previous_store_path: Option, +} + +impl State { + fn path(dir: &Path) -> PathBuf { dir.join("state.json") } + fn load(dir: &Path) -> Result { + let p = Self::path(dir); + if !p.exists() { return Ok(Self::default()); } + Ok(serde_json::from_slice(&std::fs::read(p)?)?) + } + fn save(&self, dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir).ok(); + let tmp = dir.join("state.json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?; + std::fs::rename(tmp, Self::path(dir))?; + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ) + .init(); + let args = Args::parse(); + let vk_b64 = std::fs::read_to_string(&args.public_key_file) + .with_context(|| format!("reading public key {}", args.public_key_file.display()))?; + let vk = common::decode_verifying_key(vk_b64.trim())?; + let client = reqwest::Client::builder() + .user_agent(format!("nix-ota-agent/{AGENT_VERSION}")) + .timeout(Duration::from_secs(30)) + .build()?; + + loop { + match run_once(&args, &vk, &client).await { + Ok(_) => {} + Err(e) => tracing::error!("poll failed: {e:#}"), + } + if args.once { break; } + tokio::time::sleep(Duration::from_secs(args.interval)).await; + } + Ok(()) +} + +async fn run_once(args: &Args, vk: &ed25519_dalek::VerifyingKey, client: &reqwest::Client) -> Result<()> +where + ed25519_dalek::VerifyingKey: Sized, +{ + let mut state = State::load(&args.state_dir)?; + let url = format!("{}/channels/{}/current", args.server.trim_end_matches('/'), args.channel); + let resp = client.get(&url).send().await?; + if !resp.status().is_success() { + // Still report a check-in so the dashboard knows we're alive. + checkin(args, client, &state, common::Health::Ok, Some(format!("no manifest: {}", resp.status()))).await.ok(); + bail!("server returned {}", resp.status()); + } + let manifest: common::Manifest = resp.json().await?; + common::verify_manifest(vk, &manifest) + .context("manifest signature verification failed")?; + + if manifest.body.revision <= state.last_revision { + tracing::debug!(rev = manifest.body.revision, "no new revision"); + checkin(args, client, &state, common::Health::Ok, None).await.ok(); + return Ok(()); + } + if Some(&manifest.body.store_path) == state.last_store_path.as_ref() { + // Same path, bumped revision (e.g. publish-rollback). Just record. + state.last_revision = manifest.body.revision; + state.save(&args.state_dir)?; + checkin(args, client, &state, common::Health::Ok, None).await.ok(); + return Ok(()); + } + + tracing::info!(target = %manifest.body.store_path, rev = manifest.body.revision, "applying new closure"); + checkin(args, client, &state, common::Health::Updating, + Some(format!("copying {}", manifest.body.store_path))).await.ok(); + + // 1. Copy from cache. + if !args.dry_run { + nix_copy(&manifest.body.substituter, &manifest.body.store_path).await?; + } + + // 2. Switch. + let previous = state.last_store_path.clone(); + if !args.dry_run { + nix_set_profile(&manifest.body.store_path).await?; + switch_to_configuration(&manifest.body.store_path, "switch").await?; + } + + // 3. Health check. + let healthy = run_health_check(args.health_cmd.as_deref()).await; + if !healthy { + tracing::error!("health check failed, rolling back"); + if let Some(prev) = previous.as_deref() { + if !args.dry_run { + if let Err(e) = rollback(prev).await { + tracing::error!("rollback failed: {e:#}"); + checkin(args, client, &state, common::Health::Failed, + Some(format!("rollback failed: {e}"))).await.ok(); + bail!("rollback failed"); + } + } + checkin(args, client, &state, common::Health::RolledBack, + Some(format!("rolled back to {prev}"))).await.ok(); + } else { + checkin(args, client, &state, common::Health::Failed, + Some("no previous generation to roll back to".into())).await.ok(); + } + // Do NOT record success; intentionally leave last_revision so we + // retry the next poll only if a *new* revision is published. + state.last_revision = manifest.body.revision; + state.save(&args.state_dir)?; + bail!("health check failed"); + } + + state.previous_store_path = previous; + state.last_store_path = Some(manifest.body.store_path.clone()); + state.last_revision = manifest.body.revision; + state.save(&args.state_dir)?; + checkin(args, client, &state, common::Health::Ok, Some("applied".into())).await.ok(); + Ok(()) +} + +async fn nix_copy(substituter: &str, path: &str) -> Result<()> { + let status = Command::new("nix") + .args(["copy", "--from", substituter, path]) + .status() + .await + .context("running `nix copy`")?; + if !status.success() { bail!("nix copy exited {status}"); } + Ok(()) +} + +async fn nix_set_profile(path: &str) -> Result<()> { + let status = Command::new("nix-env") + .args(["-p", "/nix/var/nix/profiles/system", "--set", path]) + .status() + .await + .context("running `nix-env --set`")?; + if !status.success() { bail!("nix-env exited {status}"); } + Ok(()) +} + +async fn switch_to_configuration(store_path: &str, action: &str) -> Result<()> { + let bin = format!("{store_path}/bin/switch-to-configuration"); + let status = Command::new(&bin).arg(action).status().await + .with_context(|| format!("running {bin}"))?; + if !status.success() { bail!("switch-to-configuration exited {status}"); } + Ok(()) +} + +async fn rollback(previous_store_path: &str) -> Result<()> { + nix_set_profile(previous_store_path).await?; + switch_to_configuration(previous_store_path, "switch").await?; + Ok(()) +} + +async fn run_health_check(cmd: Option<&str>) -> bool { + let Some(cmd) = cmd else { return true; }; + match Command::new("sh").arg("-c").arg(cmd).status().await { + Ok(s) => s.success(), + Err(e) => { + tracing::error!("health check exec failed: {e}"); + false + } + } +} + +async fn checkin( + args: &Args, + client: &reqwest::Client, + state: &State, + health: common::Health, + message: Option, +) -> Result<()> { + let ci = common::CheckIn { + device_id: args.device_id.clone(), + channel: args.channel.clone(), + current_store_path: state.last_store_path.clone(), + target_store_path: state.last_store_path.clone(), + health, + agent_version: AGENT_VERSION.into(), + message, + }; + let url = format!("{}/devices/{}/checkin", + args.server.trim_end_matches('/'), args.device_id); + let r = client.post(&url).json(&ci).send().await?; + if !r.status().is_success() { + return Err(anyhow!("checkin status {}", r.status())); + } + Ok(()) +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml new file mode 100644 index 0000000..2ab125e --- /dev/null +++ b/crates/common/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "nix-ota-common" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +ed25519-dalek.workspace = true +base64.workspace = true +rand.workspace = true +time.workspace = true +thiserror.workspace = true +sha2.workspace = true diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs new file mode 100644 index 0000000..8c0d8b0 --- /dev/null +++ b/crates/common/src/lib.rs @@ -0,0 +1,234 @@ +//! Shared types and crypto for nix-ota. +//! +//! The central object is a signed [`Manifest`]: a small JSON document +//! pointing at a NixOS system closure store path together with the +//! substituter to fetch it from. Manifests are signed by an offline +//! ed25519 key; the agent verifies them on every poll. +//! +//! The signature covers the canonical serialization of [`ManifestBody`] +//! (the manifest without its own signature). We use serde_json with sorted +//! keys via `BTreeMap`-style ordering to keep things deterministic. + +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; + +pub const STORE_PATH_PREFIX: &str = "/nix/store/"; + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid base64: {0}")] + Base64(#[from] base64::DecodeError), + #[error("invalid signature")] + Signature, + #[error("invalid key: {0}")] + Key(String), + #[error("invalid store path: {0}")] + StorePath(String), + #[error("serialization: {0}")] + Serde(#[from] serde_json::Error), + #[error("manifest signed by unexpected key")] + KeyMismatch, +} + +/// The signed payload of a manifest. +/// +/// `key_id` is the first 8 bytes (hex) of the SHA-256 of the verifying key, +/// to help operators rotate keys and to give clear errors when a device +/// is configured with the wrong key. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ManifestBody { + pub channel: String, + /// Absolute Nix store path of the system closure top-level + /// (e.g. `/nix/store/...-nixos-system-foo-24.05.toplevel`). + pub store_path: String, + /// Substituter URL the agent should `nix copy --from`. + pub substituter: String, + /// Unix timestamp seconds. + pub timestamp: i64, + /// Monotonically increasing revision for this channel. Used by agents + /// to ignore replays of older manifests. + pub revision: u64, + pub key_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Manifest { + #[serde(flatten)] + pub body: ManifestBody, + /// base64(ed25519 signature over canonical JSON of `body`). + pub signature: String, +} + +/// Validate that a string looks like a Nix store path. This is intentionally +/// strict to avoid an attacker tricking the agent into running arbitrary paths. +pub fn validate_store_path(p: &str) -> Result<(), Error> { + if !p.starts_with(STORE_PATH_PREFIX) { + return Err(Error::StorePath(format!("must start with {STORE_PATH_PREFIX}"))); + } + let rest = &p[STORE_PATH_PREFIX.len()..]; + if rest.is_empty() || rest.contains('/') || rest.contains("..") { + return Err(Error::StorePath("must be a single store object".into())); + } + // hash-name format: 32 base32 chars, '-', name + let dash = rest.find('-').ok_or_else(|| Error::StorePath("missing -".into()))?; + if dash != 32 { + return Err(Error::StorePath("hash must be 32 chars".into())); + } + for c in rest.chars() { + if !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+' | '?' | '=')) { + return Err(Error::StorePath(format!("invalid char {c:?}"))); + } + } + Ok(()) +} + +pub fn key_id(vk: &VerifyingKey) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(vk.as_bytes()); + let out = h.finalize(); + hex_short(&out[..8]) +} + +fn hex_short(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// Canonical bytes used for signing/verification. +pub fn canonical_body(body: &ManifestBody) -> Result, Error> { + // serde_json preserves field order from the struct definition, which is + // stable. That's our canonical form for v1. + Ok(serde_json::to_vec(body)?) +} + +pub fn sign_manifest(sk: &SigningKey, mut body: ManifestBody) -> Result { + body.key_id = key_id(&sk.verifying_key()); + let bytes = canonical_body(&body)?; + let sig: Signature = sk.sign(&bytes); + Ok(Manifest { + body, + signature: B64.encode(sig.to_bytes()), + }) +} + +pub fn verify_manifest(vk: &VerifyingKey, m: &Manifest) -> Result<(), Error> { + if m.body.key_id != key_id(vk) { + return Err(Error::KeyMismatch); + } + let sig_bytes = B64.decode(m.signature.as_bytes())?; + let sig = Signature::from_slice(&sig_bytes).map_err(|_| Error::Signature)?; + let bytes = canonical_body(&m.body)?; + vk.verify(&bytes, &sig).map_err(|_| Error::Signature)?; + validate_store_path(&m.body.store_path)?; + Ok(()) +} + +pub fn generate_keypair() -> SigningKey { + SigningKey::generate(&mut OsRng) +} + +pub fn encode_signing_key(sk: &SigningKey) -> String { + B64.encode(sk.to_bytes()) +} +pub fn decode_signing_key(s: &str) -> Result { + let raw = B64.decode(s.trim().as_bytes())?; + let arr: [u8; 32] = raw.as_slice().try_into().map_err(|_| Error::Key("len".into()))?; + Ok(SigningKey::from_bytes(&arr)) +} +pub fn encode_verifying_key(vk: &VerifyingKey) -> String { + B64.encode(vk.to_bytes()) +} +pub fn decode_verifying_key(s: &str) -> Result { + let raw = B64.decode(s.trim().as_bytes())?; + let arr: [u8; 32] = raw.as_slice().try_into().map_err(|_| Error::Key("len".into()))?; + VerifyingKey::from_bytes(&arr).map_err(|e| Error::Key(e.to_string())) +} + +// --- check-in API types --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckIn { + pub device_id: String, + pub channel: String, + pub current_store_path: Option, + pub target_store_path: Option, + pub health: Health, + pub agent_version: String, + pub message: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Health { + Ok, + Updating, + Failed, + RolledBack, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckInAck { + pub server_time: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishRequest { + pub store_path: String, + pub substituter: String, +} + +pub fn now() -> i64 { + OffsetDateTime::now_utc().unix_timestamp() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sign_and_verify_roundtrip() { + let sk = generate_keypair(); + let body = ManifestBody { + channel: "prod".into(), + store_path: "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-system".into(), + substituter: "https://cache.example.com".into(), + timestamp: 1234, + revision: 1, + key_id: String::new(), + }; + let m = sign_manifest(&sk, body).unwrap(); + verify_manifest(&sk.verifying_key(), &m).unwrap(); + } + + #[test] + fn rejects_tamper() { + let sk = generate_keypair(); + let body = ManifestBody { + channel: "prod".into(), + store_path: "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-system".into(), + substituter: "https://cache.example.com".into(), + timestamp: 1234, + revision: 1, + key_id: String::new(), + }; + let mut m = sign_manifest(&sk, body).unwrap(); + m.body.store_path = "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-evil".into(); + assert!(verify_manifest(&sk.verifying_key(), &m).is_err()); + } + + #[test] + fn rejects_bad_store_path() { + assert!(validate_store_path("/etc/passwd").is_err()); + assert!(validate_store_path("/nix/store/short-name").is_err()); + assert!(validate_store_path("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-system/../x").is_err()); + assert!(validate_store_path("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-system").is_ok()); + } +} diff --git a/crates/publisher/Cargo.toml b/crates/publisher/Cargo.toml new file mode 100644 index 0000000..29222e0 --- /dev/null +++ b/crates/publisher/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nix-ota-publisher" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "nix-ota" +path = "src/main.rs" + +[dependencies] +nix-ota-common = { path = "../common" } +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +clap.workspace = true +reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false } +ed25519-dalek.workspace = true +base64.workspace = true diff --git a/crates/publisher/src/main.rs b/crates/publisher/src/main.rs new file mode 100644 index 0000000..b98310b --- /dev/null +++ b/crates/publisher/src/main.rs @@ -0,0 +1,144 @@ +//! `nix-ota` — operator/CI CLI. +//! +//! Subcommands: +//! keygen Generate an ed25519 keypair for manifest signing. +//! sign Sign a manifest body and print it to stdout. +//! publish Sign + POST a manifest to the control server. +//! show-pubkey Derive and print the verifying key from a signing key. +//! +//! The signing key never leaves the operator's machine (or the CI secret +//! store); the control server only sees signed manifests. + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use nix_ota_common as common; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(version, about = "nix-ota operator CLI")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Generate a new ed25519 signing key. Writes private key to + /// `--out` and prints public key to stdout. + Keygen { + #[arg(long)] + out: PathBuf, + }, + /// Print the public key derived from a signing key file. + ShowPubkey { + #[arg(long)] + key: PathBuf, + }, + /// Build a signed manifest from arguments and print to stdout. + Sign { + #[arg(long)] + key: PathBuf, + #[arg(long)] + channel: String, + #[arg(long)] + store_path: String, + #[arg(long)] + substituter: String, + #[arg(long)] + revision: u64, + }, + /// Sign and publish a manifest to a control server. + Publish { + #[arg(long, env = "NIX_OTA_SERVER")] + server: String, + #[arg(long, env = "NIX_OTA_PUBLISH_TOKEN")] + token: String, + #[arg(long, env = "NIX_OTA_SIGNING_KEY_FILE")] + key: PathBuf, + #[arg(long)] + channel: String, + #[arg(long)] + store_path: String, + #[arg(long)] + substituter: String, + /// If unset, the publisher fetches the current revision and uses N+1. + #[arg(long)] + revision: Option, + }, +} + +fn main() -> Result<()> { + tracing_subscriber::fmt().with_env_filter("info").init(); + let cli = Cli::parse(); + match cli.cmd { + Cmd::Keygen { out } => { + let sk = common::generate_keypair(); + std::fs::write(&out, common::encode_signing_key(&sk)) + .with_context(|| format!("writing {}", out.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o600)).ok(); + } + println!("{}", common::encode_verifying_key(&sk.verifying_key())); + } + Cmd::ShowPubkey { key } => { + let sk = load_key(&key)?; + println!("{}", common::encode_verifying_key(&sk.verifying_key())); + } + Cmd::Sign { key, channel, store_path, substituter, revision } => { + let sk = load_key(&key)?; + common::validate_store_path(&store_path)?; + let m = common::sign_manifest(&sk, common::ManifestBody { + channel, store_path, substituter, + timestamp: common::now(), revision, key_id: String::new(), + })?; + println!("{}", serde_json::to_string_pretty(&m)?); + } + Cmd::Publish { server, token, key, channel, store_path, substituter, revision } => { + let sk = load_key(&key)?; + common::validate_store_path(&store_path)?; + let client = reqwest::blocking::Client::new(); + let rev = match revision { + Some(r) => r, + None => next_revision(&client, &server, &channel)?, + }; + let m = common::sign_manifest(&sk, common::ManifestBody { + channel: channel.clone(), + store_path, + substituter, + timestamp: common::now(), + revision: rev, + key_id: String::new(), + })?; + let url = format!("{}/channels/{}/publish", + server.trim_end_matches('/'), channel); + let resp = client.post(&url) + .bearer_auth(&token) + .json(&m) + .send()?; + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("publish failed: {status} {body}"); + } + println!("{body}"); + } + } + Ok(()) +} + +fn load_key(p: &PathBuf) -> Result { + let s = std::fs::read_to_string(p) + .with_context(|| format!("reading key {}", p.display()))?; + Ok(common::decode_signing_key(s.trim())?) +} + +fn next_revision(client: &reqwest::blocking::Client, server: &str, channel: &str) -> Result { + let url = format!("{}/channels/{}/current", server.trim_end_matches('/'), channel); + let r = client.get(&url).send()?; + if r.status() == reqwest::StatusCode::NOT_FOUND { return Ok(1); } + if !r.status().is_success() { anyhow::bail!("fetch current: {}", r.status()); } + let m: common::Manifest = r.json()?; + Ok(m.body.revision + 1) +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..3e8fa0b --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "nix-ota-server" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "nix-ota-server" +path = "src/main.rs" + +[dependencies] +nix-ota-common = { path = "../common" } +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +clap.workspace = true +axum.workspace = true +tower.workspace = true +tower-http.workspace = true +sqlx.workspace = true +time.workspace = true +ulid.workspace = true diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..cdd30e8 --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,316 @@ +//! `nix-ota-server` — control plane. +//! +//! Single static binary that: +//! * serves the REST API consumed by the agent and publisher, +//! * persists channel/device state in SQLite, +//! * renders an HTMX-based dashboard from embedded templates. +//! +//! The server never holds the manifest signing key. Operators sign +//! manifests on a workstation (or in CI with a sealed secret) and POST +//! the already-signed manifest to `/channels/:name/publish`. The server's +//! job is purely to fan signed manifests out to devices and to record +//! check-ins, so a server compromise cannot push arbitrary closures. + +use anyhow::Result; +use axum::{ + extract::{Path, State}, + http::{header, HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use clap::Parser; +use nix_ota_common as common; +use serde::{Deserialize, Serialize}; +use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; +use std::{net::SocketAddr, sync::Arc}; +use tower_http::trace::TraceLayer; + +mod ui; + +#[derive(Parser, Debug)] +#[command(version, about = "nix-ota control server")] +struct Args { + /// Listen address. + #[arg(long, env = "NIX_OTA_LISTEN", default_value = "0.0.0.0:8080")] + listen: SocketAddr, + /// Path to SQLite database. + #[arg(long, env = "NIX_OTA_DB", default_value = "nix-ota.db")] + db: String, + /// Bearer token required for /publish endpoints. If unset, a random + /// token is generated and printed at startup. + #[arg(long, env = "NIX_OTA_PUBLISH_TOKEN")] + publish_token: Option, +} + +#[derive(Clone)] +pub struct AppState { + pub db: SqlitePool, + pub publish_token: Arc, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,sqlx=warn".into()), + ) + .init(); + + let args = Args::parse(); + let publish_token = args.publish_token.clone().unwrap_or_else(|| { + let t = ulid::Ulid::new().to_string(); + tracing::warn!("no --publish-token set; generated ephemeral token: {t}"); + t + }); + + let db_url = format!("sqlite://{}?mode=rwc", args.db); + let db = SqlitePoolOptions::new() + .max_connections(8) + .connect(&db_url) + .await?; + migrate(&db).await?; + + let state = AppState { + db, + publish_token: Arc::new(publish_token), + }; + + let app = Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/channels/:name/current", get(get_current)) + .route("/channels/:name/publish", post(publish)) + .route("/devices/:id/checkin", post(checkin)) + .route("/", get(ui::index)) + .route("/ui/channels/:name", get(ui::channel_detail)) + .route("/ui/devices/:id", get(ui::device_detail)) + .with_state(state) + .layer(TraceLayer::new_for_http()); + + tracing::info!("listening on {}", args.listen); + let listener = tokio::net::TcpListener::bind(args.listen).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +async fn migrate(db: &SqlitePool) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS channels ( + name TEXT PRIMARY KEY, + current_manifest TEXT, + revision INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + "#, + ) + .execute(db) + .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS channel_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL, + manifest TEXT NOT NULL, + revision INTEGER NOT NULL, + published_at INTEGER NOT NULL + ); + "#, + ) + .execute(db) + .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + channel TEXT NOT NULL, + current_store_path TEXT, + target_store_path TEXT, + health TEXT NOT NULL DEFAULT 'ok', + last_message TEXT, + agent_version TEXT, + last_seen INTEGER NOT NULL DEFAULT 0 + ); + "#, + ) + .execute(db) + .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS device_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + store_path TEXT, + health TEXT NOT NULL, + message TEXT, + at INTEGER NOT NULL + ); + "#, + ) + .execute(db) + .await?; + Ok(()) +} + +// ---------- API handlers ---------- + +async fn get_current( + State(s): State, + Path(name): Path, +) -> Result, ApiError> { + let row: Option<(Option,)> = + sqlx::query_as("SELECT current_manifest FROM channels WHERE name = ?") + .bind(&name) + .fetch_optional(&s.db) + .await?; + let manifest_json = row.and_then(|r| r.0).ok_or(ApiError::NotFound)?; + let manifest: common::Manifest = serde_json::from_str(&manifest_json)?; + Ok(Json(manifest)) +} + +async fn publish( + State(s): State, + Path(name): Path, + headers: HeaderMap, + Json(manifest): Json, +) -> Result, ApiError> { + require_token(&headers, &s.publish_token)?; + if manifest.body.channel != name { + return Err(ApiError::BadRequest("channel mismatch".into())); + } + common::validate_store_path(&manifest.body.store_path) + .map_err(|e| ApiError::BadRequest(e.to_string()))?; + // We do NOT verify the signature against any key here — the server is + // intentionally key-agnostic. Devices verify against their pinned key. + + let now = common::now(); + let json = serde_json::to_string(&manifest)?; + let mut tx = s.db.begin().await?; + // Bump revision atomically. + let cur: Option<(i64,)> = sqlx::query_as("SELECT revision FROM channels WHERE name = ?") + .bind(&name) + .fetch_optional(&mut *tx) + .await?; + let next_rev = cur.map(|r| r.0).unwrap_or(0) + 1; + if (manifest.body.revision as i64) != next_rev { + return Err(ApiError::BadRequest(format!( + "manifest revision must be {next_rev}, got {}", + manifest.body.revision + ))); + } + sqlx::query( + "INSERT INTO channels(name, current_manifest, revision, updated_at) VALUES(?,?,?,?) + ON CONFLICT(name) DO UPDATE SET current_manifest=excluded.current_manifest, + revision=excluded.revision, updated_at=excluded.updated_at", + ) + .bind(&name) + .bind(&json) + .bind(next_rev) + .bind(now) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO channel_history(channel, manifest, revision, published_at) VALUES(?,?,?,?)", + ) + .bind(&name) + .bind(&json) + .bind(next_rev) + .bind(now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(serde_json::json!({"ok": true, "revision": next_rev}))) +} + +async fn checkin( + State(s): State, + Path(id): Path, + Json(ci): Json, +) -> Result, ApiError> { + if ci.device_id != id { + return Err(ApiError::BadRequest("device id mismatch".into())); + } + let now = common::now(); + let health = serde_json::to_string(&ci.health)?.trim_matches('"').to_string(); + sqlx::query( + "INSERT INTO devices(id, channel, current_store_path, target_store_path, health, + last_message, agent_version, last_seen) + VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + channel=excluded.channel, + current_store_path=excluded.current_store_path, + target_store_path=excluded.target_store_path, + health=excluded.health, + last_message=excluded.last_message, + agent_version=excluded.agent_version, + last_seen=excluded.last_seen", + ) + .bind(&ci.device_id) + .bind(&ci.channel) + .bind(&ci.current_store_path) + .bind(&ci.target_store_path) + .bind(&health) + .bind(&ci.message) + .bind(&ci.agent_version) + .bind(now) + .execute(&s.db) + .await?; + sqlx::query( + "INSERT INTO device_history(device_id, store_path, health, message, at) VALUES(?,?,?,?,?)", + ) + .bind(&ci.device_id) + .bind(&ci.current_store_path) + .bind(&health) + .bind(&ci.message) + .bind(now) + .execute(&s.db) + .await?; + Ok(Json(common::CheckInAck { server_time: now })) +} + +// ---------- helpers ---------- + +fn require_token(headers: &HeaderMap, expected: &str) -> Result<(), ApiError> { + let v = headers + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + let token = v.strip_prefix("Bearer ").unwrap_or(""); + if token != expected || token.is_empty() { + return Err(ApiError::Unauthorized); + } + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiErrorBody { + pub error: String, +} + +#[derive(Debug)] +pub enum ApiError { + NotFound, + BadRequest(String), + Unauthorized, + Internal(String), +} + +impl From for ApiError { + fn from(e: sqlx::Error) -> Self { Self::Internal(e.to_string()) } +} +impl From for ApiError { + fn from(e: serde_json::Error) -> Self { Self::BadRequest(e.to_string()) } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + let (code, msg) = match self { + ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), + ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()), + ApiError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m), + }; + (code, Json(ApiErrorBody { error: msg })).into_response() + } +} diff --git a/crates/server/src/ui.rs b/crates/server/src/ui.rs new file mode 100644 index 0000000..450141a --- /dev/null +++ b/crates/server/src/ui.rs @@ -0,0 +1,145 @@ +//! Minimal HTMX-flavored dashboard rendered with plain string formatting. +//! +//! Kept dependency-free on purpose; the UI is intentionally tiny so the +//! whole server stays a single static binary with no asset pipeline. + +use crate::{ApiError, AppState}; +use axum::{ + extract::{Path, State}, + response::Html, +}; + +const HEAD: &str = r#" +nix-ota +"#; + +fn short(p: &Option) -> String { + match p { + None => "—".into(), + Some(s) => { + // /nix/store/-name → hash[0..8] + s.strip_prefix("/nix/store/") + .and_then(|r| r.get(..8)) + .unwrap_or(s) + .to_string() + } + } +} + +fn ago(ts: i64) -> String { + if ts == 0 { return "never".into(); } + let d = (nix_ota_common::now() - ts).max(0); + if d < 60 { format!("{d}s ago") } + else if d < 3600 { format!("{}m ago", d/60) } + else if d < 86400 { format!("{}h ago", d/3600) } + else { format!("{}d ago", d/86400) } +} + +pub async fn index(State(s): State) -> Result, ApiError> { + let chans: Vec<(String, i64, i64, Option)> = sqlx::query_as( + "SELECT name, revision, updated_at, current_manifest FROM channels ORDER BY name" + ).fetch_all(&s.db).await?; + let devs: Vec<(String, String, Option, Option, String, i64)> = sqlx::query_as( + "SELECT id, channel, current_store_path, target_store_path, health, last_seen + FROM devices ORDER BY id" + ).fetch_all(&s.db).await?; + + let mut html = String::from(HEAD); + html.push_str("

nix-ota

"); + + html.push_str("

Channels

"); + for (name, rev, updated, mj) in &chans { + let target = mj.as_ref() + .and_then(|j| serde_json::from_str::(j).ok()) + .map(|m| short(&Some(m.body.store_path))) + .unwrap_or_else(|| "—".into()); + html.push_str(&format!( + "", + ago(*updated) + )); + } + html.push_str("
namerevtargetupdated
{name}{rev}{target}{}
"); + + html.push_str("

Devices

"); + for (id, channel, cur, tgt, health, last) in &devs { + html.push_str(&format!( + "", + short(cur), short(tgt), ago(*last) + )); + } + html.push_str("
idchannelcurrenttargethealthlast seen
{id}{channel}{}{}{health}{}
"); + Ok(Html(html)) +} + +pub async fn channel_detail( + State(s): State, + Path(name): Path, +) -> Result, ApiError> { + let hist: Vec<(i64, String, i64)> = sqlx::query_as( + "SELECT revision, manifest, published_at FROM channel_history + WHERE channel = ? ORDER BY revision DESC LIMIT 50" + ).bind(&name).fetch_all(&s.db).await?; + + let mut html = String::from(HEAD); + html.push_str(&format!("

channel: {name}

← back

")); + html.push_str("

History

"); + for (rev, mj, at) in &hist { + if let Ok(m) = serde_json::from_str::(mj) { + html.push_str(&format!( + "", + m.body.store_path, m.body.substituter, ago(*at) + )); + } + } + html.push_str("
revstore pathsubstituterpublished
{rev}{}{}{}
"); + Ok(Html(html)) +} + +pub async fn device_detail( + State(s): State, + Path(id): Path, +) -> Result, ApiError> { + let dev: Option<(String, String, Option, Option, String, Option, Option, i64)> = + sqlx::query_as( + "SELECT id, channel, current_store_path, target_store_path, health, + last_message, agent_version, last_seen + FROM devices WHERE id = ?" + ).bind(&id).fetch_optional(&s.db).await?; + let hist: Vec<(Option, String, Option, i64)> = sqlx::query_as( + "SELECT store_path, health, message, at FROM device_history + WHERE device_id = ? ORDER BY id DESC LIMIT 100" + ).bind(&id).fetch_all(&s.db).await?; + + let mut html = String::from(HEAD); + html.push_str(&format!("

device: {id}

← back

")); + if let Some((_, channel, cur, tgt, health, msg, ver, last)) = &dev { + html.push_str(&format!( + "

channel: {channel}
agent: {}
health: {health}
\ + current: {}
target: {}
last seen: {}
last message: {}

", + ver.as_deref().unwrap_or("?"), + cur.as_deref().unwrap_or("—"), + tgt.as_deref().unwrap_or("—"), + ago(*last), + msg.as_deref().unwrap_or("") + )); + } + html.push_str("

History

"); + for (sp, h, msg, at) in &hist { + html.push_str(&format!( + "", + ago(*at), + sp.as_deref().unwrap_or("—"), + msg.as_deref().unwrap_or("") + )); + } + html.push_str("
athealthstore pathmessage
{}{h}{}{}
"); + Ok(Html(html)) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f6a9c89 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779508470, + "narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "29916453413845e54a65b8a1cf996842300cd299", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..1baedd7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,70 @@ +{ + description = "nix-ota — open-source OTA updates for NixOS fleets"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system nixpkgs.legacyPackages.${system}); + + mkPkg = pkgs: pname: + let + subdir = { + "nix-ota-server" = "server"; + "nix-ota-agent" = "agent"; + "nix-ota-publisher" = "publisher"; + }.${pname}; + in pkgs.rustPlatform.buildRustPackage { + inherit pname; + version = "0.1.0"; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + buildAndTestSubdir = "crates/${subdir}"; + nativeBuildInputs = with pkgs; [ pkg-config ]; + buildInputs = with pkgs; [ openssl ]; + doCheck = false; + meta.license = pkgs.lib.licenses.mit; + }; + in { + packages = forAllSystems (system: pkgs: { + nix-ota-server = mkPkg pkgs "nix-ota-server"; + nix-ota-agent = mkPkg pkgs "nix-ota-agent"; + nix-ota-publisher = mkPkg pkgs "nix-ota-publisher"; + default = self.packages.${system}.nix-ota-server; + }); + + apps = forAllSystems (system: pkgs: { + server = { + type = "app"; + program = "${self.packages.${system}.nix-ota-server}/bin/nix-ota-server"; + }; + agent = { + type = "app"; + program = "${self.packages.${system}.nix-ota-agent}/bin/nix-ota-agent"; + }; + nix-ota = { + type = "app"; + program = "${self.packages.${system}.nix-ota-publisher}/bin/nix-ota"; + }; + default = self.apps.${system}.server; + }); + + nixosModules = { + server = import ./nix/modules/server.nix self; + agent = import ./nix/modules/agent.nix self; + }; + + devShells = forAllSystems (system: pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config openssl sqlite ]; + }; + }); + + checks = forAllSystems (system: pkgs: { + vm = import ./nix/tests/ota.nix { inherit pkgs self system; }; + }); + }; +} diff --git a/nix/modules/agent.nix b/nix/modules/agent.nix new file mode 100644 index 0000000..501ec95 --- /dev/null +++ b/nix/modules/agent.nix @@ -0,0 +1,87 @@ +self: { config, lib, pkgs, ... }: +let + cfg = config.services.nix-ota-agent; + inherit (lib) mkEnableOption mkOption mkIf types; +in { + options.services.nix-ota-agent = { + enable = mkEnableOption "nix-ota device agent"; + package = mkOption { + type = types.package; + default = self.packages.${pkgs.system}.nix-ota-agent; + }; + server = mkOption { type = types.str; example = "https://ota.example.com"; }; + channel = mkOption { type = types.str; default = "prod"; }; + deviceId = mkOption { type = types.str; example = "device-001"; }; + publicKey = mkOption { + type = types.nullOr types.str; + default = null; + description = "Base64-encoded ed25519 verifying key. The agent will reject manifests not signed by the matching private key. Mutually exclusive with publicKeyFile."; + }; + publicKeyFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to a file containing the base64-encoded verifying key. Use this if you need to write the key at runtime (e.g. from an orchestration system)."; + }; + interval = mkOption { type = types.int; default = 60; }; + healthCmd = mkOption { type = types.nullOr types.str; default = null; }; + cacheUrl = mkOption { + type = types.str; + description = "Substituter URL added to nix.settings.substituters so `nix copy` can fetch from it."; + }; + cachePublicKey = mkOption { + type = types.str; + description = "Trusted public key of the binary cache (the one that signs store paths)."; + }; + }; + + config = mkIf cfg.enable { + assertions = [{ + assertion = (cfg.publicKey != null) != (cfg.publicKeyFile != null); + message = "services.nix-ota-agent: set exactly one of publicKey or publicKeyFile."; + }]; + + nix.settings = { + substituters = [ cfg.cacheUrl ]; + trusted-public-keys = [ cfg.cachePublicKey ]; + experimental-features = [ "nix-command" "flakes" ]; + }; + + environment.etc."nix-ota/public.key" = lib.mkIf (cfg.publicKey != null) { + text = cfg.publicKey; + }; + + systemd.services.nix-ota-agent = { + description = "nix-ota device agent (oneshot)"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + StateDirectory = "nix-ota"; + }; + environment = { + NIX_OTA_SERVER = cfg.server; + NIX_OTA_CHANNEL = cfg.channel; + NIX_OTA_DEVICE_ID = cfg.deviceId; + NIX_OTA_PUBLIC_KEY_FILE = if cfg.publicKeyFile != null + then toString cfg.publicKeyFile + else "/etc/nix-ota/public.key"; + NIX_OTA_STATE_DIR = "/var/lib/nix-ota"; + } // lib.optionalAttrs (cfg.healthCmd != null) { + NIX_OTA_HEALTH_CMD = cfg.healthCmd; + }; + script = '' + export PATH=${lib.makeBinPath [ pkgs.nix pkgs.systemd pkgs.coreutils pkgs.bash ]}:$PATH + exec ${cfg.package}/bin/nix-ota-agent --once + ''; + }; + + systemd.timers.nix-ota-agent = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "1min"; + OnUnitActiveSec = "${toString cfg.interval}s"; + Unit = "nix-ota-agent.service"; + }; + }; + }; +} diff --git a/nix/modules/server.nix b/nix/modules/server.nix new file mode 100644 index 0000000..0d2ed93 --- /dev/null +++ b/nix/modules/server.nix @@ -0,0 +1,53 @@ +self: { config, lib, pkgs, ... }: +let + cfg = config.services.nix-ota-server; + inherit (lib) mkEnableOption mkOption mkIf types; +in { + options.services.nix-ota-server = { + enable = mkEnableOption "nix-ota control server"; + package = mkOption { + type = types.package; + default = self.packages.${pkgs.system}.nix-ota-server; + }; + listen = mkOption { type = types.str; default = "0.0.0.0:8080"; }; + dataDir = mkOption { type = types.path; default = "/var/lib/nix-ota-server"; }; + publishTokenFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to a file containing the bearer token for /publish."; + }; + openFirewall = mkOption { type = types.bool; default = false; }; + }; + + config = mkIf cfg.enable { + users.users.nix-ota = { + isSystemUser = true; group = "nix-ota"; home = cfg.dataDir; createHome = true; + }; + users.groups.nix-ota = {}; + + systemd.services.nix-ota-server = { + description = "nix-ota control server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + serviceConfig = { + User = "nix-ota"; + Group = "nix-ota"; + WorkingDirectory = cfg.dataDir; + Restart = "on-failure"; + StateDirectory = "nix-ota-server"; + }; + script = '' + ${lib.optionalString (cfg.publishTokenFile != null) '' + export NIX_OTA_PUBLISH_TOKEN="$(cat ${cfg.publishTokenFile})" + ''} + exec ${cfg.package}/bin/nix-ota-server \ + --listen ${cfg.listen} \ + --db ${cfg.dataDir}/nix-ota.db + ''; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ (lib.toInt (lib.last (lib.splitString ":" cfg.listen))) ]; + }; + }; +} diff --git a/nix/tests/ota.nix b/nix/tests/ota.nix new file mode 100644 index 0000000..2bdd76b --- /dev/null +++ b/nix/tests/ota.nix @@ -0,0 +1,147 @@ +{ pkgs, self, system }: +# NixOS VM test for nix-ota. +# +# Builds three "system closure" stand-ins at evaluation time (each is a +# directory containing a marker file and a `bin/switch-to-configuration` +# stub), then drives the agent through three publishes: +# 1. publish A -> device switches to A +# 2. publish B -> device switches to B +# 3. publish C (broken: agent's healthCmd will fail) -> device rolls back to B +let + mkClosure = label: extraScript: pkgs.runCommand "sys-${label}" {} '' + mkdir -p $out/bin + echo "${label}" > $out/marker + cat > $out/bin/switch-to-configuration <<'EOF' + #!/bin/sh + set -eu + echo "applied ${label}" >&2 + ${extraScript} + exit 0 + EOF + chmod +x $out/bin/switch-to-configuration + ''; + + closureA = mkClosure "a" "touch /run/nix-ota-applied-a"; + closureB = mkClosure "b" "touch /run/nix-ota-applied-b"; + # Closure C activates fine, but the healthCmd checks for /run/nix-ota-broken + # which we create before publishing C, forcing rollback. + closureC = mkClosure "c" "touch /run/nix-ota-applied-c"; + + # Pre-generated binary cache keypair (test fixture; not secret). + # Generated with: nix-store --generate-binary-cache-key cache.local sec pub + cacheKeys = pkgs.runCommand "test-cache-keys" {} '' + mkdir -p $out + export HOME=$TMPDIR + export NIX_STATE_DIR=$TMPDIR/state + export NIX_STORE_DIR=$TMPDIR/store + mkdir -p $NIX_STATE_DIR $NIX_STORE_DIR + ${pkgs.nix}/bin/nix-store --generate-binary-cache-key cache.local $out/secret $out/public + ''; + + pubBin = "${self.packages.${system}.nix-ota-publisher}/bin/nix-ota"; +in +pkgs.testers.runNixOSTest { + name = "nix-ota"; + nodes = { + server = { config, pkgs, lib, ... }: { + imports = [ self.nixosModules.server ]; + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + services.nix-ota-server = { + enable = true; + listen = "0.0.0.0:8080"; + openFirewall = true; + publishTokenFile = pkgs.writeText "tok" "test-token"; + }; + services.nix-serve = { + enable = true; + port = 5000; + secretKeyFile = "${cacheKeys}/secret"; + }; + networking.firewall.allowedTCPPorts = [ 5000 ]; + # The closures need to be in the server's store so nix-serve can serve them. + system.extraDependencies = [ closureA closureB closureC ]; + }; + + device = { config, pkgs, lib, ... }: { + imports = [ self.nixosModules.agent ]; + services.nix-ota-agent = { + enable = true; + server = "http://server:8080"; + channel = "prod"; + deviceId = "vm-device-1"; + publicKeyFile = "/var/lib/nix-ota/public.key"; + cacheUrl = "http://server:5000"; + cachePublicKey = builtins.readFile "${cacheKeys}/public"; + interval = 5; + healthCmd = "test ! -f /run/nix-ota-broken"; + }; + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + nix.settings.trusted-users = [ "root" ]; + }; + }; + + testScript = '' + closureA = "${closureA}" + closureB = "${closureB}" + closureC = "${closureC}" + pubBin = "${pubBin}" + + start_all() + server.wait_for_unit("nix-ota-server.service") + server.wait_for_open_port(8080) + server.wait_for_unit("nix-serve.service") + server.wait_for_open_port(5000) + # Drive the agent ourselves; disable the timer for deterministic stepping. + device.succeed("systemctl stop nix-ota-agent.timer || true") + + # Sign the closures with the binary cache key so the device's Nix will accept them. + for c in [closureA, closureB, closureC]: + server.succeed(f"nix store sign --extra-experimental-features nix-command --key-file ${cacheKeys}/secret --recursive {c}") + + # Operator generates a manifest signing key on the server host. + server.succeed("mkdir -p /root/keys") + pub = server.succeed(f"{pubBin} keygen --out /root/keys/sign.key").strip() + + # Push pubkey onto the device's writable state dir. + device.succeed("mkdir -p /var/lib/nix-ota") + device.succeed(f"echo '{pub}' > /var/lib/nix-ota/public.key") + + def publish(store_path, rev): + server.succeed( + f"{pubBin} publish " + f"--server http://localhost:8080 --token test-token " + f"--key /root/keys/sign.key --channel prod " + f"--store-path {store_path} --substituter http://server:5000 --revision {rev}" + ) + + def poll_agent(): + # oneshot service: start and wait for it to finish (success or failure). + device.succeed("systemctl start --wait nix-ota-agent.service || true") + + # --- Step 1: publish A + publish(closureA, 1) + poll_agent() + device.succeed(f"readlink -f /nix/var/nix/profiles/system | grep -qF {closureA}") + device.succeed("test -f /run/nix-ota-applied-a") + + # --- Step 2: publish B + publish(closureB, 2) + poll_agent() + device.succeed(f"readlink -f /nix/var/nix/profiles/system | grep -qF {closureB}") + device.succeed("test -f /run/nix-ota-applied-b") + + # --- Step 3: publish C with health check rigged to fail + device.succeed("touch /run/nix-ota-broken") + publish(closureC, 3) + poll_agent() + # Agent should have applied C, failed the health check, and rolled back to B. + device.succeed(f"readlink -f /nix/var/nix/profiles/system | grep -qF {closureB}") + # The activation script for C did run before health check. + device.succeed("test -f /run/nix-ota-applied-c") + + # The dashboard should reflect the rolled_back state. + server.wait_until_succeeds( + "curl -fsS http://localhost:8080/ | grep -Eq 'rolled_back|failed'", timeout=30 + ) + ''; +}