From ea96477830816c070d44dd77407e0e84322b71fe Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 11 May 2026 12:08:49 +0300 Subject: [PATCH] treewide: rewrite everything in Rust Signed-off-by: NotAShelf Change-Id: I786da853078e1013bb8f463ed9e9869c6a6a6964 --- .envrc | 1 - .gitignore | 1 + .rustfmt.toml | 26 + Cargo.lock | 3331 +++++++++++++++++++++++++++ Cargo.toml | 130 ++ README.md | 6 +- cmd/ncro/main.go | 5 - cmd/ncro/root.go | 256 -- config.example.yaml | 8 + go.mod | 51 - go.sum | 153 -- internal/cache/db.go | 313 --- internal/cache/db_test.go | 324 --- internal/config/config.go | 220 -- internal/config/config_test.go | 213 -- internal/discovery/discovery.go | 218 -- internal/mesh/gossip.go | 152 -- internal/mesh/gossip_test.go | 117 - internal/mesh/mesh.go | 152 -- internal/mesh/mesh_test.go | 75 - internal/metrics/metrics.go | 61 - internal/narinfo/narinfo.go | 139 -- internal/narinfo/narinfo_test.go | 318 --- internal/prober/prober.go | 267 --- internal/prober/prober_test.go | 188 -- internal/router/router.go | 258 --- internal/router/router_test.go | 251 -- internal/server/integration_test.go | 100 - internal/server/server.go | 252 -- internal/server/server_test.go | 487 ---- nix/package.nix | 16 +- nix/shell.nix | 29 +- src/cli.rs | 191 ++ src/config.rs | 336 +++ src/db.rs | 394 ++++ src/discovery.rs | 74 + src/health.rs | 310 +++ src/main.rs | 15 + src/mesh.rs | 223 ++ src/metrics.rs | 109 + src/narinfo.rs | 207 ++ src/router.rs | 309 +++ src/server.rs | 301 +++ 43 files changed, 5993 insertions(+), 4594 deletions(-) create mode 100644 .rustfmt.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 cmd/ncro/main.go delete mode 100644 cmd/ncro/root.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/cache/db.go delete mode 100644 internal/cache/db_test.go delete mode 100644 internal/config/config.go delete mode 100644 internal/config/config_test.go delete mode 100644 internal/discovery/discovery.go delete mode 100644 internal/mesh/gossip.go delete mode 100644 internal/mesh/gossip_test.go delete mode 100644 internal/mesh/mesh.go delete mode 100644 internal/mesh/mesh_test.go delete mode 100644 internal/metrics/metrics.go delete mode 100644 internal/narinfo/narinfo.go delete mode 100644 internal/narinfo/narinfo_test.go delete mode 100644 internal/prober/prober.go delete mode 100644 internal/prober/prober_test.go delete mode 100644 internal/router/router.go delete mode 100644 internal/router/router_test.go delete mode 100644 internal/server/integration_test.go delete mode 100644 internal/server/server.go delete mode 100644 internal/server/server_test.go create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/db.rs create mode 100644 src/discovery.rs create mode 100644 src/health.rs create mode 100644 src/main.rs create mode 100644 src/mesh.rs create mode 100644 src/metrics.rs create mode 100644 src/narinfo.rs create mode 100644 src/router.rs create mode 100644 src/server.rs diff --git a/.envrc b/.envrc index df0bb43..3550a30 100644 --- a/.envrc +++ b/.envrc @@ -1,2 +1 @@ use flake -export CGO_ENABLED=0 diff --git a/.gitignore b/.gitignore index 62db839..4d5b25a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ # Build output /ncro +/target diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..324bf8b --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,26 @@ +condense_wildcard_suffixes = true +doc_comment_code_block_width = 80 +edition = "2024" # Keep in sync with Cargo.toml. +enum_discrim_align_threshold = 60 +force_explicit_abi = false +force_multiline_blocks = true +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +group_imports = "StdExternalCrate" +hex_literal_case = "Upper" +imports_granularity = "Crate" +imports_layout = "HorizontalVertical" +inline_attribute_width = 60 +match_block_trailing_comma = true +max_width = 80 +newline_style = "Unix" +normalize_comments = true +normalize_doc_attributes = true +overflow_delimited_expr = true +struct_field_align_threshold = 60 +tab_spaces = 2 +unstable_features = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9b01050 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3331 @@ +# 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 = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[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 = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[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 = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[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 = "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.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +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 = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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-macro", + "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 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[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 = "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 = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[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 = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.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 = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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 = "if-addrs" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf39cc0423ee66021dc5eccface85580e4a001e0c5288bae8bea7ecb69225e90" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[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", + "serde", + "serde_core", +] + +[[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.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +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 = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[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 = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[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.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[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 = "mdns-sd" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1856c9bb96761020572ccc972887318aafca947a441e7ee52cd2facb8f9af3be" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2", +] + +[[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 = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ncro" +version = "1.0.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64", + "bytes", + "chrono", + "clap", + "ed25519-dalek", + "futures-util", + "hex", + "http", + "http-body-util", + "humantime-serde", + "mdns-sd", + "prometheus", + "rand 0.8.6", + "reqwest", + "rmp-serde", + "serde", + "serde_json", + "serde_yaml", + "sqlx", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", +] + +[[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-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 = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "protobuf", + "thiserror 2.0.18", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[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-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", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "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 = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[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 = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +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 = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "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 = "socket-pktinfo" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +dependencies = [ + "libc", + "socket2", + "windows-sys 0.60.2", +] + +[[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", + "chrono", + "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", + "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", + "chrono", + "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", + "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", + "chrono", + "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", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "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", + "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 = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "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", + "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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "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-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[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 = "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 = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "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 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[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.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +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..fe438c5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,130 @@ +[package] +name = "ncro" +version = "1.0.0" +edition = "2024" +license = "MIT" + +[dependencies] +anyhow = "1.0.102" +async-trait = "0.1.89" +axum = { version = "0.8.9", features = ["macros"] } +base64 = "0.22.1" +bytes = "1.11.1" +clap = { version = "4.6.1", features = ["derive", "env"] } +chrono = { version = "0.4.44", features = ["serde"] } +ed25519-dalek = { version = "2.2.0", features = ["rand_core"] } +futures-util = "0.3.32" +hex = "0.4.3" +http = "1.4.0" +http-body-util = "0.1.3" +humantime-serde = "1.1.1" +mdns-sd = "0.15.2" +prometheus = "0.14.0" +rand = "0.8.6" +reqwest = { version = "0.12.28", default-features = false, features = [ + "rustls-tls", + "stream", +] } +rmp-serde = "1.3.1" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +serde_yaml = "0.9.34" +sqlx = { version = "0.8.6", default-features = false, features = [ + "runtime-tokio-rustls", + "sqlite", + "macros", + "migrate", + "chrono", +] } +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = [ + "macros", + "rt-multi-thread", + "signal", + "time", + "net", + "fs", +] } +tokio-util = { version = "0.7.18", features = ["io"] } +tower-http = { version = "0.6.10", features = ["trace"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } +url = "2.5.8" + +[dev-dependencies] +tempfile = "3.27.0" +tower = { version = "0.5.3", features = ["util"] } + +# See: +# +[lints.clippy] +cargo = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } + +# The lint groups above enable some less-than-desirable rules, we should manually +# enable those to keep our sanity. +absolute_paths = "allow" +arbitrary_source_item_ordering = "allow" +clone_on_ref_ptr = "warn" +dbg_macro = "warn" +empty_drop = "warn" +empty_structs_with_brackets = "warn" +exit = "warn" +filetype_is_file = "warn" +get_unwrap = "warn" +implicit_return = "allow" +infinite_loop = "warn" +map_with_unused_argument_over_ranges = "warn" +missing_docs_in_private_items = "allow" +multiple_crate_versions = "allow" # :( +non_ascii_literal = "allow" +non_std_lazy_statics = "warn" +pathbuf_init_then_push = "warn" +pattern_type_mismatch = "allow" +question_mark_used = "allow" +rc_buffer = "warn" +rc_mutex = "warn" +rest_pat_in_fully_bound_structs = "warn" +similar_names = "allow" +single_call_fn = "allow" +std_instead_of_core = "allow" +too_long_first_doc_paragraph = "allow" +too_many_lines = "allow" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +unused_result_ok = "warn" +unused_trait_names = "allow" + +# False positive: +# clippy's build script check doesn't recognize workspace-inherited metadata +# which means in our current workspace layout, we get pranked by Clippy. +cargo_common_metadata = "allow" + +# In the honor of a recent Cloudflare regression +panic = "deny" +unwrap_used = "deny" + +# Less dangerous, but we'd like to know +# Those must be opt-in, and are fine ONLY in tests and examples. We *can* panic +# in NDG (the binary crate), but it should be very deliberate +expect_used = "warn" +print_stderr = "warn" +print_stdout = "warn" +todo = "warn" +unimplemented = "warn" +unreachable = "warn" + +[profile.dev] +debug = true +opt-level = 0 + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "z" +panic = "abort" +strip = "symbols" diff --git a/README.md b/README.md index 97b88de..b0ceaf4 100644 --- a/README.md +++ b/README.md @@ -195,10 +195,10 @@ Prometheus metrics are available at `/metrics`. # With Nix (recommended) $ nix build -# With Go directly -$ go build ./cmd/ncro/ +# With Cargo directly +$ cargo build --release # Development shell $ nix develop -$ go test ./... +$ cargo test ``` diff --git a/cmd/ncro/main.go b/cmd/ncro/main.go deleted file mode 100644 index 736ef31..0000000 --- a/cmd/ncro/main.go +++ /dev/null @@ -1,5 +0,0 @@ -package main - -func main() { - Execute() -} diff --git a/cmd/ncro/root.go b/cmd/ncro/root.go deleted file mode 100644 index ef5827e..0000000 --- a/cmd/ncro/root.go +++ /dev/null @@ -1,256 +0,0 @@ -package main - -import ( - "context" - "crypto/ed25519" - "encoding/hex" - "errors" - "fmt" - "log/slog" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/prometheus/client_golang/prometheus" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "notashelf.dev/ncro/internal/cache" - "notashelf.dev/ncro/internal/config" - "notashelf.dev/ncro/internal/discovery" - "notashelf.dev/ncro/internal/mesh" - "notashelf.dev/ncro/internal/metrics" - "notashelf.dev/ncro/internal/prober" - "notashelf.dev/ncro/internal/router" - "notashelf.dev/ncro/internal/server" -) - -// Injected at build time via -ldflags "-X main.version=". -var version = "dev" - -// Execute is the entrypoint called by main. -func Execute() { - if err := newRootCmd().Execute(); err != nil { - os.Exit(1) - } -} - -func newRootCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "ncro", - Short: "Nix Cache Route Optimizer", - Version: version, - SilenceUsage: true, - RunE: runServer, - } - - cmd.Flags().StringP("config", "c", "", "path to config YAML file (env: NCRO_CONFIG)") - _ = viper.BindPFlag("config", cmd.Flags().Lookup("config")) - viper.SetEnvPrefix("NCRO") - viper.AutomaticEnv() - - return cmd -} - -func runServer(_ *cobra.Command, _ []string) error { - cfg, err := config.Load(viper.GetString("config")) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - if err := cfg.Validate(); err != nil { - return fmt.Errorf("invalid config: %w", err) - } - - level := slog.LevelInfo - switch cfg.Logging.Level { - case "debug": - level = slog.LevelDebug - case "warn": - level = slog.LevelWarn - case "error": - level = slog.LevelError - } - var handler slog.Handler - if cfg.Logging.Format == "text" { - handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}) - } else { - handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}) - } - slog.SetDefault(slog.New(handler)) - - metrics.Register(prometheus.DefaultRegisterer) - - db, err := cache.Open(cfg.Cache.DBPath, cfg.Cache.MaxEntries) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer db.Close() - - expireDone := make(chan struct{}) - go func() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - for { - select { - case <-expireDone: - return - case <-ticker.C: - if err := db.ExpireOldRoutes(); err != nil { - slog.Warn("expire routes error", "error", err) - } - if err := db.ExpireNegatives(); err != nil { - slog.Warn("expire negatives error", "error", err) - } - if count, err := db.RouteCount(); err == nil { - metrics.RouteEntries.Set(float64(count)) - } - } - } - }() - - p := prober.New(cfg.Cache.LatencyAlpha) - p.InitUpstreams(cfg.Upstreams) - - if rows, err := db.LoadAllHealth(); err == nil { - for _, row := range rows { - p.Seed(row.URL, row.EMALatency, row.ConsecutiveFails, int64(row.TotalQueries)) - } - } else { - slog.Warn("failed to load persisted health data", "error", err) - } - - p.SetHealthPersistence(func(url string, ema float64, cf uint32, tq uint64) { - if err := db.SaveHealth(url, ema, int(cf), int64(tq)); err != nil { - slog.Warn("failed to save health", "url", url, "error", err) - } - }) - - for _, u := range cfg.Upstreams { - go p.ProbeUpstream(u.URL) - } - - probeDone := make(chan struct{}) - go p.RunProbeLoop(30*time.Second, probeDone) - - // Setup mDNS discovery if enabled - var discoveryMgr *discovery.Discovery - if cfg.Discovery.Enabled { - discoveryMgr, err = discovery.New(cfg.Discovery) - if err != nil { - return fmt.Errorf("create discovery manager: %w", err) - } - discoveryMgr.SetCallbacks( - func(url string, priority int) { - slog.Info("adding discovered upstream", "url", url) - p.AddUpstream(url, priority) - }, - func(url string) { - slog.Info("removing discovered upstream", "url", url) - p.RemoveUpstream(url) - }, - ) - slog.Info("mDNS discovery enabled", "service", cfg.Discovery.ServiceName) - } - - r := router.New(db, p, cfg.Cache.TTL.Duration, 5*time.Second, cfg.Cache.NegativeTTL.Duration) - for _, u := range cfg.Upstreams { - if u.PublicKey != "" { - if err := r.SetUpstreamKey(u.URL, u.PublicKey); err != nil { - return fmt.Errorf("invalid upstream public key for %s: %w", u.URL, err) - } - slog.Info("narinfo signature verification enabled", "upstream", u.URL) - } - } - - var gossipDone chan struct{} - if cfg.Mesh.Enabled { - store := mesh.NewRouteStore() - node, err := mesh.NewNode(cfg.Mesh.PrivateKeyPath, store) - if err != nil { - return fmt.Errorf("create mesh node: %w", err) - } - slog.Info("mesh node identity", "node_id", node.ID(), - "public_key", hex.EncodeToString(node.PublicKey())) - - allowedKeys := make([]ed25519.PublicKey, 0, len(cfg.Mesh.Peers)) - for _, peer := range cfg.Mesh.Peers { - if peer.PublicKey != "" { - b, _ := hex.DecodeString(peer.PublicKey) - allowedKeys = append(allowedKeys, ed25519.PublicKey(b)) - } - } - - if err := mesh.ListenAndServe(cfg.Mesh.BindAddr, store, allowedKeys...); err != nil { - return fmt.Errorf("start mesh listener: %w", err) - } - - peerAddrs := make([]string, len(cfg.Mesh.Peers)) - for i, p := range cfg.Mesh.Peers { - peerAddrs[i] = p.Addr - } - - gossipDone = make(chan struct{}) - go mesh.RunGossipLoop(node, db, peerAddrs, cfg.Mesh.GossipInterval.Duration, gossipDone) - slog.Info("mesh enabled", "addr", cfg.Mesh.BindAddr, "peers", len(cfg.Mesh.Peers)) - } - - // Start mDNS discovery in background - discoveryDone := make(chan struct{}) - var discoveryCancel context.CancelFunc - if discoveryMgr != nil { - var ctx context.Context - ctx, discoveryCancel = context.WithCancel(context.Background()) - go func() { - if err := discoveryMgr.Start(ctx); err != nil { - slog.Error("discovery error", "error", err) - } - }() - go func() { - <-discoveryDone - discoveryCancel() - discoveryMgr.Stop() - }() - } - - srv := &http.Server{ - Addr: cfg.Server.Listen, - Handler: server.New(r, p, db, cfg.Upstreams, cfg.Server.CachePriority), - ReadTimeout: cfg.Server.ReadTimeout.Duration, - WriteTimeout: cfg.Server.WriteTimeout.Duration, - } - - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - - serverErr := make(chan error, 1) - go func() { - slog.Info("ncro listening", "addr", cfg.Server.Listen, - "upstreams", len(cfg.Upstreams), "version", version) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - serverErr <- err - } - close(serverErr) - }() - - select { - case <-stop: - slog.Info("shutting down") - case err := <-serverErr: - return fmt.Errorf("server: %w", err) - } - - close(expireDone) - close(probeDone) - if gossipDone != nil { - close(gossipDone) - } - close(discoveryDone) - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := srv.Shutdown(ctx); err != nil { - slog.Warn("shutdown error", "error", err) - } - return nil -} diff --git a/config.example.yaml b/config.example.yaml index 7664f40..1752b51 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -16,8 +16,16 @@ cache: db_path: "/var/lib/ncro/routes.db" max_entries: 100000 ttl: 1h + negative_ttl: 10m latency_alpha: 0.3 +discovery: + enabled: false + service_name: "_nix-serve._tcp" + domain: "local" + discovery_time: 5s + priority: 20 + mesh: enabled: false bind_addr: "0.0.0.0:7946" diff --git a/go.mod b/go.mod deleted file mode 100644 index 145f52b..0000000 --- a/go.mod +++ /dev/null @@ -1,51 +0,0 @@ -module notashelf.dev/ncro - -go 1.25.7 - -require ( - github.com/grandcat/zeroconf v1.0.0 - github.com/prometheus/client_golang v1.23.2 - github.com/spf13/cobra v1.10.2 - github.com/spf13/viper v1.21.0 - github.com/vmihailenco/msgpack/v5 v5.4.1 - golang.org/x/sync v0.20.0 - gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.50.0 -) - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff v2.2.1+incompatible // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect - github.com/miekg/dns v1.1.72 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/pelletier/go-toml/v2 v2.3.1 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.20.1 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - go.yaml.in/yaml/v2 v2.4.4 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.45.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect - modernc.org/libc v1.72.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 22948cf..0000000 --- a/go.sum +++ /dev/null @@ -1,153 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= -github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= -github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= -github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= -github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= -github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= -github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= -github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= -github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= -modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= -modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= -modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/cache/db.go b/internal/cache/db.go deleted file mode 100644 index 3932bbc..0000000 --- a/internal/cache/db.go +++ /dev/null @@ -1,313 +0,0 @@ -package cache - -import ( - "database/sql" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - _ "modernc.org/sqlite" -) - -// Core routing decision persisted per store path. -type RouteEntry struct { - StorePath string - UpstreamURL string - LatencyMs float64 - LatencyEMA float64 - LastVerified time.Time - QueryCount uint32 - FailureCount uint32 - TTL time.Time - NarHash string - NarSize uint64 - NarURL string // narinfo URL field, e.g. "nar/1wwh37...nar.xz" -} - -// Returns true if the entry exists and hasn't expired. -func (r *RouteEntry) IsValid() bool { - return r != nil && time.Now().Before(r.TTL) -} - -// SQLite-backed store for route persistence. -type DB struct { - db *sql.DB - maxEntries int -} - -// Opens or creates the SQLite database at path with WAL mode. -// Creates parent directories as needed (unless path is ":memory:"). -func Open(path string, maxEntries int) (*DB, error) { - if path != ":memory:" { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return nil, fmt.Errorf("create db dir: %w", err) - } - } - db, err := sql.Open("sqlite", path+"?_journal=WAL&_busy_timeout=5000") - if err != nil { - return nil, fmt.Errorf("open sqlite: %w", err) - } - db.SetMaxOpenConns(1) // SQLite WAL allows 1 writer - - if err := migrate(db); err != nil { - db.Close() - return nil, fmt.Errorf("migrate: %w", err) - } - - return &DB{db: db, maxEntries: maxEntries}, nil -} - -// Closes the database. -func (d *DB) Close() error { - return d.db.Close() -} - -func migrate(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS routes ( - store_path TEXT PRIMARY KEY, - upstream_url TEXT NOT NULL, - latency_ms REAL DEFAULT 0, - latency_ema REAL DEFAULT 0, - query_count INTEGER DEFAULT 1, - failure_count INTEGER DEFAULT 0, - last_verified INTEGER DEFAULT 0, - ttl INTEGER NOT NULL, - nar_hash TEXT DEFAULT '', - nar_size INTEGER DEFAULT 0, - created_at INTEGER DEFAULT (strftime('%s', 'now')) - ); - CREATE INDEX IF NOT EXISTS idx_routes_ttl ON routes(ttl); - CREATE INDEX IF NOT EXISTS idx_routes_last_verified ON routes(last_verified); - - CREATE TABLE IF NOT EXISTS upstream_health ( - url TEXT PRIMARY KEY, - ema_latency REAL DEFAULT 0, - last_probe INTEGER DEFAULT 0, - consecutive_fails INTEGER DEFAULT 0, - total_queries INTEGER DEFAULT 0, - success_rate REAL DEFAULT 1.0 - ); - CREATE TABLE IF NOT EXISTS negative_cache ( - store_path TEXT PRIMARY KEY, - expires_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_negative_expires ON negative_cache(expires_at); - `) - if err != nil { - return err - } - // Add nar_url column if it does not exist yet (ALTER TABLE does not support - // IF NOT EXISTS in SQLite, so we ignore the "duplicate column" error). - if _, err := db.Exec(`ALTER TABLE routes ADD COLUMN nar_url TEXT DEFAULT ''`); err != nil { - if !isDuplicateColumn(err) { - return err - } - } - _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_routes_nar_url ON routes(nar_url)`) - return err -} - -// Returns true when err is a SQLite "duplicate column name" error produced by -// ALTER TABLE ADD COLUMN on a column that already exists. -func isDuplicateColumn(err error) bool { - return err != nil && strings.Contains(err.Error(), "duplicate column name") -} - -// Returns the route for storePath, or nil if not found. -func (d *DB) GetRoute(storePath string) (*RouteEntry, error) { - row := d.db.QueryRow(` - SELECT store_path, upstream_url, latency_ms, latency_ema, - query_count, failure_count, last_verified, ttl, nar_hash, nar_size, nar_url - FROM routes WHERE store_path = ?`, storePath) - - var e RouteEntry - var lastVerifiedUnix, ttlUnix int64 - err := row.Scan( - &e.StorePath, &e.UpstreamURL, &e.LatencyMs, &e.LatencyEMA, - &e.QueryCount, &e.FailureCount, &lastVerifiedUnix, &ttlUnix, - &e.NarHash, &e.NarSize, &e.NarURL, - ) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - e.LastVerified = time.Unix(lastVerifiedUnix, 0).UTC() - e.TTL = time.Unix(ttlUnix, 0).UTC() - return &e, nil -} - -// Returns the route whose narinfo URL matches narURL, or nil if not found / expired. -func (d *DB) GetRouteByNarURL(narURL string) (*RouteEntry, error) { - row := d.db.QueryRow(` - SELECT store_path, upstream_url, latency_ms, latency_ema, - query_count, failure_count, last_verified, ttl, nar_hash, nar_size, nar_url - FROM routes WHERE nar_url = ? AND ttl > ?`, narURL, time.Now().Unix()) - - var e RouteEntry - var lastVerifiedUnix, ttlUnix int64 - err := row.Scan( - &e.StorePath, &e.UpstreamURL, &e.LatencyMs, &e.LatencyEMA, - &e.QueryCount, &e.FailureCount, &lastVerifiedUnix, &ttlUnix, - &e.NarHash, &e.NarSize, &e.NarURL, - ) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - e.LastVerified = time.Unix(lastVerifiedUnix, 0).UTC() - e.TTL = time.Unix(ttlUnix, 0).UTC() - return &e, nil -} - -// Inserts or updates a route entry. -func (d *DB) SetRoute(entry *RouteEntry) error { - _, err := d.db.Exec(` - INSERT INTO routes - (store_path, upstream_url, latency_ms, latency_ema, - query_count, failure_count, last_verified, ttl, nar_hash, nar_size, nar_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(store_path) DO UPDATE SET - upstream_url = excluded.upstream_url, - latency_ms = excluded.latency_ms, - latency_ema = excluded.latency_ema, - query_count = excluded.query_count, - failure_count = excluded.failure_count, - last_verified = excluded.last_verified, - ttl = excluded.ttl, - nar_hash = excluded.nar_hash, - nar_size = excluded.nar_size, - nar_url = excluded.nar_url`, - entry.StorePath, entry.UpstreamURL, - entry.LatencyMs, entry.LatencyEMA, - entry.QueryCount, entry.FailureCount, - entry.LastVerified.Unix(), entry.TTL.Unix(), - entry.NarHash, entry.NarSize, entry.NarURL, - ) - if err != nil { - return err - } - return d.evictIfNeeded() -} - -// Deletes routes whose TTL has passed. -func (d *DB) ExpireOldRoutes() error { - _, err := d.db.Exec(`DELETE FROM routes WHERE ttl < ?`, time.Now().Unix()) - return err -} - -// Returns up to n non-expired routes ordered by most-recently-verified. -func (d *DB) ListRecentRoutes(n int) ([]RouteEntry, error) { - rows, err := d.db.Query(` - SELECT store_path, upstream_url, latency_ema, last_verified, ttl, nar_hash, nar_size, nar_url - FROM routes WHERE ttl > ? ORDER BY last_verified DESC LIMIT ?`, - time.Now().Unix(), n) - if err != nil { - return nil, err - } - defer rows.Close() - var result []RouteEntry - for rows.Next() { - var e RouteEntry - var lastVerifiedUnix, ttlUnix int64 - if err := rows.Scan( - &e.StorePath, &e.UpstreamURL, &e.LatencyEMA, - &lastVerifiedUnix, &ttlUnix, &e.NarHash, &e.NarSize, &e.NarURL, - ); err != nil { - return nil, err - } - e.LastVerified = time.Unix(lastVerifiedUnix, 0).UTC() - e.TTL = time.Unix(ttlUnix, 0).UTC() - result = append(result, e) - } - return result, rows.Err() -} - -// Returns the total number of stored routes. -func (d *DB) RouteCount() (int, error) { - var count int - err := d.db.QueryRow(`SELECT COUNT(*) FROM routes`).Scan(&count) - return count, err -} - -// Records a negative cache entry for storePath with the given TTL. -func (d *DB) SetNegative(storePath string, ttl time.Duration) error { - _, err := d.db.Exec( - `INSERT INTO negative_cache (store_path, expires_at) VALUES (?, ?) - ON CONFLICT(store_path) DO UPDATE SET expires_at = excluded.expires_at`, - storePath, time.Now().Add(ttl).Unix(), - ) - return err -} - -// Returns true if a non-expired negative entry exists for storePath. -func (d *DB) IsNegative(storePath string) (bool, error) { - var exists bool - err := d.db.QueryRow( - `SELECT EXISTS(SELECT 1 FROM negative_cache WHERE store_path = ? AND expires_at > ?)`, - storePath, time.Now().Unix(), - ).Scan(&exists) - return exists, err -} - -// Deletes expired negative cache entries. -func (d *DB) ExpireNegatives() error { - _, err := d.db.Exec(`DELETE FROM negative_cache WHERE expires_at < ?`, time.Now().Unix()) - return err -} - -// Persisted snapshot of one upstream's health metrics. -type HealthRow struct { - URL string - EMALatency float64 - ConsecutiveFails int - TotalQueries int64 -} - -// Upserts the health metrics for the given upstream URL. -func (d *DB) SaveHealth(url string, ema float64, consecutiveFails int, totalQueries int64) error { - _, err := d.db.Exec(` - INSERT INTO upstream_health (url, ema_latency, consecutive_fails, total_queries) - VALUES (?, ?, ?, ?) - ON CONFLICT(url) DO UPDATE SET - ema_latency = excluded.ema_latency, - consecutive_fails = excluded.consecutive_fails, - total_queries = excluded.total_queries`, - url, ema, consecutiveFails, totalQueries, - ) - return err -} - -// Returns all rows from the upstream_health table. -func (d *DB) LoadAllHealth() ([]HealthRow, error) { - rows, err := d.db.Query(`SELECT url, ema_latency, consecutive_fails, total_queries FROM upstream_health`) - if err != nil { - return nil, err - } - defer rows.Close() - var result []HealthRow - for rows.Next() { - var r HealthRow - if err := rows.Scan(&r.URL, &r.EMALatency, &r.ConsecutiveFails, &r.TotalQueries); err != nil { - return nil, err - } - result = append(result, r) - } - return result, rows.Err() -} - -// Deletes the oldest routes (by last_verified) when over capacity. -func (d *DB) evictIfNeeded() error { - _, err := d.db.Exec(` - DELETE FROM routes WHERE store_path IN ( - SELECT store_path FROM routes ORDER BY last_verified ASC - LIMIT MAX(0, (SELECT COUNT(*) FROM routes) - ?) - )`, d.maxEntries) - return err -} diff --git a/internal/cache/db_test.go b/internal/cache/db_test.go deleted file mode 100644 index b74dbab..0000000 --- a/internal/cache/db_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package cache_test - -import ( - "os" - "testing" - "time" - - "notashelf.dev/ncro/internal/cache" -) - -func newTestDB(t *testing.T) *cache.DB { - t.Helper() - f, err := os.CreateTemp("", "ncro-test-*.db") - if err != nil { - t.Fatal(err) - } - f.Close() - t.Cleanup(func() { os.Remove(f.Name()) }) - - db, err := cache.Open(f.Name(), 1000) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - return db -} - -func TestGetSetRoute(t *testing.T) { - db := newTestDB(t) - - entry := &cache.RouteEntry{ - StorePath: "abc123xyz-hello-2.12", - UpstreamURL: "https://cache.nixos.org", - LatencyMs: 12.5, - LatencyEMA: 12.5, - LastVerified: time.Now().UTC().Truncate(time.Second), - QueryCount: 1, - TTL: time.Now().Add(time.Hour).UTC().Truncate(time.Second), - } - - if err := db.SetRoute(entry); err != nil { - t.Fatalf("SetRoute: %v", err) - } - - got, err := db.GetRoute("abc123xyz-hello-2.12") - if err != nil { - t.Fatalf("GetRoute: %v", err) - } - if got == nil { - t.Fatal("GetRoute returned nil") - } - if got.UpstreamURL != entry.UpstreamURL { - t.Errorf("upstream = %q, want %q", got.UpstreamURL, entry.UpstreamURL) - } - if got.QueryCount != 1 { - t.Errorf("query_count = %d, want 1", got.QueryCount) - } -} - -func TestGetRouteNotFound(t *testing.T) { - db := newTestDB(t) - got, err := db.GetRoute("nonexistent") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != nil { - t.Errorf("expected nil, got %+v", got) - } -} - -func TestSetRouteUpsert(t *testing.T) { - db := newTestDB(t) - - entry := &cache.RouteEntry{ - StorePath: "abc123-pkg", - UpstreamURL: "https://cache.nixos.org", - LatencyMs: 20.0, - LatencyEMA: 20.0, - QueryCount: 1, - TTL: time.Now().Add(time.Hour), - } - db.SetRoute(entry) - - entry.LatencyEMA = 18.0 - entry.QueryCount = 2 - if err := db.SetRoute(entry); err != nil { - t.Fatalf("upsert: %v", err) - } - - got, _ := db.GetRoute("abc123-pkg") - if got.LatencyEMA != 18.0 { - t.Errorf("ema = %f, want 18.0", got.LatencyEMA) - } - if got.QueryCount != 2 { - t.Errorf("query_count = %d, want 2", got.QueryCount) - } -} - -func TestExpireOldRoutes(t *testing.T) { - db := newTestDB(t) - - // Insert expired route - expired := &cache.RouteEntry{ - StorePath: "expired-pkg", - UpstreamURL: "https://cache.nixos.org", - TTL: time.Now().Add(-time.Minute), // already expired - } - db.SetRoute(expired) - - // Insert valid route - valid := &cache.RouteEntry{ - StorePath: "valid-pkg", - UpstreamURL: "https://cache.nixos.org", - TTL: time.Now().Add(time.Hour), - } - db.SetRoute(valid) - - if err := db.ExpireOldRoutes(); err != nil { - t.Fatalf("ExpireOldRoutes: %v", err) - } - - got, _ := db.GetRoute("expired-pkg") - if got != nil { - t.Error("expired route should have been deleted") - } - got2, _ := db.GetRoute("valid-pkg") - if got2 == nil { - t.Error("valid route should still exist") - } -} - -func TestRouteEntryIsValidExpired(t *testing.T) { - expired := &cache.RouteEntry{TTL: time.Now().Add(-time.Minute)} - if expired.IsValid() { - t.Error("expired entry should not be valid") - } -} - -func TestRouteEntryIsValidFuture(t *testing.T) { - valid := &cache.RouteEntry{TTL: time.Now().Add(time.Hour)} - if !valid.IsValid() { - t.Error("future-TTL entry should be valid") - } -} - -func TestDBOpenCreatesSchema(t *testing.T) { - db := newTestDB(t) - // RouteCount works only if schema was created. - count, err := db.RouteCount() - if err != nil { - t.Fatalf("RouteCount after fresh open: %v", err) - } - if count != 0 { - t.Errorf("expected 0 routes in fresh DB, got %d", count) - } -} - -func TestRouteCountAfterExpiry(t *testing.T) { - db := newTestDB(t) - - for i := range 3 { - ttl := time.Now().Add(-time.Minute) // all expired - db.SetRoute(&cache.RouteEntry{ - StorePath: "pkg-" + string(rune('a'+i)), - UpstreamURL: "https://cache.nixos.org", - TTL: ttl, - }) - } - - before, _ := db.RouteCount() - if err := db.ExpireOldRoutes(); err != nil { - t.Fatal(err) - } - after, _ := db.RouteCount() - if after >= before { - t.Errorf("count did not decrease after expiry: before=%d after=%d", before, after) - } - if after != 0 { - t.Errorf("expected 0 routes after expiring all, got %d", after) - } -} - -func TestNegativeCacheSetAndCheck(t *testing.T) { - db, err := cache.Open(":memory:", 100) - if err != nil { - t.Fatal(err) - } - defer db.Close() - - neg, err := db.IsNegative("missing-path") - if err != nil { - t.Fatalf("IsNegative: %v", err) - } - if neg { - t.Error("expected false for unknown path") - } - - if err := db.SetNegative("missing-path", 10*time.Minute); err != nil { - t.Fatalf("SetNegative: %v", err) - } - - neg, err = db.IsNegative("missing-path") - if err != nil { - t.Fatalf("IsNegative after set: %v", err) - } - if !neg { - t.Error("expected true after SetNegative") - } -} - -func TestNegativeCacheExpiry(t *testing.T) { - db, err := cache.Open(":memory:", 100) - if err != nil { - t.Fatal(err) - } - defer db.Close() - - // Set with negative duration so it's already expired. - if err := db.SetNegative("expires-now", -time.Second); err != nil { - t.Fatalf("SetNegative: %v", err) - } - - // IsNegative must filter expired entries via the inline SQL predicate, - // even before ExpireNegatives cleans them up. - neg, err := db.IsNegative("expires-now") - if err != nil { - t.Fatalf("IsNegative for expired entry: %v", err) - } - if neg { - t.Error("IsNegative should return false for an already-expired entry (SQL time predicate)") - } - - // Janitor cleanup should also work. - if err := db.ExpireNegatives(); err != nil { - t.Fatalf("ExpireNegatives: %v", err) - } - neg, _ = db.IsNegative("expires-now") - if neg { - t.Error("expired negative should not be returned after ExpireNegatives") - } -} - -func TestGetRouteByNarURL(t *testing.T) { - db, err := cache.Open(":memory:", 100) - if err != nil { - t.Fatal(err) - } - defer db.Close() - - entry := &cache.RouteEntry{ - StorePath: "abc123", - UpstreamURL: "https://cache.nixos.org", - NarURL: "nar/abc123.nar.xz", - TTL: time.Now().Add(time.Hour), - } - if err := db.SetRoute(entry); err != nil { - t.Fatalf("SetRoute: %v", err) - } - - got, err := db.GetRouteByNarURL("nar/abc123.nar.xz") - if err != nil { - t.Fatalf("GetRouteByNarURL: %v", err) - } - if got == nil { - t.Fatal("expected non-nil entry") - } - if got.UpstreamURL != "https://cache.nixos.org" { - t.Errorf("UpstreamURL = %q", got.UpstreamURL) - } - - // Non-existent NarURL returns nil. - got2, err := db.GetRouteByNarURL("nar/nonexistent.nar.xz") - if err != nil { - t.Fatalf("GetRouteByNarURL for missing: %v", err) - } - if got2 != nil { - t.Error("expected nil for missing NarURL") - } - - // Expired entry must not be returned (tests the AND ttl > ? predicate). - expired := &cache.RouteEntry{ - StorePath: "abc456", - UpstreamURL: "https://cache.nixos.org", - NarURL: "nar/abc456.nar.xz", - TTL: time.Now().Add(-time.Hour), // already in the past - } - if err := db.SetRoute(expired); err != nil { - t.Fatalf("SetRoute expired: %v", err) - } - got3, err := db.GetRouteByNarURL("nar/abc456.nar.xz") - if err != nil { - t.Fatalf("GetRouteByNarURL for expired: %v", err) - } - if got3 != nil { - t.Error("GetRouteByNarURL should return nil for an expired entry") - } -} - -func TestLRUEviction(t *testing.T) { - // Use maxEntries=3 to trigger eviction easily - f, _ := os.CreateTemp("", "ncro-lru-*.db") - f.Close() - defer os.Remove(f.Name()) - - db, _ := cache.Open(f.Name(), 3) - defer db.Close() - - for i := range 4 { - db.SetRoute(&cache.RouteEntry{ - StorePath: "pkg-" + string(rune('a'+i)), - UpstreamURL: "https://cache.nixos.org", - LastVerified: time.Now().Add(time.Duration(i) * time.Second), - TTL: time.Now().Add(time.Hour), - }) - } - - count, err := db.RouteCount() - if err != nil { - t.Fatal(err) - } - if count > 3 { - t.Errorf("expected count <= 3 after LRU eviction, got %d", count) - } -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index dd20ee1..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,220 +0,0 @@ -package config - -import ( - "encoding/hex" - "fmt" - "net/url" - "os" - "strings" - "time" - - "gopkg.in/yaml.v3" -) - -// Wrapper around time.Duration supporting YAML duration strings ("30s", "1h"). -// yaml.v3 cannot unmarshal duration strings directly into time.Duration (int64). -type Duration struct { - time.Duration -} - -func (d *Duration) UnmarshalYAML(value *yaml.Node) error { - var s string - if err := value.Decode(&s); err != nil { - // Try decoding as a raw int64 (nanoseconds) as fallback. - var ns int64 - if err2 := value.Decode(&ns); err2 != nil { - return fmt.Errorf("cannot unmarshal duration (tried string: %v): %w", err, err2) - } - d.Duration = time.Duration(ns) - return nil - } - parsed, err := time.ParseDuration(s) - if err != nil { - return fmt.Errorf("invalid duration %q: %w", s, err) - } - d.Duration = parsed - return nil -} - -type UpstreamConfig struct { - URL string `yaml:"url"` - Priority int `yaml:"priority"` - PublicKey string `yaml:"public_key"` // Nix signing key "name:base64(key)" -} - -type ServerConfig struct { - Listen string `yaml:"listen"` - ReadTimeout Duration `yaml:"read_timeout"` - WriteTimeout Duration `yaml:"write_timeout"` - CachePriority int `yaml:"cache_priority"` -} - -type CacheConfig struct { - DBPath string `yaml:"db_path"` - MaxEntries int `yaml:"max_entries"` - TTL Duration `yaml:"ttl"` - NegativeTTL Duration `yaml:"negative_ttl"` - LatencyAlpha float64 `yaml:"latency_alpha"` -} - -// Mesh peer with its ed25519 public key for gossip message verification. -type PeerConfig struct { - Addr string `yaml:"addr"` - PublicKey string `yaml:"public_key"` // hex-encoded ed25519 public key (32 bytes) -} - -type MeshConfig struct { - Enabled bool `yaml:"enabled"` - BindAddr string `yaml:"bind_addr"` - Peers []PeerConfig `yaml:"peers"` - PrivateKeyPath string `yaml:"private_key"` - GossipInterval Duration `yaml:"gossip_interval"` -} - -// Controls mDNS/DNS-SD based dynamic upstream discovery. -type DiscoveryConfig struct { - Enabled bool `yaml:"enabled"` - ServiceName string `yaml:"service_name"` - Domain string `yaml:"domain"` - DiscoveryTime Duration `yaml:"discovery_time"` - Priority int `yaml:"priority"` -} - -type LoggingConfig struct { - Level string `yaml:"level"` - Format string `yaml:"format"` -} - -type Config struct { - Server ServerConfig `yaml:"server"` - Upstreams []UpstreamConfig `yaml:"upstreams"` - Cache CacheConfig `yaml:"cache"` - Mesh MeshConfig `yaml:"mesh"` - Discovery DiscoveryConfig `yaml:"discovery"` - Logging LoggingConfig `yaml:"logging"` -} - -func defaults() Config { - return Config{ - Server: ServerConfig{ - Listen: ":8080", - ReadTimeout: Duration{30 * time.Second}, - WriteTimeout: Duration{30 * time.Second}, - CachePriority: 30, - }, - Upstreams: []UpstreamConfig{ - {URL: "https://cache.nixos.org", Priority: 10}, - }, - Cache: CacheConfig{ - DBPath: "/var/lib/ncro/routes.db", - MaxEntries: 100000, - TTL: Duration{time.Hour}, - NegativeTTL: Duration{10 * time.Minute}, - LatencyAlpha: 0.3, - }, - Mesh: MeshConfig{ - BindAddr: "0.0.0.0:7946", - GossipInterval: Duration{30 * time.Second}, - }, - Discovery: DiscoveryConfig{ - ServiceName: "_nix-serve._tcp", - Domain: "local", - DiscoveryTime: Duration{5 * time.Second}, - Priority: 20, - }, - Logging: LoggingConfig{ - Level: "info", - Format: "json", - }, - } -} - -// Validates config fields. Call after Load. -func (c *Config) Validate() error { - if len(c.Upstreams) == 0 { - return fmt.Errorf("at least one upstream is required") - } - for i, u := range c.Upstreams { - if u.URL == "" { - return fmt.Errorf("upstream[%d]: URL is empty", i) - } - if _, err := url.ParseRequestURI(u.URL); err != nil { - return fmt.Errorf("upstream[%d]: invalid URL %q: %w", i, u.URL, err) - } - if u.PublicKey != "" && !strings.Contains(u.PublicKey, ":") { - return fmt.Errorf("upstream[%d]: public_key must be in 'name:base64(key)' Nix format", i) - } - } - if c.Server.Listen == "" { - return fmt.Errorf("server.listen is empty") - } - if c.Server.CachePriority < 1 { - return fmt.Errorf("server.cache_priority must be >= 1, got %d", c.Server.CachePriority) - } - if c.Cache.LatencyAlpha <= 0 || c.Cache.LatencyAlpha >= 1 { - return fmt.Errorf("cache.latency_alpha must be between 0 and 1 exclusive, got %f", c.Cache.LatencyAlpha) - } - if c.Cache.TTL.Duration <= 0 { - return fmt.Errorf("cache.ttl must be positive") - } - if c.Cache.NegativeTTL.Duration <= 0 { - return fmt.Errorf("cache.negative_ttl must be positive") - } - if c.Cache.MaxEntries <= 0 { - return fmt.Errorf("cache.max_entries must be positive") - } - if c.Mesh.Enabled && len(c.Mesh.Peers) == 0 { - return fmt.Errorf("mesh.enabled is true but no peers configured") - } - for i, peer := range c.Mesh.Peers { - if peer.Addr == "" { - return fmt.Errorf("mesh.peers[%d]: addr is empty", i) - } - if peer.PublicKey != "" { - b, err := hex.DecodeString(peer.PublicKey) - if err != nil || len(b) != 32 { - return fmt.Errorf("mesh.peers[%d]: public_key must be a hex-encoded 32-byte ed25519 key", i) - } - } - } - if c.Discovery.Enabled { - if c.Discovery.ServiceName == "" { - return fmt.Errorf("discovery.service_name is required when discovery is enabled") - } - if c.Discovery.Domain == "" { - return fmt.Errorf("discovery.domain is required when discovery is enabled") - } - if c.Discovery.DiscoveryTime.Duration <= 0 { - return fmt.Errorf("discovery.discovery_time must be positive") - } - } - return nil -} - -// Loads config from file (if non-empty) and applies env overrides. -func Load(path string) (*Config, error) { - cfg := defaults() - - if path != "" { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, err - } - } - - // Env overrides - if v := os.Getenv("NCRO_LISTEN"); v != "" { - cfg.Server.Listen = v - } - if v := os.Getenv("NCRO_DB_PATH"); v != "" { - cfg.Cache.DBPath = v - } - if v := os.Getenv("NCRO_LOG_LEVEL"); v != "" { - cfg.Logging.Level = v - } - - return &cfg, nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index b71801e..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package config_test - -import ( - "os" - "testing" - "time" - - "notashelf.dev/ncro/internal/config" -) - -func TestLoadDefaults(t *testing.T) { - cfg, err := config.Load("") - if err != nil { - t.Fatalf("Load(\"\") error: %v", err) - } - if cfg.Server.Listen != ":8080" { - t.Errorf("default listen = %q, want :8080", cfg.Server.Listen) - } - if len(cfg.Upstreams) == 0 { - t.Error("expected at least one default upstream") - } - if cfg.Cache.MaxEntries != 100000 { - t.Errorf("default max_entries = %d, want 100000", cfg.Cache.MaxEntries) - } -} - -func TestLoadFromYAML(t *testing.T) { - yamlContent := ` -server: - listen: ":9090" -upstreams: - - url: "https://cache.nixos.org" - priority: 10 -cache: - db_path: "/tmp/test.db" - max_entries: 500 -` - f, _ := os.CreateTemp("", "ncro-*.yaml") - defer os.Remove(f.Name()) - f.WriteString(yamlContent) - f.Close() - - cfg, err := config.Load(f.Name()) - if err != nil { - t.Fatalf("Load error: %v", err) - } - if cfg.Server.Listen != ":9090" { - t.Errorf("listen = %q, want :9090", cfg.Server.Listen) - } - if cfg.Cache.MaxEntries != 500 { - t.Errorf("max_entries = %d, want 500", cfg.Cache.MaxEntries) - } -} - -func TestEnvOverride(t *testing.T) { - t.Setenv("NCRO_LISTEN", ":1234") - cfg, err := config.Load("") - if err != nil { - t.Fatalf("Load error: %v", err) - } - if cfg.Server.Listen != ":1234" { - t.Errorf("env override listen = %q, want :1234", cfg.Server.Listen) - } -} - -func TestDurationParsing(t *testing.T) { - yamlContent := ` -server: - listen: ":8080" - read_timeout: 30s - write_timeout: 1m -cache: - ttl: 2h -mesh: - gossip_interval: 45s -` - f, _ := os.CreateTemp("", "ncro-dur-*.yaml") - defer os.Remove(f.Name()) - f.WriteString(yamlContent) - f.Close() - - cfg, err := config.Load(f.Name()) - if err != nil { - t.Fatalf("Load error: %v", err) - } - if cfg.Server.ReadTimeout.Duration != 30*time.Second { - t.Errorf("read_timeout = %v, want 30s", cfg.Server.ReadTimeout.Duration) - } - if cfg.Server.WriteTimeout.Duration != time.Minute { - t.Errorf("write_timeout = %v, want 1m", cfg.Server.WriteTimeout.Duration) - } - if cfg.Cache.TTL.Duration != 2*time.Hour { - t.Errorf("ttl = %v, want 2h", cfg.Cache.TTL.Duration) - } - if cfg.Mesh.GossipInterval.Duration != 45*time.Second { - t.Errorf("gossip_interval = %v, want 45s", cfg.Mesh.GossipInterval.Duration) - } -} - -func TestValidateValid(t *testing.T) { - cfg, _ := config.Load("") - if err := cfg.Validate(); err != nil { - t.Errorf("default config should be valid: %v", err) - } -} - -func TestValidateNoUpstreams(t *testing.T) { - cfg, _ := config.Load("") - cfg.Upstreams = nil - if err := cfg.Validate(); err == nil { - t.Error("expected error for no upstreams") - } -} - -func TestValidateBadURL(t *testing.T) { - cfg, _ := config.Load("") - cfg.Upstreams = []config.UpstreamConfig{{URL: "not-a-url"}} - if err := cfg.Validate(); err == nil { - t.Error("expected error for invalid URL") - } -} - -func TestValidateBadAlpha(t *testing.T) { - cfg, _ := config.Load("") - cfg.Cache.LatencyAlpha = 0 - if err := cfg.Validate(); err == nil { - t.Error("expected error for alpha=0") - } - cfg.Cache.LatencyAlpha = 1 - if err := cfg.Validate(); err == nil { - t.Error("expected error for alpha=1") - } -} - -func TestValidateZeroTTL(t *testing.T) { - cfg, _ := config.Load("") - cfg.Cache.TTL = config.Duration{} - if err := cfg.Validate(); err == nil { - t.Error("expected error for zero TTL") - } -} - -func TestValidateNegativeTTL(t *testing.T) { - cfg, _ := config.Load("") - cfg.Cache.NegativeTTL = config.Duration{} - if err := cfg.Validate(); err == nil { - t.Error("expected error for zero negative_ttl") - } -} - -func TestValidateMeshEnabledNoPeers(t *testing.T) { - cfg, _ := config.Load("") - cfg.Mesh.Enabled = true - cfg.Mesh.Peers = nil - if err := cfg.Validate(); err == nil { - t.Error("expected error for mesh enabled without peers") - } -} - -func TestValidateMeshBadPeerKey(t *testing.T) { - cfg, _ := config.Load("") - cfg.Mesh.Enabled = true - cfg.Mesh.Peers = []config.PeerConfig{ - {Addr: "127.0.0.1:7946", PublicKey: "not-hex!"}, - } - if err := cfg.Validate(); err == nil { - t.Error("expected error for invalid mesh peer public key") - } -} - -func TestValidateUpstreamBadPublicKey(t *testing.T) { - cfg, _ := config.Load("") - cfg.Upstreams = []config.UpstreamConfig{ - {URL: "https://cache.nixos.org", PublicKey: "no-colon-here"}, - } - if err := cfg.Validate(); err == nil { - t.Error("expected error for upstream public_key missing ':'") - } -} - -func TestCachePriorityDefault(t *testing.T) { - cfg, err := config.Load("") - if err != nil { - t.Fatal(err) - } - if cfg.Server.CachePriority != 30 { - t.Errorf("default CachePriority = %d, want 30", cfg.Server.CachePriority) - } -} - -func TestCachePriorityValidation(t *testing.T) { - cfg, _ := config.Load("") - cfg.Server.CachePriority = 0 - if err := cfg.Validate(); err == nil { - t.Error("expected error for CachePriority = 0") - } -} - -func TestInvalidDuration(t *testing.T) { - yamlContent := ` -server: - read_timeout: "bananas" -` - f, _ := os.CreateTemp("", "ncro-bad-*.yaml") - defer os.Remove(f.Name()) - f.WriteString(yamlContent) - f.Close() - - _, err := config.Load(f.Name()) - if err == nil { - t.Error("expected error for invalid duration string, got nil") - } -} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go deleted file mode 100644 index 3755cca..0000000 --- a/internal/discovery/discovery.go +++ /dev/null @@ -1,218 +0,0 @@ -package discovery - -import ( - "context" - "fmt" - "log/slog" - "net" - "sync" - "time" - - "github.com/grandcat/zeroconf" - "notashelf.dev/ncro/internal/config" -) - -// Tracks discovered nix-serve instances and maintains the upstream list. -type Discovery struct { - cfg config.DiscoveryConfig - resolver *zeroconf.Resolver - discovered map[string]*discoveredPeer - mu sync.RWMutex - stopCh chan struct{} - stopOnce sync.Once - waitGroup sync.WaitGroup - onAddUpstream func(url string, priority int) - onRemoveUpstream func(url string) -} - -type discoveredPeer struct { - url string - lastSeen time.Time -} - -// Creates a new Discovery manager. -func New(cfg config.DiscoveryConfig) (*Discovery, error) { - resolver, err := zeroconf.NewResolver(nil) - if err != nil { - return nil, fmt.Errorf("create zeroconf resolver: %w", err) - } - - return &Discovery{ - cfg: cfg, - resolver: resolver, - discovered: make(map[string]*discoveredPeer), - stopCh: make(chan struct{}), - }, nil -} - -// Sets callbacks for upstream addition/removal. These are invoked when peers -// are discovered or leave the network. -func (d *Discovery) SetCallbacks( - add func(url string, priority int), - remove func(url string), -) { - d.mu.Lock() - defer d.mu.Unlock() - d.onAddUpstream = add - d.onRemoveUpstream = remove -} - -// Starts browsing for services on the local network. Blocks until the context -// is cancelled or Stop is called. -func (d *Discovery) Start(ctx context.Context) error { - entries := make(chan *zeroconf.ServiceEntry) - - d.waitGroup.Add(1) - go d.handleEntries(ctx, entries) - - d.waitGroup.Add(1) - go d.maintainPeers(ctx) - - if err := d.resolver.Browse(ctx, d.cfg.ServiceName, d.cfg.Domain, entries); err != nil { - close(entries) - d.stopOnce.Do(func() { close(d.stopCh) }) - d.waitGroup.Wait() - return fmt.Errorf("browse services: %w", err) - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-d.stopCh: - return nil - } -} - -// Stops the discovery process. -func (d *Discovery) Stop() { - d.stopOnce.Do(func() { close(d.stopCh) }) - d.waitGroup.Wait() -} - -// Processes discovered service entries. -func (d *Discovery) handleEntries(ctx context.Context, entries chan *zeroconf.ServiceEntry) { - defer d.waitGroup.Done() - - for { - select { - case <-ctx.Done(): - return - case <-d.stopCh: - return - case entry, ok := <-entries: - if !ok { - return - } - d.handleEntry(ctx, entry) - } - } -} - -// Handles a single service entry. -func (d *Discovery) handleEntry(_ context.Context, entry *zeroconf.ServiceEntry) { - if len(entry.AddrIPv4) == 0 && len(entry.AddrIPv6) == 0 { - slog.Debug("discovered service has no addresses", "instance", entry.Instance) - return - } - - var addr string - if len(entry.AddrIPv4) > 0 { - addr = entry.AddrIPv4[0].String() - } else { - addr = entry.AddrIPv6[0].String() - } - - url := "http://" + net.JoinHostPort(addr, fmt.Sprintf("%d", entry.Port)) - key := fmt.Sprintf("%s@%s", entry.Instance, entry.HostName) - - d.mu.Lock() - defer d.mu.Unlock() - - // Check if we already know this peer - if _, exists := d.discovered[key]; exists { - d.discovered[key].lastSeen = time.Now() - return - } - - // New peer discovered - slog.Info("discovered nix-serve instance", "instance", entry.Instance, "url", url) - - d.discovered[key] = &discoveredPeer{ - url: url, - lastSeen: time.Now(), - } - - // Notify callback if set - if d.onAddUpstream != nil { - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("panic in add upstream callback", "recover", r) - } - }() - d.onAddUpstream(url, d.cfg.Priority) - }() - } -} - -// Removes peers that haven't been seen within the TTL period. -func (d *Discovery) maintainPeers(ctx context.Context) { - defer d.waitGroup.Done() - - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-d.stopCh: - return - case <-ticker.C: - d.cleanupPeers() - } - } -} - -// Cleans up stale peer entries. -func (d *Discovery) cleanupPeers() { - d.mu.Lock() - defer d.mu.Unlock() - - now := time.Now() - // TTL is the discovery response time; peers should re-announce periodically. - // Use 3x TTL as the expiration window. - expiration := d.cfg.DiscoveryTime.Duration * 3 - if expiration == 0 { - expiration = 30 * time.Second - } - - for key, peer := range d.discovered { - if now.Sub(peer.lastSeen) > expiration { - slog.Info("removing stale peer", "url", peer.url) - delete(d.discovered, key) - if d.onRemoveUpstream != nil { - go func(url string) { - defer func() { - if r := recover(); r != nil { - slog.Error("panic in remove upstream callback", "recover", r) - } - }() - d.onRemoveUpstream(url) - }(peer.url) - } - } - } -} - -// Returns a list of currently discovered peer URLs. -func (d *Discovery) DiscoveredPeers() []string { - d.mu.RLock() - defer d.mu.RUnlock() - - peers := make([]string, 0, len(d.discovered)) - for _, peer := range d.discovered { - peers = append(peers, peer.url) - } - return peers -} diff --git a/internal/mesh/gossip.go b/internal/mesh/gossip.go deleted file mode 100644 index a5f10f6..0000000 --- a/internal/mesh/gossip.go +++ /dev/null @@ -1,152 +0,0 @@ -package mesh - -import ( - "bytes" - "crypto/ed25519" - "fmt" - "log/slog" - "net" - "time" - - "github.com/vmihailenco/msgpack/v5" - "notashelf.dev/ncro/internal/cache" -) - -const ( - maxPacketSize = 65536 // UDP max payload - headerSize = ed25519.PublicKeySize + ed25519.SignatureSize // 32 + 64 = 96 -) - -// Wire format: [32-byte sender pubkey][64-byte ed25519 sig][msgpack body] - -func encodePacket(node *Node, msg Message) ([]byte, error) { - body, sig, err := node.Sign(msg) - if err != nil { - return nil, err - } - pkt := make([]byte, headerSize+len(body)) - copy(pkt[:ed25519.PublicKeySize], node.PublicKey()) - copy(pkt[ed25519.PublicKeySize:headerSize], sig) - copy(pkt[headerSize:], body) - return pkt, nil -} - -func decodePacket(pkt []byte) (pubKey ed25519.PublicKey, sig, body []byte, msg Message, err error) { - if len(pkt) < headerSize { - return nil, nil, nil, Message{}, fmt.Errorf("packet too short: %d bytes", len(pkt)) - } - pubKey = ed25519.PublicKey(pkt[:ed25519.PublicKeySize]) - sig = pkt[ed25519.PublicKeySize:headerSize] - body = pkt[headerSize:] - if err := msgpack.Unmarshal(body, &msg); err != nil { - return nil, nil, nil, Message{}, fmt.Errorf("unmarshal: %w", err) - } - return pubKey, sig, body, msg, nil -} - -// Starts a UDP listener at addr. All messages are signature-verified. -// When allowedKeys is non-empty, messages from unlisted senders are dropped. -// Pass no keys (or an empty list) to accept messages from any sender. -func ListenAndServe(addr string, store *RouteStore, allowedKeys ...ed25519.PublicKey) error { - conn, err := net.ListenPacket("udp", addr) - if err != nil { - return err - } - go func() { - defer conn.Close() - buf := make([]byte, maxPacketSize) - for { - n, src, err := conn.ReadFrom(buf) - if err != nil { - return - } - pubKey, sig, body, msg, err := decodePacket(buf[:n]) - if err != nil { - slog.Warn("mesh: malformed packet", "src", src, "error", err) - continue - } - if len(allowedKeys) > 0 { - allowed := false - for _, k := range allowedKeys { - if bytes.Equal(k, pubKey) { - allowed = true - break - } - } - if !allowed { - slog.Warn("mesh: rejecting packet from unknown sender", "src", src) - continue - } - } - if err := Verify(pubKey, body, sig); err != nil { - slog.Warn("mesh: signature verification failed", "src", src, "error", err) - continue - } - if msg.Type == MsgAnnounce && len(msg.Routes) > 0 { - store.Merge(msg.Routes) - slog.Debug("mesh: merged peer routes", "node", msg.NodeID, "src", src, "count", len(msg.Routes)) - } - } - }() - return nil -} - -// Sends an MsgAnnounce carrying routes to a single peer address. -func Announce(peerAddr string, node *Node, routes []cache.RouteEntry) error { - msg := Message{ - Type: MsgAnnounce, - NodeID: node.ID(), - Timestamp: time.Now().UnixNano(), - Routes: routes, - } - pkt, err := encodePacket(node, msg) - if err != nil { - return err - } - addr, err := net.ResolveUDPAddr("udp", peerAddr) - if err != nil { - return err - } - conn, err := net.DialUDP("udp", nil, addr) - if err != nil { - return err - } - defer conn.Close() - conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) - _, err = conn.Write(pkt) - return err -} - -// RouteSource retrieves routes to gossip. -type RouteSource interface { - ListRecentRoutes(n int) ([]cache.RouteEntry, error) -} - -// Announces our top routes to each peer on interval. Blocks until stop is closed. -func RunGossipLoop(node *Node, src RouteSource, peers []string, interval time.Duration, stop <-chan struct{}) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-stop: - return - case <-ticker.C: - routes, err := src.ListRecentRoutes(100) - if err != nil { - slog.Warn("mesh: failed to list routes for gossip", "error", err) - continue - } - if len(routes) == 0 { - continue - } - for _, peer := range peers { - go func(p string) { - if err := Announce(p, node, routes); err != nil { - slog.Warn("mesh: announce failed", "peer", p, "error", err) - } - }(peer) - } - slog.Debug("mesh: announced routes to peers", "routes", len(routes), "peers", len(peers)) - } - } -} diff --git a/internal/mesh/gossip_test.go b/internal/mesh/gossip_test.go deleted file mode 100644 index fcb82bc..0000000 --- a/internal/mesh/gossip_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package mesh_test - -import ( - "net" - "testing" - "time" - - "notashelf.dev/ncro/internal/cache" - "notashelf.dev/ncro/internal/mesh" -) - -func freeUDPAddr(t *testing.T) string { - t.Helper() - conn, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - addr := conn.LocalAddr().String() - conn.Close() - return addr -} - -func TestAnnounceAndReceive(t *testing.T) { - store := mesh.NewRouteStore() - node, err := mesh.NewNode("", store) - if err != nil { - t.Fatal(err) - } - - addr := freeUDPAddr(t) - // Allow messages from our own node (its public key is the only allowed key). - if err := mesh.ListenAndServe(addr, store, node.PublicKey()); err != nil { - t.Fatalf("ListenAndServe: %v", err) - } - - routes := []cache.RouteEntry{ - { - StorePath: "test-pkg-abc", - UpstreamURL: "https://cache.nixos.org", - LatencyEMA: 25, - TTL: time.Now().Add(time.Hour), - }, - } - - if err := mesh.Announce(addr, node, routes); err != nil { - t.Fatalf("Announce: %v", err) - } - - time.Sleep(50 * time.Millisecond) - - entry := store.Get("test-pkg-abc") - if entry == nil { - t.Fatal("route not merged into store after announce") - } - if entry.UpstreamURL != "https://cache.nixos.org" { - t.Errorf("UpstreamURL = %q", entry.UpstreamURL) - } -} - -func TestRejectUnknownSender(t *testing.T) { - store := mesh.NewRouteStore() - - // Listener node, this'll reject messages not from trusted - trusted, err := mesh.NewNode("", nil) - if err != nil { - t.Fatal(err) - } - // Untrusted sender - untrusted, err := mesh.NewNode("", nil) - if err != nil { - t.Fatal(err) - } - - addr := freeUDPAddr(t) - // Only allow trusted node's key. - if err := mesh.ListenAndServe(addr, store, trusted.PublicKey()); err != nil { - t.Fatalf("ListenAndServe: %v", err) - } - - routes := []cache.RouteEntry{ - {StorePath: "untrusted-pkg", UpstreamURL: "https://evil.example.com", - TTL: time.Now().Add(time.Hour)}, - } - mesh.Announce(addr, untrusted, routes) - time.Sleep(50 * time.Millisecond) - - if entry := store.Get("untrusted-pkg"); entry != nil { - t.Error("route from untrusted sender should have been rejected") - } -} - -func TestRejectTamperedMessage(t *testing.T) { - // This is covered by TestVerifyFailsOnTamper the mesh tests on the crypto level. - // Here we verify the full pipeline rejects a re-signed-but-tampered body. - store := mesh.NewRouteStore() - node, err := mesh.NewNode("", store) - if err != nil { - t.Fatal(err) - } - - addr := freeUDPAddr(t) - if err := mesh.ListenAndServe(addr, store, node.PublicKey()); err != nil { - t.Fatalf("ListenAndServe: %v", err) - } - - // Send a valid message first to confirm it works. - routes := []cache.RouteEntry{ - {StorePath: "legit-pkg", UpstreamURL: "https://cache.nixos.org", - TTL: time.Now().Add(time.Hour)}, - } - mesh.Announce(addr, node, routes) - time.Sleep(50 * time.Millisecond) - - if store.Get("legit-pkg") == nil { - t.Fatal("valid message should have been accepted") - } -} diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go deleted file mode 100644 index ee3e04f..0000000 --- a/internal/mesh/mesh.go +++ /dev/null @@ -1,152 +0,0 @@ -package mesh - -import ( - "crypto/ed25519" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "os" - "sync" - "time" - - "github.com/vmihailenco/msgpack/v5" - "notashelf.dev/ncro/internal/cache" -) - -// Gossip message types. -type MsgType uint8 - -const ( - MsgAnnounce MsgType = 1 -) - -// Wire format for gossip messages. -type Message struct { - Type MsgType - NodeID string - Timestamp int64 - Routes []cache.RouteEntry -} - -// Cryptographic identity of an ncro node. -type Node struct { - pubKey ed25519.PublicKey - privKey ed25519.PrivateKey - store *RouteStore -} - -// Loads or generates an ed25519 keypair from keyPath. -// Pass "" for an ephemeral in-memory key. -func NewNode(keyPath string, store *RouteStore) (*Node, error) { - if store == nil { - store = NewRouteStore() - } - if keyPath == "" { - pub, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return nil, fmt.Errorf("generate key: %w", err) - } - return &Node{pubKey: pub, privKey: priv, store: store}, nil - } - data, err := os.ReadFile(keyPath) - if err == nil && len(data) == ed25519.PrivateKeySize { - priv := ed25519.PrivateKey(data) - return &Node{pubKey: priv.Public().(ed25519.PublicKey), privKey: priv, store: store}, nil - } - pub, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return nil, fmt.Errorf("generate key: %w", err) - } - if err := os.WriteFile(keyPath, priv, 0600); err != nil { - return nil, fmt.Errorf("write key: %w", err) - } - return &Node{pubKey: pub, privKey: priv, store: store}, nil -} - -// Returns the hex-encoded public key fingerprint. -func (n *Node) ID() string { - return hex.EncodeToString(n.pubKey[:8]) -} - -// Returns the node's public key. -func (n *Node) PublicKey() ed25519.PublicKey { - return n.pubKey -} - -// Serializes msg with msgpack and signs it; returns (data, signature, error). -func (n *Node) Sign(msg Message) ([]byte, []byte, error) { - data, err := msgpack.Marshal(msg) - if err != nil { - return nil, nil, err - } - return data, ed25519.Sign(n.privKey, data), nil -} - -// Checks that sig is a valid ed25519 signature of data under pubKey. -func Verify(pubKey ed25519.PublicKey, data, sig []byte) error { - if !ed25519.Verify(pubKey, data, sig) { - return errors.New("invalid signature") - } - return nil -} - -// In-memory route table with merge-conflict resolution for gossip. -type RouteStore struct { - mu sync.RWMutex - routes map[string]*cache.RouteEntry -} - -// Creates an empty RouteStore. -func NewRouteStore() *RouteStore { - return &RouteStore{routes: make(map[string]*cache.RouteEntry)} -} - -// Applies incoming routes: lower latency wins; newer LastVerified wins on tie. -func (rs *RouteStore) Merge(incoming []cache.RouteEntry) { - rs.mu.Lock() - defer rs.mu.Unlock() - now := time.Now() - for _, r := range incoming { - r := r - if r.TTL.Before(now) { - continue - } - existing, ok := rs.routes[r.StorePath] - if !ok { - rs.routes[r.StorePath] = &r - continue - } - if r.LatencyEMA < existing.LatencyEMA { - rs.routes[r.StorePath] = &r - } else if r.LatencyEMA == existing.LatencyEMA && r.LastVerified.After(existing.LastVerified) { - rs.routes[r.StorePath] = &r - } - } -} - -// Returns a copy of the stored route, or nil. -func (rs *RouteStore) Get(storePath string) *cache.RouteEntry { - rs.mu.RLock() - defer rs.mu.RUnlock() - e, ok := rs.routes[storePath] - if !ok { - return nil - } - cp := *e - return &cp -} - -// Returns up to n routes for sync batching. -func (rs *RouteStore) Top(n int) []cache.RouteEntry { - rs.mu.RLock() - defer rs.mu.RUnlock() - result := make([]cache.RouteEntry, 0, min(n, len(rs.routes))) - for _, e := range rs.routes { - result = append(result, *e) - if len(result) >= n { - break - } - } - return result -} diff --git a/internal/mesh/mesh_test.go b/internal/mesh/mesh_test.go deleted file mode 100644 index f6fdf2b..0000000 --- a/internal/mesh/mesh_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package mesh_test - -import ( - "testing" - "time" - - "notashelf.dev/ncro/internal/cache" - "notashelf.dev/ncro/internal/mesh" -) - -func TestSignVerify(t *testing.T) { - node, err := mesh.NewNode("", nil) - if err != nil { - t.Fatal(err) - } - - msg := mesh.Message{ - Type: mesh.MsgAnnounce, - NodeID: node.ID(), - Timestamp: time.Now().UnixNano(), - Routes: []cache.RouteEntry{{StorePath: "abc123", UpstreamURL: "https://cache.nixos.org"}}, - } - - data, sig, err := node.Sign(msg) - if err != nil { - t.Fatalf("Sign: %v", err) - } - if err := mesh.Verify(node.PublicKey(), data, sig); err != nil { - t.Errorf("Verify: %v", err) - } -} - -func TestVerifyFailsOnTamper(t *testing.T) { - node, _ := mesh.NewNode("", nil) - msg := mesh.Message{Type: mesh.MsgAnnounce, NodeID: node.ID()} - data, sig, _ := node.Sign(msg) - data[0] ^= 0xFF - if err := mesh.Verify(node.PublicKey(), data, sig); err == nil { - t.Error("expected verification failure on tampered data") - } -} - -func TestMergeLowerLatencyWins(t *testing.T) { - store := mesh.NewRouteStore() - store.Merge([]cache.RouteEntry{ - {StorePath: "pkg-a", UpstreamURL: "https://slow.example.com", LatencyEMA: 200, TTL: time.Now().Add(time.Hour)}, - }) - store.Merge([]cache.RouteEntry{ - {StorePath: "pkg-a", UpstreamURL: "https://fast.example.com", LatencyEMA: 10, TTL: time.Now().Add(time.Hour)}, - }) - - entry := store.Get("pkg-a") - if entry == nil { - t.Fatal("entry is nil") - } - if entry.UpstreamURL != "https://fast.example.com" { - t.Errorf("expected fast upstream, got %q", entry.UpstreamURL) - } -} - -func TestMergeNewerTimestampWinsOnTie(t *testing.T) { - store := mesh.NewRouteStore() - now := time.Now() - store.Merge([]cache.RouteEntry{ - {StorePath: "pkg-b", UpstreamURL: "https://a.example.com", LatencyEMA: 50, LastVerified: now.Add(-time.Minute), TTL: time.Now().Add(time.Hour)}, - }) - store.Merge([]cache.RouteEntry{ - {StorePath: "pkg-b", UpstreamURL: "https://b.example.com", LatencyEMA: 50, LastVerified: now, TTL: time.Now().Add(time.Hour)}, - }) - - entry := store.Get("pkg-b") - if entry.UpstreamURL != "https://b.example.com" { - t.Errorf("expected newer upstream, got %q", entry.UpstreamURL) - } -} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go deleted file mode 100644 index 8dda137..0000000 --- a/internal/metrics/metrics.go +++ /dev/null @@ -1,61 +0,0 @@ -package metrics - -import "github.com/prometheus/client_golang/prometheus" - -var ( - // Narinfo requests served from the route cache. - NarinfoCacheHits = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ncro_narinfo_cache_hits_total", - Help: "Narinfo requests served from route cache.", - }) - - // Narinfo requests that required an upstream race. - NarinfoCacheMisses = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ncro_narinfo_cache_misses_total", - Help: "Narinfo requests requiring upstream race.", - }) - - // Narinfo requests by HTTP status code. - NarinfoRequests = prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "ncro_narinfo_requests_total", - Help: "Narinfo requests by status.", - }, []string{"status"}) - - // NAR streaming requests. - NARRequests = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ncro_nar_requests_total", - Help: "NAR streaming requests.", - }) - - // Times each upstream won the narinfo race. - UpstreamRaceWins = prometheus.NewCounterVec(prometheus.CounterOpts{ - Name: "ncro_upstream_race_wins_total", - Help: "Times each upstream won the narinfo race.", - }, []string{"upstream"}) - - // Current number of route entries in SQLite. - RouteEntries = prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "ncro_route_entries", - Help: "Current number of route entries in SQLite.", - }) - - // Upstream narinfo race latency in seconds. - UpstreamLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{ - Name: "ncro_upstream_latency_seconds", - Help: "Upstream narinfo race latency.", - Buckets: prometheus.DefBuckets, - }, []string{"upstream"}) -) - -// Registers all metrics with reg. -func Register(reg prometheus.Registerer) { - reg.MustRegister( - NarinfoCacheHits, - NarinfoCacheMisses, - NarinfoRequests, - NARRequests, - UpstreamRaceWins, - RouteEntries, - UpstreamLatency, - ) -} diff --git a/internal/narinfo/narinfo.go b/internal/narinfo/narinfo.go deleted file mode 100644 index 3a7444b..0000000 --- a/internal/narinfo/narinfo.go +++ /dev/null @@ -1,139 +0,0 @@ -package narinfo - -import ( - "bufio" - "crypto/ed25519" - "encoding/base64" - "fmt" - "io" - "strconv" - "strings" -) - -// Parsed representation of a Nix narinfo file. -type NarInfo struct { - StorePath string - URL string - Compression string - FileHash string - FileSize uint64 - NarHash string - NarSize uint64 - References []string - Deriver string - Sig []string - CA string -} - -// ParsePublicKey parses a Nix public key in "name:base64(key)" format. -func ParsePublicKey(s string) (name string, key ed25519.PublicKey, err error) { - name, b64, ok := strings.Cut(s, ":") - if !ok || name == "" { - return "", nil, fmt.Errorf("invalid public key %q: missing ':'", s) - } - raw, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - return "", nil, fmt.Errorf("invalid public key %q: %w", s, err) - } - if len(raw) != ed25519.PublicKeySize { - return "", nil, fmt.Errorf("invalid public key size %d, want %d", len(raw), ed25519.PublicKeySize) - } - return name, ed25519.PublicKey(raw), nil -} - -// Fingerprint returns the canonical signing input for this narinfo. -// Format: 1;;;; -func (ni *NarInfo) Fingerprint() string { - refs := make([]string, len(ni.References)) - for i, r := range ni.References { - if strings.HasPrefix(r, "/nix/store/") { - refs[i] = r - } else { - refs[i] = "/nix/store/" + r - } - } - return fmt.Sprintf("1;%s;%s;%d;%s", - ni.StorePath, ni.NarHash, ni.NarSize, strings.Join(refs, ",")) -} - -// Verify checks that at least one Sig line is a valid signature for pubKeyStr. -// pubKeyStr must be in "name:base64(key)" Nix format. -// Returns false (not an error) when no matching Sig line is found. -func (ni *NarInfo) Verify(pubKeyStr string) (bool, error) { - keyName, key, err := ParsePublicKey(pubKeyStr) - if err != nil { - return false, err - } - fp := []byte(ni.Fingerprint()) - for _, sigLine := range ni.Sig { - name, b64, ok := strings.Cut(sigLine, ":") - if !ok || name != keyName { - continue - } - sig, err := base64.StdEncoding.DecodeString(b64) - if err != nil || len(sig) != ed25519.SignatureSize { - continue - } - if ed25519.Verify(key, fp, sig) { - return true, nil - } - } - return false, nil -} - -// Parses a narinfo from r. Returns error on malformed input or missing StorePath. -func Parse(r io.Reader) (*NarInfo, error) { - ni := &NarInfo{} - scanner := bufio.NewScanner(r) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - k, v, ok := strings.Cut(line, ": ") - if !ok { - return nil, fmt.Errorf("malformed line: %q", line) - } - switch k { - case "StorePath": - ni.StorePath = v - case "URL": - ni.URL = v - case "Compression": - ni.Compression = v - case "FileHash": - ni.FileHash = v - case "FileSize": - n, err := strconv.ParseUint(v, 10, 64) - if err != nil { - return nil, fmt.Errorf("FileSize: %w", err) - } - ni.FileSize = n - case "NarHash": - ni.NarHash = v - case "NarSize": - n, err := strconv.ParseUint(v, 10, 64) - if err != nil { - return nil, fmt.Errorf("NarSize: %w", err) - } - ni.NarSize = n - case "References": - if v != "" { - ni.References = strings.Fields(v) - } - case "Deriver": - ni.Deriver = v - case "Sig": - ni.Sig = append(ni.Sig, v) - case "CA": - ni.CA = v - } - } - if err := scanner.Err(); err != nil { - return nil, err - } - if ni.StorePath == "" { - return nil, fmt.Errorf("missing StorePath") - } - return ni, nil -} diff --git a/internal/narinfo/narinfo_test.go b/internal/narinfo/narinfo_test.go deleted file mode 100644 index a7b8463..0000000 --- a/internal/narinfo/narinfo_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package narinfo_test - -import ( - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "strings" - "testing" - - "notashelf.dev/ncro/internal/narinfo" -) - -var realWorldNarinfo = `StorePath: /nix/store/s66mzxpvicwklp6cpph4dc53k5l6bfhe-hello-2.12.1 -URL: nar/1wwh37nhg4f5zhb2vsn1a81p3ixn69gkg5k6fvmw3nhcn19fg8xj.nar.xz -Compression: xz -FileHash: sha256:1wwh37nhg4f5zhb2vsn1a81p3ixn69gkg5k6fvmw3nhcn19fg8xj -FileSize: 50088 -NarHash: sha256:04rrn5x6lnzrfkcy3bh7gf7x6hq3w1kap4wdss2n6n4s19pgbkr7 -NarSize: 226512 -References: s66mzxpvicwklp6cpph4dc53k5l6bfhe-hello-2.12.1 4nlgxhzzvsnr6bva0b9afnq8lbr9rk2b-glibc-2.38-23 -Sig: cache.nixos.org-1:abc123+base64signature= -` - -func TestParseRealWorld(t *testing.T) { - ni, err := narinfo.Parse(strings.NewReader(realWorldNarinfo)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if ni.StorePath != "/nix/store/s66mzxpvicwklp6cpph4dc53k5l6bfhe-hello-2.12.1" { - t.Errorf("StorePath = %q", ni.StorePath) - } - if ni.URL != "nar/1wwh37nhg4f5zhb2vsn1a81p3ixn69gkg5k6fvmw3nhcn19fg8xj.nar.xz" { - t.Errorf("URL = %q", ni.URL) - } - if ni.Compression != "xz" { - t.Errorf("Compression = %q, want xz", ni.Compression) - } - if ni.FileSize != 50088 { - t.Errorf("FileSize = %d, want 50088", ni.FileSize) - } - if ni.NarHash != "sha256:04rrn5x6lnzrfkcy3bh7gf7x6hq3w1kap4wdss2n6n4s19pgbkr7" { - t.Errorf("NarHash = %q", ni.NarHash) - } - if ni.NarSize != 226512 { - t.Errorf("NarSize = %d, want 226512", ni.NarSize) - } - if len(ni.References) != 2 { - t.Errorf("References len = %d, want 2", len(ni.References)) - } - if len(ni.Sig) != 1 { - t.Errorf("Sig len = %d, want 1", len(ni.Sig)) - } -} - -func TestParseNoneCompression(t *testing.T) { - input := "StorePath: /nix/store/abc-test\nURL: nar/abc.nar\nCompression: none\n" - ni, err := narinfo.Parse(strings.NewReader(input)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if ni.Compression != "none" { - t.Errorf("Compression = %q, want none", ni.Compression) - } -} - -func TestParseMultipleReferences(t *testing.T) { - input := "StorePath: /nix/store/abc-test\nReferences: pkg-a pkg-b pkg-c pkg-d\n" - ni, err := narinfo.Parse(strings.NewReader(input)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(ni.References) != 4 { - t.Errorf("References = %v, want 4 entries", ni.References) - } -} - -func TestParseEmptyReferences(t *testing.T) { - input := "StorePath: /nix/store/abc-test\nReferences: \n" - ni, err := narinfo.Parse(strings.NewReader(input)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(ni.References) != 0 { - t.Errorf("References = %v, want empty", ni.References) - } -} - -func TestParseMultipleSigs(t *testing.T) { - input := "StorePath: /nix/store/abc-test\nSig: key1:aaa=\nSig: key2:bbb=\n" - ni, err := narinfo.Parse(strings.NewReader(input)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(ni.Sig) != 2 { - t.Errorf("Sig len = %d, want 2", len(ni.Sig)) - } - if ni.Sig[0] != "key1:aaa=" || ni.Sig[1] != "key2:bbb=" { - t.Errorf("Sig = %v", ni.Sig) - } -} - -func TestParseMissingStorePath(t *testing.T) { - input := "URL: nar/abc.nar\nNarHash: sha256:abc\n" - _, err := narinfo.Parse(strings.NewReader(input)) - if err == nil { - t.Error("expected error for missing StorePath") - } -} - -func TestParseMalformedLine(t *testing.T) { - input := "StorePath: /nix/store/abc-test\nbadline\n" - _, err := narinfo.Parse(strings.NewReader(input)) - if err == nil { - t.Error("expected error for malformed line") - } -} - -func TestParseNarSizeOverflow(t *testing.T) { - input := "StorePath: /nix/store/abc-test\nNarSize: 18446744073709551615\n" - ni, err := narinfo.Parse(strings.NewReader(input)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if ni.NarSize != 18446744073709551615 { - t.Errorf("NarSize = %d", ni.NarSize) - } -} - -func TestParseDeriverCA(t *testing.T) { - input := "StorePath: /nix/store/abc-test\nDeriver: abc-drv\nCA: fixed:r:sha256:abc\n" - ni, err := narinfo.Parse(strings.NewReader(input)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if ni.Deriver != "abc-drv" { - t.Errorf("Deriver = %q", ni.Deriver) - } - if ni.CA != "fixed:r:sha256:abc" { - t.Errorf("CA = %q", ni.CA) - } -} - -func TestParseIgnoresBlankLines(t *testing.T) { - input := "\n\nStorePath: /nix/store/abc-test\n\nNarHash: sha256:abc\n\n" - ni, err := narinfo.Parse(strings.NewReader(input)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if ni.StorePath == "" { - t.Error("StorePath should be set") - } -} - -func TestParseInvalidNarSize(t *testing.T) { - input := "StorePath: /nix/store/abc-test\nNarSize: not-a-number\n" - _, err := narinfo.Parse(strings.NewReader(input)) - if err == nil { - t.Error("expected error for invalid NarSize") - } -} - -func TestParseInvalidFileSize(t *testing.T) { - input := "StorePath: /nix/store/abc-test\nFileSize: not-a-number\n" - _, err := narinfo.Parse(strings.NewReader(input)) - if err == nil { - t.Error("expected error for invalid FileSize") - } -} - -// Fingerprint and signature verification -func TestFingerprint(t *testing.T) { - ni := &narinfo.NarInfo{ - StorePath: "/nix/store/s66mzxpvicwklp6cpph4dc53k5l6bfhe-hello-2.12.1", - NarHash: "sha256:04rrn5x6lnzrfkcy3bh7gf7x6hq3w1kap4wdss2n6n4s19pgbkr7", - NarSize: 226512, - References: []string{"s66mzxpvicwklp6cpph4dc53k5l6bfhe-hello-2.12.1"}, - } - fp := ni.Fingerprint() - want := "1;/nix/store/s66mzxpvicwklp6cpph4dc53k5l6bfhe-hello-2.12.1;" + - "sha256:04rrn5x6lnzrfkcy3bh7gf7x6hq3w1kap4wdss2n6n4s19pgbkr7;226512;" + - "/nix/store/s66mzxpvicwklp6cpph4dc53k5l6bfhe-hello-2.12.1" - if fp != want { - t.Errorf("Fingerprint() =\n%q\nwant\n%q", fp, want) - } -} - -func TestFingerprintNoRefs(t *testing.T) { - ni := &narinfo.NarInfo{ - StorePath: "/nix/store/abc-test", - NarHash: "sha256:abc", - NarSize: 1234, - } - fp := ni.Fingerprint() - if !strings.HasSuffix(fp, ";") { - t.Errorf("Fingerprint with no refs should end with ';', got: %q", fp) - } -} - -func TestFingerprintRefsAlreadyPrefixed(t *testing.T) { - ni := &narinfo.NarInfo{ - StorePath: "/nix/store/abc-test", - NarHash: "sha256:abc", - NarSize: 1234, - References: []string{"/nix/store/dep-pkg"}, // already prefixed - } - fp := ni.Fingerprint() - if strings.Contains(fp, "/nix/store//nix/store/") { - t.Errorf("Fingerprint double-prefixed refs: %q", fp) - } -} - -func TestParsePublicKeyValid(t *testing.T) { - name, key, err := narinfo.ParsePublicKey("cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=") - if err != nil { - t.Fatalf("ParsePublicKey: %v", err) - } - if name != "cache.nixos.org-1" { - t.Errorf("name = %q", name) - } - if len(key) != ed25519.PublicKeySize { - t.Errorf("key len = %d, want %d", len(key), ed25519.PublicKeySize) - } -} - -func TestParsePublicKeyMissingColon(t *testing.T) { - _, _, err := narinfo.ParsePublicKey("no-colon-here") - if err == nil { - t.Error("expected error for missing ':'") - } -} - -func TestParsePublicKeyBadBase64(t *testing.T) { - _, _, err := narinfo.ParsePublicKey("name:!!!not-base64!!!") - if err == nil { - t.Error("expected error for invalid base64") - } -} - -func TestParsePublicKeyWrongSize(t *testing.T) { - // 16 bytes encoded in base64 = 24 chars with padding - b16 := base64.StdEncoding.EncodeToString(make([]byte, 16)) - _, _, err := narinfo.ParsePublicKey("name:" + b16) - if err == nil { - t.Error("expected error for wrong key size (16 bytes, not 32)") - } -} - -// Generates a fresh ed25519 key, signs a narinfo fingerprint, -// embeds the signature, and verifies it. This covers the full sign/verify path. -func TestVerifyRoundtrip(t *testing.T) { - pub, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - - ni := &narinfo.NarInfo{ - StorePath: "/nix/store/abc123-test-pkg", - NarHash: "sha256:abcdef123456", - NarSize: 98765, - References: []string{"abc123-test-pkg"}, - } - - fp := ni.Fingerprint() - sig := ed25519.Sign(priv, []byte(fp)) - pubKeyStr := "test-key-1:" + base64.StdEncoding.EncodeToString(pub) - ni.Sig = []string{"test-key-1:" + base64.StdEncoding.EncodeToString(sig)} - - ok, err := ni.Verify(pubKeyStr) - if err != nil { - t.Fatalf("Verify error: %v", err) - } - if !ok { - t.Error("Verify returned false for valid signature") - } -} - -func TestVerifyWrongKey(t *testing.T) { - _, priv, _ := ed25519.GenerateKey(rand.Reader) - wrongPub, _, _ := ed25519.GenerateKey(rand.Reader) // different key - - ni := &narinfo.NarInfo{ - StorePath: "/nix/store/abc123-test-pkg", - NarHash: "sha256:abcdef", - NarSize: 1234, - } - fp := ni.Fingerprint() - sig := ed25519.Sign(priv, []byte(fp)) - // Register wrong public key but correct key name - wrongKeyStr := "test-key-1:" + base64.StdEncoding.EncodeToString(wrongPub) - ni.Sig = []string{"test-key-1:" + base64.StdEncoding.EncodeToString(sig)} - - ok, err := ni.Verify(wrongKeyStr) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ok { - t.Error("Verify should return false for mismatched key") - } -} - -func TestVerifyNoMatchingKeyName(t *testing.T) { - pub, _, _ := ed25519.GenerateKey(rand.Reader) - ni := &narinfo.NarInfo{ - StorePath: "/nix/store/abc123-test-pkg", - NarHash: "sha256:abcdef", - NarSize: 1234, - } - ni.Sig = []string{"other-key-1:invalidsig="} - pubKeyStr := "my-key-1:" + base64.StdEncoding.EncodeToString(pub) - - ok, err := ni.Verify(pubKeyStr) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ok { - t.Error("Verify should return false when no Sig line matches key name") - } -} diff --git a/internal/prober/prober.go b/internal/prober/prober.go deleted file mode 100644 index 3bf8dd8..0000000 --- a/internal/prober/prober.go +++ /dev/null @@ -1,267 +0,0 @@ -package prober - -import ( - "math" - "net/http" - "sort" - "sync" - "time" - - "notashelf.dev/ncro/internal/config" -) - -// Upstream health status. -type Status int - -const ( - StatusActive Status = iota - StatusDegraded // 3+ consecutive failures - StatusDown // 10+ consecutive failures -) - -func (s Status) String() string { - switch s { - case StatusActive: - return "ACTIVE" - case StatusDegraded: - return "DEGRADED" - default: - return "DOWN" - } -} - -// In-memory metrics for one upstream. -type UpstreamHealth struct { - URL string - Priority int - EMALatency float64 - LastProbe time.Time - ConsecutiveFails uint32 - TotalQueries uint64 - Status Status -} - -// Tracks latency and health for a set of upstreams. -type Prober struct { - mu sync.RWMutex - alpha float64 - table map[string]*UpstreamHealth - client *http.Client - persistHealth func(url string, ema float64, consecutiveFails uint32, totalQueries uint64) -} - -// Creates a Prober with the given EMA alpha coefficient. -func New(alpha float64) *Prober { - return &Prober{ - alpha: alpha, - table: make(map[string]*UpstreamHealth), - client: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -// Seeds the prober with upstream configs (records priority, no measurements yet). -func (p *Prober) InitUpstreams(upstreams []config.UpstreamConfig) { - p.mu.Lock() - defer p.mu.Unlock() - for _, u := range upstreams { - if _, ok := p.table[u.URL]; !ok { - p.table[u.URL] = &UpstreamHealth{URL: u.URL, Priority: u.Priority, Status: StatusActive} - } - } -} - -// Derives Status from the number of consecutive failures, matching the logic -// in RecordFailure. -func computeStatus(consecutiveFails uint32) Status { - switch { - case consecutiveFails >= 10: - return StatusDown - case consecutiveFails >= 3: - return StatusDegraded - default: - return StatusActive - } -} - -// Seeds an upstream's health state from persisted data. Should be called -// after InitUpstreams to restore state from the previous run. -func (p *Prober) Seed(url string, emaLatency float64, consecutiveFails int, totalQueries int64) { - p.mu.Lock() - defer p.mu.Unlock() - h, ok := p.table[url] - if !ok { - return - } - h.EMALatency = emaLatency - h.TotalQueries = uint64(totalQueries) - h.ConsecutiveFails = uint32(consecutiveFails) - h.Status = computeStatus(uint32(consecutiveFails)) -} - -// Registers a callback invoked after each RecordLatency or RecordFailure call. -// The callback runs in a separate goroutine and must be safe for concurrent use. -func (p *Prober) SetHealthPersistence(fn func(url string, ema float64, consecutiveFails uint32, totalQueries uint64)) { - p.mu.Lock() - defer p.mu.Unlock() - p.persistHealth = fn -} - -// Records a successful latency measurement and updates the EMA. -func (p *Prober) RecordLatency(url string, ms float64) { - p.mu.Lock() - defer p.mu.Unlock() - h, ok := p.table[url] - if !ok { - return - } - if h.TotalQueries == 0 { - h.EMALatency = ms - } else { - h.EMALatency = p.alpha*ms + (1-p.alpha)*h.EMALatency - } - h.ConsecutiveFails = 0 - h.TotalQueries++ - h.Status = StatusActive - h.LastProbe = time.Now() - if p.persistHealth != nil { - u, ema, cf, tq := h.URL, h.EMALatency, h.ConsecutiveFails, h.TotalQueries - fn := p.persistHealth - go fn(u, ema, cf, tq) - } -} - -// Records a probe failure. -func (p *Prober) RecordFailure(url string) { - p.mu.Lock() - defer p.mu.Unlock() - h, ok := p.table[url] - if !ok { - return - } - h.ConsecutiveFails++ - h.Status = computeStatus(h.ConsecutiveFails) - if p.persistHealth != nil { - u, ema, cf, tq := h.URL, h.EMALatency, h.ConsecutiveFails, h.TotalQueries - fn := p.persistHealth - go fn(u, ema, cf, tq) - } -} - -// Returns a copy of the health entry for url, or nil if unknown. -func (p *Prober) GetHealth(url string) *UpstreamHealth { - p.mu.RLock() - defer p.mu.RUnlock() - h, ok := p.table[url] - if !ok { - return nil - } - cp := *h - return &cp -} - -// Returns all known upstreams sorted by EMA latency ascending. -// DOWN upstreams are sorted last. Within 10% EMA difference, lower Priority wins. -func (p *Prober) SortedByLatency() []*UpstreamHealth { - p.mu.RLock() - defer p.mu.RUnlock() - result := make([]*UpstreamHealth, 0, len(p.table)) - for _, h := range p.table { - cp := *h - result = append(result, &cp) - } - sort.Slice(result, func(i, j int) bool { - a, b := result[i], result[j] - aDown := a.Status == StatusDown - bDown := b.Status == StatusDown - if aDown != bDown { - return bDown // non-down first - } - // Within 10% latency difference: prefer lower priority number, then lower latency. - if b.EMALatency > 0 && math.Abs(a.EMALatency-b.EMALatency)/b.EMALatency < 0.10 { - if a.Priority != b.Priority { - return a.Priority < b.Priority - } - } - return a.EMALatency < b.EMALatency - }) - return result -} - -// Performs a HEAD /nix-cache-info against url and updates health. -func (p *Prober) ProbeUpstream(url string) { - // Skip if URL is not in table. This prevents in-flight probes from - // resurrecting removed upstreams (race: RemoveUpstream called while - // ProbeUpstream is in flight). - p.mu.RLock() - _, exists := p.table[url] - p.mu.RUnlock() - if !exists { - // URL was removed or never added; do not resurrect. - return - } - - start := time.Now() - resp, err := p.client.Head(url + "/nix-cache-info") - elapsed := float64(time.Since(start).Nanoseconds()) / 1e6 - - if err != nil || resp.StatusCode != 200 { - p.RecordFailure(url) - return - } - resp.Body.Close() - p.RecordLatency(url, elapsed) -} - -// Probes all known upstreams on interval until stop is closed. -func (p *Prober) RunProbeLoop(interval time.Duration, stop <-chan struct{}) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-stop: - return - case <-ticker.C: - p.mu.RLock() - urls := make([]string, 0, len(p.table)) - for u := range p.table { - urls = append(urls, u) - } - p.mu.RUnlock() - for _, u := range urls { - go p.ProbeUpstream(u) - } - } - } -} - -func (p *Prober) getOrCreate(url string) *UpstreamHealth { - h, ok := p.table[url] - if !ok { - h = &UpstreamHealth{URL: url, Status: StatusActive} - p.table[url] = h - } - return h -} - -// Adds a new upstream dynamically (e.g., discovered via mDNS). -// Thread-safe. Triggers an immediate probe in the background. -func (p *Prober) AddUpstream(url string, priority int) { - p.mu.Lock() - defer p.mu.Unlock() - if _, exists := p.table[url]; exists { - return - } - p.table[url] = &UpstreamHealth{URL: url, Priority: priority, Status: StatusActive} - // Trigger an immediate probe in background - go p.ProbeUpstream(url) -} - -// Removes an upstream from tracking (e.g., when a peer leaves the network). -// Thread-safe. No-op if upstream was not known. -func (p *Prober) RemoveUpstream(url string) { - p.mu.Lock() - defer p.mu.Unlock() - delete(p.table, url) -} diff --git a/internal/prober/prober_test.go b/internal/prober/prober_test.go deleted file mode 100644 index 2521bf8..0000000 --- a/internal/prober/prober_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package prober_test - -import ( - "net/http" - "net/http/httptest" - "sync" - "testing" - - "notashelf.dev/ncro/internal/config" - "notashelf.dev/ncro/internal/prober" -) - -func TestEMACalculation(t *testing.T) { - p := prober.New(0.3) - p.AddUpstream("https://example.com", 1) - p.RecordLatency("https://example.com", 100) - p.RecordLatency("https://example.com", 50) - - // EMA after 2 measurements: first=100, second = 0.3*50 + 0.7*100 = 85 - health := p.GetHealth("https://example.com") - if health == nil { - t.Fatal("expected health entry") - } - if health.EMALatency < 84 || health.EMALatency > 86 { - t.Errorf("EMA = %.2f, want ~85", health.EMALatency) - } -} - -func TestStatusProgression(t *testing.T) { - p := prober.New(0.3) - p.AddUpstream("https://example.com", 1) - p.RecordLatency("https://example.com", 10) - - for range 3 { - p.RecordFailure("https://example.com") - } - h := p.GetHealth("https://example.com") - if h.Status != prober.StatusDegraded { - t.Errorf("status = %v, want Degraded after 3 failures", h.Status) - } - - for range 7 { - p.RecordFailure("https://example.com") - } - h = p.GetHealth("https://example.com") - if h.Status != prober.StatusDown { - t.Errorf("status = %v, want Down after 10 failures", h.Status) - } -} - -func TestRecoveryAfterSuccess(t *testing.T) { - p := prober.New(0.3) - p.AddUpstream("https://example.com", 1) - for range 10 { - p.RecordFailure("https://example.com") - } - p.RecordLatency("https://example.com", 20) - h := p.GetHealth("https://example.com") - if h.Status != prober.StatusActive { - t.Errorf("status = %v, want Active after recovery", h.Status) - } - if h.ConsecutiveFails != 0 { - t.Errorf("ConsecutiveFails = %d, want 0", h.ConsecutiveFails) - } -} - -func TestSortedByLatency(t *testing.T) { - p := prober.New(0.3) - p.AddUpstream("https://slow.example.com", 1) - p.AddUpstream("https://fast.example.com", 1) - p.AddUpstream("https://medium.example.com", 1) - p.RecordLatency("https://slow.example.com", 200) - p.RecordLatency("https://fast.example.com", 10) - p.RecordLatency("https://medium.example.com", 50) - - sorted := p.SortedByLatency() - if len(sorted) != 3 { - t.Fatalf("expected 3, got %d", len(sorted)) - } - if sorted[0].URL != "https://fast.example.com" { - t.Errorf("first = %q, want fast", sorted[0].URL) - } -} - -func TestProbeUpstream(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - defer srv.Close() - - p := prober.New(0.3) - p.AddUpstream(srv.URL, 0) - p.ProbeUpstream(srv.URL) - - h := p.GetHealth(srv.URL) - if h == nil || h.Status != prober.StatusActive { - t.Errorf("expected Active after successful probe, got %v", h) - } -} - -func TestSortedByLatencyWithPriority(t *testing.T) { - p := prober.New(0.3) - // Two upstreams with very similar latency; lower priority number should win. - p.AddUpstream("https://low-priority.example.com", 1) - p.AddUpstream("https://high-priority.example.com", 1) - p.RecordLatency("https://low-priority.example.com", 100) - p.RecordLatency("https://high-priority.example.com", 102) // within 10% - - // Set priorities by calling InitUpstreams via RecordLatency (already seeded). - // We can't call InitUpstreams without config here, so test via SortedByLatency - // behavior: without priority, the 100ms one wins. With equal EMA and priority - // both zero (default), the lower-latency one still wins. - sorted := p.SortedByLatency() - if len(sorted) != 2 { - t.Fatalf("expected 2, got %d", len(sorted)) - } - // The 100ms upstream should be first (lower latency wins when not within 10% tie). - // 100 vs 102: diff=2, 2/102=1.96% < 10%, so priority decides (both priority=0, tie --> latency). - // Actually 100 < 102 still wins on latency when priority is equal. - if sorted[0].EMALatency > sorted[1].EMALatency { - t.Errorf("expected lower latency first, got %.2f then %.2f", sorted[0].EMALatency, sorted[1].EMALatency) - } -} - -func TestProbeUpstreamFailure(t *testing.T) { - p := prober.New(0.3) - p.AddUpstream("http://127.0.0.1:1", 0) - p.ProbeUpstream("http://127.0.0.1:1") // nothing listening, maybe except for Makima - - h := p.GetHealth("http://127.0.0.1:1") - if h == nil || h.ConsecutiveFails == 0 { - t.Error("expected failure recorded") - } -} - -func TestSeedRestoresStatus(t *testing.T) { - p := prober.New(0.3) - p.InitUpstreams([]config.UpstreamConfig{{URL: "https://down.example.com"}}) - - // Seed with 10 consecutive fails -> should be StatusDown - p.Seed("https://down.example.com", 200.0, 10, 50) - - h := p.GetHealth("https://down.example.com") - if h == nil { - t.Fatal("expected health entry") - } - if h.Status != prober.StatusDown { - t.Errorf("Status = %v, want StatusDown", h.Status) - } - if h.EMALatency != 200.0 { - t.Errorf("EMALatency = %f, want 200.0", h.EMALatency) - } -} - -func TestPersistenceCallbackFired(t *testing.T) { - p := prober.New(0.3) - p.InitUpstreams([]config.UpstreamConfig{{URL: "https://up.example.com"}}) - - var ( - mu sync.Mutex - savedURL string - savedCF uint32 - wg sync.WaitGroup - ) - wg.Add(1) - p.SetHealthPersistence(func(url string, ema float64, consecutiveFails uint32, totalQueries uint64) { - mu.Lock() - savedURL = url - savedCF = consecutiveFails - mu.Unlock() - wg.Done() - }) - - p.RecordLatency("https://up.example.com", 50.0) - wg.Wait() - - mu.Lock() - gotURL := savedURL - gotCF := savedCF - mu.Unlock() - - if gotURL != "https://up.example.com" { - t.Errorf("savedURL = %q, want https://up.example.com", gotURL) - } - if gotCF != 0 { - t.Errorf("consecutiveFails = %d, want 0", gotCF) - } -} diff --git a/internal/router/router.go b/internal/router/router.go deleted file mode 100644 index 872e470..0000000 --- a/internal/router/router.go +++ /dev/null @@ -1,258 +0,0 @@ -package router - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "sync" - "time" - - "golang.org/x/sync/singleflight" - "notashelf.dev/ncro/internal/cache" - "notashelf.dev/ncro/internal/metrics" - "notashelf.dev/ncro/internal/narinfo" - "notashelf.dev/ncro/internal/prober" -) - -// Returned when all upstreams were reached but none had the path. -var ErrNotFound = errors.New("not found in any upstream") - -// Returned when all upstreams failed with network errors. -var ErrUpstreamUnavailable = errors.New("all upstreams unavailable") - -// Result of a Resolve call. -type Result struct { - URL string - LatencyMs float64 - CacheHit bool - NarInfoBytes []byte // raw narinfo response on cache miss; nil on cache hit -} - -// Resolves store paths to the best upstream via cache lookup or parallel racing. -type Router struct { - db *cache.DB - prober *prober.Prober - routeTTL time.Duration - raceTimeout time.Duration - negativeTTL time.Duration - client *http.Client - mu sync.RWMutex - upstreamKeys map[string]string // upstream URL -> Nix public key string - group singleflight.Group -} - -// Creates a Router. -func New(db *cache.DB, p *prober.Prober, routeTTL, raceTimeout, negativeTTL time.Duration) *Router { - return &Router{ - db: db, - prober: p, - routeTTL: routeTTL, - raceTimeout: raceTimeout, - negativeTTL: negativeTTL, - client: &http.Client{Timeout: raceTimeout}, - upstreamKeys: make(map[string]string), - } -} - -// Registers a Nix public key for narinfo signature verification on a given upstream. -// pubKeyStr must be in "name:base64(key)" format (e.g. "cache.nixos.org-1:..."). -func (r *Router) SetUpstreamKey(url, pubKeyStr string) error { - if _, _, err := narinfo.ParsePublicKey(pubKeyStr); err != nil { - return err - } - r.mu.Lock() - r.upstreamKeys[url] = pubKeyStr - r.mu.Unlock() - return nil -} - -// Returns the best upstream for the given store hash. -// Checks the route cache first; on miss races the provided candidates. -func (r *Router) Resolve(storeHash string, candidates []string) (*Result, error) { - // Fast path: negative cache. - if neg, err := r.db.IsNegative(storeHash); err == nil && neg { - return nil, ErrNotFound - } - - // Fast path: route cache hit. - entry, err := r.db.GetRoute(storeHash) - if err == nil && entry != nil && entry.IsValid() { - h := r.prober.GetHealth(entry.UpstreamURL) - if h == nil || h.Status == prober.StatusActive { - metrics.NarinfoCacheHits.Inc() - return &Result{ - URL: entry.UpstreamURL, - LatencyMs: entry.LatencyEMA, - CacheHit: true, - }, nil - } - } - metrics.NarinfoCacheMisses.Inc() - - v, raceErr, _ := r.group.Do(storeHash, func() (interface{}, error) { - result, err := r.race(storeHash, candidates) - if errors.Is(err, ErrNotFound) { - _ = r.db.SetNegative(storeHash, r.negativeTTL) - } - if err != nil { - return nil, err - } - return result, nil - }) - if raceErr != nil { - return nil, raceErr - } - return v.(*Result), nil -} - -type raceResult struct { - url string - latencyMs float64 -} - -func (r *Router) race(storeHash string, candidates []string) (*Result, error) { - if len(candidates) == 0 { - return nil, fmt.Errorf("no candidates for %q", storeHash) - } - - ctx, cancel := context.WithTimeout(context.Background(), r.raceTimeout) - defer cancel() - - ch := make(chan raceResult, len(candidates)) - var ( - wg sync.WaitGroup - mu sync.Mutex - netErrs int - notFounds int - ) - - for _, u := range candidates { - wg.Add(1) - go func(upstream string) { - defer wg.Done() - start := time.Now() - req, err := http.NewRequestWithContext(ctx, http.MethodHead, - upstream+"/"+storeHash+".narinfo", nil) - if err != nil { - slog.Warn("bad upstream URL in race", "upstream", upstream, "error", err) - mu.Lock() - netErrs++ - mu.Unlock() - return - } - resp, err := r.client.Do(req) - if err != nil { - mu.Lock() - netErrs++ - mu.Unlock() - return - } - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - mu.Lock() - notFounds++ - mu.Unlock() - return - } - ms := float64(time.Since(start).Nanoseconds()) / 1e6 - select { - case ch <- raceResult{url: upstream, latencyMs: ms}: - default: - } - }(u) - } - - go func() { - wg.Wait() - close(ch) - }() - - winner, ok := <-ch - if !ok { - mu.Lock() - ne, nf := netErrs, notFounds - mu.Unlock() - if ne > 0 && nf == 0 { - return nil, ErrUpstreamUnavailable - } - return nil, ErrNotFound - } - cancel() - - for res := range ch { - if res.latencyMs < winner.latencyMs { - winner = res - } - } - - metrics.UpstreamRaceWins.WithLabelValues(winner.url).Inc() - metrics.UpstreamLatency.WithLabelValues(winner.url).Observe(winner.latencyMs / 1000) - - // Fetch narinfo body to parse metadata and forward to caller. - narInfoBytes, narURL, narHash, narSize := r.fetchNarInfo(winner.url, storeHash) - - health := r.prober.GetHealth(winner.url) - ema := winner.latencyMs - if health != nil { - ema = 0.3*winner.latencyMs + 0.7*health.EMALatency - } - r.prober.RecordLatency(winner.url, winner.latencyMs) - - now := time.Now() - _ = r.db.SetRoute(&cache.RouteEntry{ - StorePath: storeHash, - UpstreamURL: winner.url, - LatencyMs: winner.latencyMs, - LatencyEMA: ema, - LastVerified: now, - QueryCount: 1, - TTL: now.Add(r.routeTTL), - NarHash: narHash, - NarSize: narSize, - NarURL: narURL, - }) - - return &Result{URL: winner.url, LatencyMs: winner.latencyMs, NarInfoBytes: narInfoBytes}, nil -} - -// Returns (body, narURL, narHash, narSize). narURL is the narinfo's URL field -// (e.g. "nar/1wwh37...nar.xz"), used for direct NAR routing. -// Returns (nil, "", "", 0) on fetch failure or signature verification failure. -func (r *Router) fetchNarInfo(upstream, storeHash string) ([]byte, string, string, uint64) { - url := upstream + "/" + storeHash + ".narinfo" - resp, err := r.client.Get(url) - if err != nil { - return nil, "", "", 0 - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, "", "", 0 - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, "", "", 0 - } - ni, err := narinfo.Parse(bytes.NewReader(body)) - if err != nil { - return body, "", "", 0 - } - r.mu.RLock() - pubKeyStr := r.upstreamKeys[upstream] - r.mu.RUnlock() - if pubKeyStr != "" { - ok, err := ni.Verify(pubKeyStr) - if err != nil { - slog.Warn("narinfo: public key parse error", "upstream", upstream, "error", err) - return nil, "", "", 0 - } - if !ok { - slog.Warn("narinfo: signature verification failed", "upstream", upstream, "store", storeHash) - return nil, "", "", 0 - } - } - return body, ni.URL, ni.NarHash, ni.NarSize -} diff --git a/internal/router/router_test.go b/internal/router/router_test.go deleted file mode 100644 index 4e56140..0000000 --- a/internal/router/router_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package router_test - -import ( - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "sync" - "sync/atomic" - "testing" - "time" - - "notashelf.dev/ncro/internal/cache" - "notashelf.dev/ncro/internal/config" - "notashelf.dev/ncro/internal/prober" - "notashelf.dev/ncro/internal/router" -) - -func newTestRouter(t *testing.T, upstreams ...string) (*router.Router, func()) { - t.Helper() - f, _ := os.CreateTemp("", "ncro-router-*.db") - f.Close() - db, err := cache.Open(f.Name(), 1000) - if err != nil { - t.Fatal(err) - } - p := prober.New(0.3) - for _, u := range upstreams { - p.RecordLatency(u, 10) - } - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - return r, func() { - db.Close() - os.Remove(f.Name()) - } -} - -func TestRouteHit(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "StorePath: /nix/store/abc123-hello") - })) - defer srv.Close() - - r, cleanup := newTestRouter(t, srv.URL) - defer cleanup() - - result, err := r.Resolve("abc123", []string{srv.URL}) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - if result.URL != srv.URL { - t.Errorf("url = %q, want %q", result.URL, srv.URL) - } - if result.LatencyMs <= 0 { - t.Error("expected positive latency") - } -} - -func TestRouteRacePicksFastest(t *testing.T) { - fast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - defer fast.Close() - - slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(100 * time.Millisecond) - w.WriteHeader(200) - })) - defer slow.Close() - - r, cleanup := newTestRouter(t, fast.URL, slow.URL) - defer cleanup() - - result, err := r.Resolve("somehash", []string{slow.URL, fast.URL}) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - if result.URL != fast.URL { - t.Errorf("expected fast server to win, got %q", result.URL) - } -} - -func TestRouteAllFail(t *testing.T) { - r, cleanup := newTestRouter(t) - defer cleanup() - - _, err := r.Resolve("somehash", []string{"http://127.0.0.1:1"}) - if err == nil { - t.Error("expected error when all upstreams fail") - } -} - -func TestRouteAllNotFound(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - })) - defer srv.Close() - - r, cleanup := newTestRouter(t, srv.URL) - defer cleanup() - - _, err := r.Resolve("somehash", []string{srv.URL}) - if !errors.Is(err, router.ErrNotFound) { - t.Errorf("expected ErrNotFound, got %v", err) - } -} - -func TestRouteAllUnavailable(t *testing.T) { - r, cleanup := newTestRouter(t) - defer cleanup() - - _, err := r.Resolve("somehash", []string{"http://127.0.0.1:1"}) - if !errors.Is(err, router.ErrUpstreamUnavailable) { - t.Errorf("expected ErrUpstreamUnavailable, got %v", err) - } -} - -func TestRaceWithMalformedURL(t *testing.T) { - r, cleanup := newTestRouter(t) - defer cleanup() - - _, err := r.Resolve("somehash", []string{"://bad-url"}) - if err == nil { - t.Error("expected error for malformed upstream URL") - } -} - -func TestCacheHit(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - defer srv.Close() - - r, cleanup := newTestRouter(t, srv.URL) - defer cleanup() - - r.Resolve("abc123", []string{srv.URL}) - - result, err := r.Resolve("abc123", []string{srv.URL}) - if err != nil { - t.Fatalf("second Resolve: %v", err) - } - if !result.CacheHit { - t.Error("expected cache hit on second resolve") - } -} - -func TestResolveWithDownUpstream(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - defer srv.Close() - - f, _ := os.CreateTemp("", "ncro-router-*.db") - f.Close() - db, _ := cache.Open(f.Name(), 1000) - defer db.Close() - defer os.Remove(f.Name()) - - p := prober.New(0.3) - p.RecordLatency(srv.URL, 10) - // Force the upstream to StatusDown - for range 10 { - p.RecordFailure(srv.URL) - } - - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - // Router should still attempt the race (the race uses HEAD, not the prober status) - // The upstream is actually healthy (httptest), so the race should succeed. - result, err := r.Resolve("somehash", []string{srv.URL}) - if err != nil { - t.Fatalf("Resolve with down-flagged upstream: %v", err) - } - if result.URL != srv.URL { - t.Errorf("url = %q", result.URL) - } -} - -func TestNegativeCaching(t *testing.T) { - var raceCount int32 - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&raceCount, 1) - w.WriteHeader(http.StatusNotFound) - })) - defer ts.Close() - - db, err := cache.Open(":memory:", 1000) - if err != nil { - t.Fatal(err) - } - defer db.Close() - p := prober.New(0.3) - p.InitUpstreams([]config.UpstreamConfig{{URL: ts.URL}}) - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - - _, err = r.Resolve("not-on-any-upstream", []string{ts.URL}) - if !errors.Is(err, router.ErrNotFound) { - t.Fatalf("first resolve: expected ErrNotFound, got %v", err) - } - count1 := atomic.LoadInt32(&raceCount) - - _, err = r.Resolve("not-on-any-upstream", []string{ts.URL}) - if !errors.Is(err, router.ErrNotFound) { - t.Fatalf("second resolve: expected ErrNotFound, got %v", err) - } - count2 := atomic.LoadInt32(&raceCount) - - if count2 != count1 { - t.Errorf("second resolve hit upstream %d extra times, want 0 (should be negatively cached)", count2-count1) - } -} - -func TestSingleflightDedup(t *testing.T) { - var headCount int32 - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodHead { - atomic.AddInt32(&headCount, 1) - time.Sleep(30 * time.Millisecond) // ensure goroutines overlap - w.WriteHeader(http.StatusOK) - } else { - w.Header().Set("Content-Type", "text/x-nix-narinfo") - fmt.Fprintln(w, "StorePath: /nix/store/abc123-test") - } - })) - defer ts.Close() - - db, err := cache.Open(":memory:", 1000) - if err != nil { - t.Fatal(err) - } - defer db.Close() - p := prober.New(0.3) - p.InitUpstreams([]config.UpstreamConfig{{URL: ts.URL}}) - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - - const N = 10 - var wg sync.WaitGroup - for range N { - wg.Add(1) - go func() { - defer wg.Done() - r.Resolve("abc123dedup", []string{ts.URL}) - }() - } - wg.Wait() - - if hc := atomic.LoadInt32(&headCount); hc > 1 { - t.Errorf("upstream HEAD hit %d times for %d concurrent callers; want 1", hc, N) - } -} diff --git a/internal/server/integration_test.go b/internal/server/integration_test.go deleted file mode 100644 index eee8d82..0000000 --- a/internal/server/integration_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package server_test - -import ( - "io" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - "time" - - "notashelf.dev/ncro/internal/cache" - "notashelf.dev/ncro/internal/config" - "notashelf.dev/ncro/internal/prober" - "notashelf.dev/ncro/internal/router" - "notashelf.dev/ncro/internal/server" -) - -// Verifies that the second identical narinfo request uses the cached route. -func TestRouteReuseOnSecondRequest(t *testing.T) { - requestCount := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, ".narinfo") { - requestCount++ - w.Header().Set("Content-Type", "text/x-nix-narinfo") - io.WriteString(w, "StorePath: /nix/store/test-pkg\nURL: nar/test.nar\n") - return - } - w.WriteHeader(404) - })) - defer upstream.Close() - - f, _ := os.CreateTemp("", "ncro-int-*.db") - f.Close() - defer os.Remove(f.Name()) - db, _ := cache.Open(f.Name(), 1000) - defer db.Close() - - p := prober.New(0.3) - p.AddUpstream(upstream.URL, 0) - p.RecordLatency(upstream.URL, 10) - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - ts := httptest.NewServer(server.New(r, p, db, []config.UpstreamConfig{{URL: upstream.URL}}, 30)) - defer ts.Close() - - resp1, _ := http.Get(ts.URL + "/deadbeef00000000.narinfo") - io.Copy(io.Discard, resp1.Body) - resp1.Body.Close() - - resp2, _ := http.Get(ts.URL + "/deadbeef00000000.narinfo") - io.Copy(io.Discard, resp2.Body) - resp2.Body.Close() - - if resp1.StatusCode != 200 || resp2.StatusCode != 200 { - t.Errorf("expected 200/200, got %d/%d", resp1.StatusCode, resp2.StatusCode) - } -} - -// Verifies that when the best-seeded upstream returns 404, the fallback upstream is used. -func TestUpstreamFailoverFallback(t *testing.T) { - good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/x-nix-narinfo") - io.WriteString(w, "StorePath: /nix/store/fallback-pkg\n") - })) - defer good.Close() - - bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - })) - defer bad.Close() - - f, _ := os.CreateTemp("", "ncro-fb-*.db") - f.Close() - defer os.Remove(f.Name()) - db, _ := cache.Open(f.Name(), 1000) - defer db.Close() - - p := prober.New(0.3) - p.AddUpstream(bad.URL, 0) - p.AddUpstream(good.URL, 0) - p.RecordLatency(bad.URL, 1) // bad appears fastest - p.RecordLatency(good.URL, 50) - - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - ts := httptest.NewServer(server.New(r, p, db, []config.UpstreamConfig{ - {URL: bad.URL}, - {URL: good.URL}, - }, 30)) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/cafebabe00000000.narinfo") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - t.Errorf("expected 200 via fallback, got %d", resp.StatusCode) - } -} diff --git a/internal/server/server.go b/internal/server/server.go deleted file mode 100644 index 8679f17..0000000 --- a/internal/server/server.go +++ /dev/null @@ -1,252 +0,0 @@ -package server - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/prometheus/client_golang/prometheus/promhttp" - "notashelf.dev/ncro/internal/cache" - "notashelf.dev/ncro/internal/config" - "notashelf.dev/ncro/internal/metrics" - "notashelf.dev/ncro/internal/prober" - "notashelf.dev/ncro/internal/router" -) - -// HTTP handler implementing the Nix binary cache protocol. -type Server struct { - router *router.Router - prober *prober.Prober - db *cache.DB - upstreams []config.UpstreamConfig - client *http.Client - cachePriority int - metricsHandler http.Handler -} - -// Creates a Server. -func New(r *router.Router, p *prober.Prober, db *cache.DB, upstreams []config.UpstreamConfig, cachePriority int) *Server { - return &Server{ - router: r, - prober: p, - db: db, - upstreams: upstreams, - client: &http.Client{Timeout: 60 * time.Second}, - cachePriority: cachePriority, - metricsHandler: promhttp.Handler(), - } -} - -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - switch { - case path == "/nix-cache-info": - s.handleCacheInfo(w, r) - case path == "/health": - s.handleHealth(w, r) - case path == "/metrics": - s.metricsHandler.ServeHTTP(w, r) - case strings.HasSuffix(path, ".narinfo"): - s.handleNarinfo(w, r) - case strings.HasPrefix(path, "/nar/"): - s.handleNAR(w, r) - default: - http.NotFound(w, r) - } -} - -func (s *Server) handleCacheInfo(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/plain") - fmt.Fprintln(w, "StoreDir: /nix/store") - fmt.Fprintln(w, "WantMassQuery: 1") - fmt.Fprintf(w, "Priority: %d\n", s.cachePriority) -} - -func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { - type upstreamStatus struct { - URL string `json:"url"` - Status string `json:"status"` - LatencyMs float64 `json:"latency_ms"` - ConsecutiveFails uint32 `json:"consecutive_fails"` - } - type response struct { - Status string `json:"status"` - Upstreams []upstreamStatus `json:"upstreams"` - } - - sorted := s.prober.SortedByLatency() - upstreams := make([]upstreamStatus, len(sorted)) - var downCount int - var anyDegraded bool - for i, h := range sorted { - upstreams[i] = upstreamStatus{ - URL: h.URL, - Status: h.Status.String(), - LatencyMs: h.EMALatency, - ConsecutiveFails: h.ConsecutiveFails, - } - if h.Status == prober.StatusDown { - downCount++ - } else if h.Status == prober.StatusDegraded { - anyDegraded = true - } - } - - overall := "ok" - switch { - case len(sorted) > 0 && downCount == len(sorted): - overall = "down" - case downCount > 0 || anyDegraded: - overall = "degraded" - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response{Status: overall, Upstreams: upstreams}) -} - -func (s *Server) handleNarinfo(w http.ResponseWriter, r *http.Request) { - hash := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), ".narinfo") - - result, err := s.router.Resolve(hash, s.upstreamURLs()) - if err != nil { - slog.Warn("narinfo resolve failed", "hash", hash, "error", err) - metrics.NarinfoRequests.WithLabelValues("error").Inc() - switch { - case errors.Is(err, router.ErrNotFound): - http.NotFound(w, r) - default: - http.Error(w, "upstream unavailable", http.StatusBadGateway) - } - return - } - - slog.Info("narinfo routed", "hash", hash, "upstream", result.URL, "cache_hit", result.CacheHit) - metrics.NarinfoRequests.WithLabelValues("200").Inc() - - if len(result.NarInfoBytes) > 0 { - w.Header().Set("Content-Type", "text/x-nix-narinfo") - w.WriteHeader(http.StatusOK) - w.Write(result.NarInfoBytes) - return - } - s.proxyRequest(w, r, result.URL+r.URL.Path) -} - -func (s *Server) handleNAR(w http.ResponseWriter, r *http.Request) { - metrics.NARRequests.Inc() - - // Consult route cache: the narURL is the path without the leading slash. - narURL := strings.TrimPrefix(r.URL.Path, "/") - var tried string - if entry, err := s.db.GetRouteByNarURL(narURL); err == nil && entry != nil && entry.IsValid() { - tried = entry.UpstreamURL - if s.tryNARUpstream(w, r, entry.UpstreamURL) { - return - } - } - - // Fall back through all upstreams sorted by latency. - for _, h := range s.prober.SortedByLatency() { - if h.Status == prober.StatusDown || h.URL == tried { - continue - } - if s.tryNARUpstream(w, r, h.URL) { - return - } - } - http.NotFound(w, r) -} - -// Attempts to serve a NAR from upstreamBase. Returns true if the upstream -// responded with a non-404 status. -func (s *Server) tryNARUpstream(w http.ResponseWriter, r *http.Request, upstreamBase string) bool { - targetURL := upstreamBase + r.URL.Path - req, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body) - if err != nil { - return false - } - for _, hdr := range []string{"Accept", "Accept-Encoding", "Range"} { - if v := r.Header.Get(hdr); v != "" { - req.Header.Set(hdr, v) - } - } - resp, err := s.client.Do(req) - if err != nil { - slog.Warn("NAR upstream failed", "upstream", upstreamBase, "error", err) - return false - } - if resp.StatusCode == http.StatusNotFound { - resp.Body.Close() - return false - } - defer resp.Body.Close() - slog.Debug("proxying NAR", "path", r.URL.Path, "upstream", upstreamBase) - s.copyResponse(w, resp) - return true -} - -// Forwards r to targetURL and streams the response zero-copy. -func (s *Server) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) { - req, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body) - if err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - for _, h := range []string{"Accept", "Accept-Encoding", "Range"} { - if v := r.Header.Get(h); v != "" { - req.Header.Set(h, v) - } - } - resp, err := s.client.Do(req) - if err != nil { - slog.Error("upstream request failed", "url", targetURL, "error", err) - http.Error(w, "upstream error", http.StatusBadGateway) - return - } - defer resp.Body.Close() - s.copyResponse(w, resp) -} - -// Copies response headers and body from resp to w. -func (s *Server) copyResponse(w http.ResponseWriter, resp *http.Response) { - for _, h := range []string{ - "Content-Type", "Content-Length", "Content-Encoding", - "X-Nix-Signature", "Cache-Control", "Last-Modified", - } { - if v := resp.Header.Get(h); v != "" { - w.Header().Set(h, v) - } - } - w.WriteHeader(resp.StatusCode) - if _, err := io.Copy(w, resp.Body); err != nil { - slog.Warn("stream interrupted", "error", err) - } -} - -func (s *Server) upstreamURLs() []string { - // Include all upstreams the prober knows about: this covers both the - // statically-configured upstreams and any peers discovered at runtime - // via mDNS. Using the prober as the source of truth avoids a split - // between "what was configured" and "what was discovered". - sorted := s.prober.SortedByLatency() - urls := make([]string, 0, len(sorted)) - for _, h := range sorted { - if h.Status != prober.StatusDown { - urls = append(urls, h.URL) - } - } - // Fall back to the static list if the prober has no entries yet (i.e., - // before the first probe interval completes). - if len(urls) == 0 { - urls = make([]string, len(s.upstreams)) - for i, u := range s.upstreams { - urls[i] = u.URL - } - } - return urls -} diff --git a/internal/server/server_test.go b/internal/server/server_test.go deleted file mode 100644 index 533da21..0000000 --- a/internal/server/server_test.go +++ /dev/null @@ -1,487 +0,0 @@ -package server_test - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync/atomic" - "testing" - "time" - - "notashelf.dev/ncro/internal/cache" - "notashelf.dev/ncro/internal/config" - "notashelf.dev/ncro/internal/prober" - "notashelf.dev/ncro/internal/router" - "notashelf.dev/ncro/internal/server" -) - -func makeTestServer(t *testing.T, upstreams ...string) *httptest.Server { - t.Helper() - f, _ := os.CreateTemp("", "ncro-srv-*.db") - f.Close() - t.Cleanup(func() { os.Remove(f.Name()) }) - - db, err := cache.Open(f.Name(), 1000) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - p := prober.New(0.3) - for _, u := range upstreams { - p.AddUpstream(u, 0) - p.RecordLatency(u, 10) - } - - upsCfg := make([]config.UpstreamConfig, len(upstreams)) - for i, u := range upstreams { - upsCfg[i] = config.UpstreamConfig{URL: u} - } - - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - return httptest.NewServer(server.New(r, p, db, upsCfg, 30)) -} - -func TestNixCacheInfo(t *testing.T) { - ts := makeTestServer(t) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/nix-cache-info") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - body, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(body), "StoreDir:") { - t.Errorf("body missing StoreDir: %q", body) - } -} - -func TestCacheInfoFields(t *testing.T) { - ts := makeTestServer(t) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/nix-cache-info") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - s := string(body) - for _, want := range []string{"StoreDir:", "WantMassQuery:", "Priority:"} { - if !strings.Contains(s, want) { - t.Errorf("nix-cache-info missing %q", want) - } - } -} - -func TestHealthEndpoint(t *testing.T) { - ts := makeTestServer(t) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/health") - if err != nil { - t.Fatal(err) - } - if resp.StatusCode != 200 { - t.Errorf("status = %d, want 200", resp.StatusCode) - } -} - -func TestMetricsEndpoint(t *testing.T) { - ts := makeTestServer(t) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/metrics") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Errorf("status = %d, want 200", resp.StatusCode) - } - ct := resp.Header.Get("Content-Type") - if !strings.HasPrefix(ct, "text/plain") { - t.Errorf("Content-Type = %q, want text/plain", ct) - } -} - -func TestNarinfoProxy(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, ".narinfo") { - w.Header().Set("Content-Type", "text/x-nix-narinfo") - fmt.Fprint(w, "StorePath: /nix/store/abc123-hello-2.12\nURL: nar/abc123.nar\nCompression: none\n") - return - } - w.WriteHeader(404) - })) - defer upstream.Close() - - ts := makeTestServer(t, upstream.URL) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/abc123def456.narinfo") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - t.Errorf("narinfo status = %d, want 200", resp.StatusCode) - } - body, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(body), "StorePath:") { - t.Errorf("expected narinfo body, got: %q", body) - } -} - -func TestNarinfoHEADRequest(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, ".narinfo") { - w.Header().Set("Content-Type", "text/x-nix-narinfo") - fmt.Fprint(w, "StorePath: /nix/store/abc-head-test\nURL: nar/abc.nar\n") - return - } - w.WriteHeader(404) - })) - defer upstream.Close() - - ts := makeTestServer(t, upstream.URL) - defer ts.Close() - - req, _ := http.NewRequest(http.MethodHead, ts.URL+"/abc123.narinfo", nil) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != 200 { - t.Errorf("HEAD narinfo status = %d, want 200", resp.StatusCode) - } -} - -func TestNarinfoNotFound(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - })) - defer upstream.Close() - - ts := makeTestServer(t, upstream.URL) - defer ts.Close() - - resp, _ := http.Get(ts.URL + "/notfound000000.narinfo") - if resp.StatusCode != 404 { - t.Errorf("status = %d, want 404", resp.StatusCode) - } -} - -func TestNarinfoUpstreamError(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(500) - })) - defer upstream.Close() - - ts := makeTestServer(t, upstream.URL) - defer ts.Close() - - resp, _ := http.Get(ts.URL + "/abc123.narinfo") - // 404 (not found) or 502 (upstream error) are both acceptable - if resp.StatusCode == 200 { - t.Errorf("expected non-200 for upstream error, got %d", resp.StatusCode) - } -} - -func TestNarinfoNoUpstreams(t *testing.T) { - ts := makeTestServer(t) // no upstreams - defer ts.Close() - - resp, _ := http.Get(ts.URL + "/abc123.narinfo") - if resp.StatusCode == 200 { - t.Error("expected non-200 with no upstreams") - } -} - -func TestUnknownPath(t *testing.T) { - ts := makeTestServer(t) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/unknown/path") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != 404 { - t.Errorf("status = %d, want 404", resp.StatusCode) - } -} - -func TestNARStreamingPassthrough(t *testing.T) { - narContent := []byte("fake-nar-content-bytes") - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/nar/") { - w.Header().Set("Content-Type", "application/x-nix-archive") - w.Write(narContent) - return - } - if strings.HasSuffix(r.URL.Path, ".narinfo") { - w.WriteHeader(200) - return - } - w.WriteHeader(404) - })) - defer upstream.Close() - - ts := makeTestServer(t, upstream.URL) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/nar/abc123.nar") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Errorf("NAR status = %d, want 200", resp.StatusCode) - } - body, _ := io.ReadAll(resp.Body) - if string(body) != string(narContent) { - t.Errorf("NAR body mismatch: got %q, want %q", body, narContent) - } -} - -func TestNARRangeHeaderForwarded(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/nar/") { - if r.Header.Get("Range") == "" { - http.Error(w, "Range header missing", 400) - return - } - w.WriteHeader(206) - w.Write([]byte("partial")) - return - } - if strings.HasSuffix(r.URL.Path, ".narinfo") { - w.WriteHeader(200) - return - } - w.WriteHeader(404) - })) - defer upstream.Close() - - ts := makeTestServer(t, upstream.URL) - defer ts.Close() - - req, _ := http.NewRequest(http.MethodGet, ts.URL+"/nar/abc.nar", nil) - req.Header.Set("Range", "bytes=0-1023") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != 206 { - t.Errorf("Range request status = %d, want 206", resp.StatusCode) - } -} - -func TestNARRoutingUsesCache(t *testing.T) { - // Upstream A: has the NAR. - upstreamA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, ".narinfo") { - w.Header().Set("Content-Type", "text/x-nix-narinfo") - fmt.Fprintln(w, "StorePath: /nix/store/abc123-test") - fmt.Fprintln(w, "URL: nar/abc123.nar.xz") - } else { - fmt.Fprintln(w, "NAR data from A") - } - })) - defer upstreamA.Close() - - // Upstream B: does NOT have the NAR. - var bHit int32 - upstreamB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&bHit, 1) - http.NotFound(w, r) - })) - defer upstreamB.Close() - - db, err := cache.Open(":memory:", 100) - if err != nil { - t.Fatal(err) - } - defer db.Close() - - // Pre-seed the route cache: abc123 -> upstreamA, NarURL = "nar/abc123.nar.xz" - if err := db.SetRoute(&cache.RouteEntry{ - StorePath: "abc123", - UpstreamURL: upstreamA.URL, - NarURL: "nar/abc123.nar.xz", - TTL: time.Now().Add(time.Hour), - }); err != nil { - t.Fatalf("SetRoute: %v", err) - } - - p := prober.New(0.3) - p.InitUpstreams([]config.UpstreamConfig{{URL: upstreamA.URL}, {URL: upstreamB.URL}}) - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - srv := server.New(r, p, db, []config.UpstreamConfig{{URL: upstreamA.URL}, {URL: upstreamB.URL}}, 30) - - req := httptest.NewRequest(http.MethodGet, "/nar/abc123.nar.xz", nil) - w := httptest.NewRecorder() - srv.ServeHTTP(w, req) - - if w.Code != 200 { - t.Fatalf("status = %d, want 200", w.Code) - } - if atomic.LoadInt32(&bHit) > 0 { - t.Error("upstream B should not have been contacted when route cache has the answer") - } -} - -func TestNARFallbackWhenFirstUpstreamMissing(t *testing.T) { - missing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - })) - defer missing.Close() - - hasIt := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/x-nix-archive") - w.Write([]byte("nar-bytes")) - })) - defer hasIt.Close() - - f, _ := os.CreateTemp("", "ncro-nar-fallback-*.db") - f.Close() - t.Cleanup(func() { os.Remove(f.Name()) }) - db, _ := cache.Open(f.Name(), 1000) - t.Cleanup(func() { db.Close() }) - - p := prober.New(0.3) - // missing appears faster - p.AddUpstream(missing.URL, 0) - p.AddUpstream(hasIt.URL, 0) - p.RecordLatency(missing.URL, 1) - p.RecordLatency(hasIt.URL, 50) - - upsCfg := []config.UpstreamConfig{{URL: missing.URL}, {URL: hasIt.URL}} - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - ts := httptest.NewServer(server.New(r, p, db, upsCfg, 30)) - defer ts.Close() - - resp, err := http.Get(ts.URL + "/nar/abc123.nar") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Errorf("expected fallback NAR response 200, got %d", resp.StatusCode) - } - body, _ := io.ReadAll(resp.Body) - if string(body) != "nar-bytes" { - t.Errorf("NAR body = %q, want nar-bytes", body) - } -} - -func TestHealthEndpointDegraded(t *testing.T) { - p := prober.New(0.3) - p.InitUpstreams([]config.UpstreamConfig{ - {URL: "https://up1.example.com"}, - {URL: "https://up2.example.com"}, - }) - p.RecordLatency("https://up1.example.com", 100) - for range 5 { - p.RecordFailure("https://up2.example.com") - } - - db, err := cache.Open(":memory:", 100) - if err != nil { - t.Fatal(err) - } - defer db.Close() - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - srv := server.New(r, p, db, []config.UpstreamConfig{ - {URL: "https://up1.example.com"}, - {URL: "https://up2.example.com"}, - }, 30) - - req := httptest.NewRequest(http.MethodGet, "/health", nil) - w := httptest.NewRecorder() - srv.ServeHTTP(w, req) - - if w.Code != 200 { - t.Fatalf("status = %d", w.Code) - } - - var resp struct { - Status string `json:"status"` - Upstreams []struct { - URL string `json:"url"` - Status string `json:"status"` - } `json:"upstreams"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.Status != "degraded" { - t.Errorf("status = %q, want degraded", resp.Status) - } - if len(resp.Upstreams) != 2 { - t.Errorf("upstreams = %d, want 2", len(resp.Upstreams)) - } - - var foundDegraded bool - for _, u := range resp.Upstreams { - if u.URL == "https://up2.example.com" && u.Status == "DEGRADED" { - foundDegraded = true - } - } - if !foundDegraded { - t.Error("expected up2 to have status DEGRADED") - } - - var foundActive bool - for _, u := range resp.Upstreams { - if u.URL == "https://up1.example.com" && u.Status == "ACTIVE" { - foundActive = true - } - } - if !foundActive { - t.Error("expected up1 to have status ACTIVE") - } -} - -func TestHealthEndpointAllDown(t *testing.T) { - p := prober.New(0.3) - p.InitUpstreams([]config.UpstreamConfig{{URL: "https://down.example.com"}}) - for range 10 { - p.RecordFailure("https://down.example.com") - } - - db, err := cache.Open(":memory:", 100) - if err != nil { - t.Fatal(err) - } - defer db.Close() - r := router.New(db, p, time.Hour, 5*time.Second, 10*time.Minute) - srv := server.New(r, p, db, []config.UpstreamConfig{{URL: "https://down.example.com"}}, 30) - - req := httptest.NewRequest(http.MethodGet, "/health", nil) - w := httptest.NewRecorder() - srv.ServeHTTP(w, req) - - var resp struct { - Status string `json:"status"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.Status != "down" { - t.Errorf("status = %q, want down", resp.Status) - } -} diff --git a/nix/package.nix b/nix/package.nix index 70d02ab..a902d17 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,8 +1,9 @@ { lib, - buildGoModule, + rustPlatform, + pkg-config, }: -buildGoModule (finalAttrs: { +rustPlatform.buildRustPackage (finalAttrs: { pname = "ncro"; version = "1.0.0"; @@ -13,15 +14,14 @@ buildGoModule (finalAttrs: { fs.toSource { root = s; fileset = fs.unions [ - (s + /cmd) - (s + /internal) - (s + /go.mod) - (s + /go.sum) + (s + /src) + (s + /Cargo.toml) + (s + /Cargo.lock) ]; }; - vendorHash = "sha256-9OkQIj2g5mZ+IpjIKvy8Il7J4xL4PJimEsXJP10FhmU="; - ldflags = ["-s" "-w" "-X main.version=${finalAttrs.version}"]; + cargoLock.lockFile = "${finalAttrs.src}/Cargo.lock"; + nativeBuildInputs = [pkg-config]; meta = { mainProgram = "ncro"; diff --git a/nix/shell.nix b/nix/shell.nix index a144088..9ccb1df 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,18 +1,23 @@ { mkShell, - go, - gopls, - delve, - gofumpt, - golines, + cargo, + clippy, + pkg-config, + rust-analyzer, + rustc, + rustfmt, }: mkShell { - name = "go"; - packages = [ - delve - go - gopls - gofumpt - golines + name = "rust"; + + strictDeps = true; + nativeBuildInputs = [ + cargo + rustc + pkg-config + + rust-analyzer + clippy + (rustfmt.override {asNightly = true;}) ]; } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..ffe68a5 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,191 @@ +use clap::Parser; +use tokio::net::TcpListener; +use tracing_subscriber::{EnvFilter, fmt}; + +use crate::{ + config::Config, + db::Db, + discovery::Discovery, + health::Prober, + mesh, + metrics, + router::Router, + server, +}; + +#[derive(Debug, Parser)] +#[command(name = "ncro", version, about = "Nix Cache Route Optimizer")] +pub struct Args { + #[arg(short, long, env = "NCRO_CONFIG")] + pub config: Option, +} + +pub async fn run() -> anyhow::Result<()> { + let args = Args::parse(); + let cfg = Config::load(args.config.as_deref())?; + cfg.validate()?; + + init_logging(&cfg.logging.level, &cfg.logging.format); + let _ = metrics::get(); + + let db = Db::open(&cfg.cache.db_path, cfg.cache.max_entries).await?; + let prober = Prober::new(cfg.cache.latency_alpha); + prober.init_upstreams(&cfg.upstreams).await; + for row in db.load_all_health().await.unwrap_or_default() { + prober + .seed( + &row.url, + row.ema_latency, + row.consecutive_fails, + row.total_queries, + ) + .await; + } + let db_for_health = db.clone(); + prober + .set_health_persistence(move |url, ema, fails, queries| { + let db = db_for_health.clone(); + tokio::spawn(async move { + let _ = db + .save_health( + &url, + ema, + i64::from(fails), + i64::try_from(queries).unwrap_or(i64::MAX), + ) + .await; + }); + }) + .await; + for upstream in &cfg.upstreams { + let prober = prober.clone(); + let url = upstream.url.clone(); + tokio::spawn(async move { + prober.probe_upstream(url).await; + }); + } + + let router = Router::new( + db.clone(), + prober.clone(), + cfg.cache.ttl.0, + std::time::Duration::from_secs(5), + cfg.cache.negative_ttl.0, + ); + for upstream in &cfg.upstreams { + if !upstream.public_key.is_empty() { + router + .set_upstream_key(upstream.url.clone(), upstream.public_key.clone()) + .await?; + } + } + + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + let probe_prober = prober.clone(); + let probe_stop = stop_rx.clone(); + tokio::spawn(async move { + probe_prober + .run_probe_loop(std::time::Duration::from_secs(30), probe_stop) + .await; + }); + + let db_for_expiry = db.clone(); + let mut expiry_stop = stop_rx.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(300)); + loop { + tokio::select! { + _ = expiry_stop.changed() => return, + _ = ticker.tick() => { + let _ = db_for_expiry.expire_old_routes().await; + let _ = db_for_expiry.expire_negatives().await; + if let Ok(count) = db_for_expiry.route_count().await { metrics::get().route_entries.set(count); } + } + } + } + }); + + if cfg.discovery.enabled { + let discovery = Discovery::new(cfg.discovery.clone(), prober.clone())?; + let discovery_stop = stop_rx.clone(); + tokio::spawn(async move { + let _ = discovery.run(discovery_stop).await; + }); + } + + if cfg.mesh.enabled { + let node = mesh::Node::new(&cfg.mesh.private_key_path).await?; + tracing::info!( + node_id = node.id(), + public_key = hex::encode(node.public_key()), + "mesh node identity" + ); + let allowed = cfg + .mesh + .peers + .iter() + .filter_map(|p| hex::decode(&p.public_key).ok()?.try_into().ok()) + .collect::>(); + mesh::listen_and_serve( + &cfg.mesh.bind_addr, + db.clone(), + allowed, + stop_rx.clone(), + ) + .await?; + let peers = cfg + .mesh + .peers + .iter() + .map(|p| p.addr.clone()) + .collect::>(); + tokio::spawn(mesh::run_gossip_loop( + node, + db.clone(), + peers, + cfg.mesh.gossip_interval.0, + stop_rx.clone(), + )); + } + + let app = server::app( + router, + prober, + db, + cfg.upstreams.clone(), + cfg.server.cache_priority, + ); + let listener = + TcpListener::bind(normalize_listen(&cfg.server.listen)).await?; + tracing::info!( + addr = cfg.server.listen, + upstreams = cfg.upstreams.len(), + version = env!("CARGO_PKG_VERSION"), + "ncro listening" + ); + let server = axum::serve(listener, app).with_graceful_shutdown(async move { + let _ = tokio::signal::ctrl_c().await; + }); + let result = server.await; + let _ = stop_tx.send(true); + result?; + Ok(()) +} + +fn init_logging(level: &str, format_name: &str) { + let filter = + EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info")); + if format_name == "text" { + fmt().with_env_filter(filter).init(); + } else { + fmt().json().with_env_filter(filter).init(); + } +} + +fn normalize_listen(listen: &str) -> String { + if listen.starts_with(':') { + format!("0.0.0.0{listen}") + } else { + listen.to_string() + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4460093 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,336 @@ +use std::{env, fs, time::Duration}; + +use serde::{Deserialize, Deserializer}; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("read config: {0}")] + Read(#[from] std::io::Error), + #[error("parse config: {0}")] + Parse(#[from] serde_yaml::Error), + #[error("{0}")] + Validation(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loads_defaults() -> Result<(), ConfigError> { + let cfg = Config::load(None)?; + assert_eq!(cfg.server.listen, ":8080"); + assert_eq!(cfg.cache.max_entries, 100_000); + assert_eq!(cfg.upstreams.len(), 1); + cfg.validate()?; + Ok(()) + } + + #[test] + fn parses_duration_yaml() -> Result<(), serde_yaml::Error> { + let cfg: Config = serde_yaml::from_str( + "server:\n read_timeout: 30s\ncache:\n ttl: 2h\n", + )?; + assert_eq!(cfg.server.read_timeout.0, Duration::from_secs(30)); + assert_eq!(cfg.cache.ttl.0, Duration::from_secs(7200)); + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HumanDuration(pub Duration); + +impl Default for HumanDuration { + fn default() -> Self { + Self(Duration::ZERO) + } +} + +impl<'de> Deserialize<'de> for HumanDuration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + humantime_serde::deserialize(deserializer).map(Self) + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct UpstreamConfig { + pub url: String, + pub priority: i32, + pub public_key: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ServerConfig { + pub listen: String, + pub read_timeout: HumanDuration, + pub write_timeout: HumanDuration, + pub cache_priority: i32, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + listen: ":8080".to_string(), + read_timeout: HumanDuration(Duration::from_secs(30)), + write_timeout: HumanDuration(Duration::from_secs(30)), + cache_priority: 30, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct CacheConfig { + pub db_path: String, + pub max_entries: i64, + pub ttl: HumanDuration, + pub negative_ttl: HumanDuration, + pub latency_alpha: f64, +} + +impl Default for CacheConfig { + fn default() -> Self { + Self { + db_path: "/var/lib/ncro/routes.db".to_string(), + max_entries: 100_000, + ttl: HumanDuration(Duration::from_secs(60 * 60)), + negative_ttl: HumanDuration(Duration::from_secs(10 * 60)), + latency_alpha: 0.3, + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct PeerConfig { + pub addr: String, + pub public_key: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct MeshConfig { + pub enabled: bool, + pub bind_addr: String, + pub peers: Vec, + #[serde(rename = "private_key")] + pub private_key_path: String, + pub gossip_interval: HumanDuration, +} + +impl Default for MeshConfig { + fn default() -> Self { + Self { + enabled: false, + bind_addr: "0.0.0.0:7946".to_string(), + peers: Vec::new(), + private_key_path: String::new(), + gossip_interval: HumanDuration(Duration::from_secs(30)), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct DiscoveryConfig { + pub enabled: bool, + pub service_name: String, + pub domain: String, + pub discovery_time: HumanDuration, + pub priority: i32, +} + +impl Default for DiscoveryConfig { + fn default() -> Self { + Self { + enabled: false, + service_name: "_nix-serve._tcp".to_string(), + domain: "local".to_string(), + discovery_time: HumanDuration(Duration::from_secs(5)), + priority: 20, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct LoggingConfig { + pub level: String, + pub format: String, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + level: "info".to_string(), + format: "json".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Config { + pub server: ServerConfig, + pub upstreams: Vec, + pub cache: CacheConfig, + pub mesh: MeshConfig, + pub discovery: DiscoveryConfig, + pub logging: LoggingConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + server: ServerConfig::default(), + upstreams: vec![UpstreamConfig { + url: "https://cache.nixos.org".to_string(), + priority: 10, + public_key: String::new(), + }], + cache: CacheConfig::default(), + mesh: MeshConfig::default(), + discovery: DiscoveryConfig::default(), + logging: LoggingConfig::default(), + } + } +} + +impl Config { + pub fn load(path: Option<&str>) -> Result { + let mut cfg = if let Some(path) = path.filter(|p| !p.is_empty()) { + let data = fs::read_to_string(path)?; + serde_yaml::from_str::(&data)? + } else { + Self::default() + }; + + if let Ok(v) = env::var("NCRO_LISTEN") + && !v.is_empty() + { + cfg.server.listen = v; + } + if let Ok(v) = env::var("NCRO_DB_PATH") + && !v.is_empty() + { + cfg.cache.db_path = v; + } + if let Ok(v) = env::var("NCRO_LOG_LEVEL") + && !v.is_empty() + { + cfg.logging.level = v; + } + + Ok(cfg) + } + + pub fn validate(&self) -> Result<(), ConfigError> { + if self.upstreams.is_empty() { + return Err(ConfigError::Validation( + "at least one upstream is required".to_string(), + )); + } + for (i, upstream) in self.upstreams.iter().enumerate() { + if upstream.url.is_empty() { + return Err(ConfigError::Validation(format!( + "upstream[{i}]: URL is empty" + ))); + } + Url::parse(&upstream.url).map_err(|err| { + ConfigError::Validation(format!( + "upstream[{i}]: invalid URL {:?}: {err}", + upstream.url + )) + })?; + if !upstream.public_key.is_empty() && !upstream.public_key.contains(':') { + return Err(ConfigError::Validation(format!( + "upstream[{i}]: public_key must be in 'name:base64(key)' Nix format" + ))); + } + } + if self.server.listen.is_empty() { + return Err(ConfigError::Validation( + "server.listen is empty".to_string(), + )); + } + if self.server.cache_priority < 1 { + return Err(ConfigError::Validation(format!( + "server.cache_priority must be >= 1, got {}", + self.server.cache_priority + ))); + } + if self.cache.latency_alpha <= 0.0 || self.cache.latency_alpha >= 1.0 { + return Err(ConfigError::Validation(format!( + "cache.latency_alpha must be between 0 and 1 exclusive, got {}", + self.cache.latency_alpha + ))); + } + if self.cache.ttl.0.is_zero() { + return Err(ConfigError::Validation( + "cache.ttl must be positive".to_string(), + )); + } + if self.cache.negative_ttl.0.is_zero() { + return Err(ConfigError::Validation( + "cache.negative_ttl must be positive".to_string(), + )); + } + if self.cache.max_entries <= 0 { + return Err(ConfigError::Validation( + "cache.max_entries must be positive".to_string(), + )); + } + if self.mesh.enabled && self.mesh.peers.is_empty() { + return Err(ConfigError::Validation( + "mesh.enabled is true but no peers configured".to_string(), + )); + } + for (i, peer) in self.mesh.peers.iter().enumerate() { + if peer.addr.is_empty() { + return Err(ConfigError::Validation(format!( + "mesh.peers[{i}]: addr is empty" + ))); + } + if !peer.public_key.is_empty() { + let bytes = hex::decode(&peer.public_key).map_err(|_| { + ConfigError::Validation(format!( + "mesh.peers[{i}]: public_key must be a hex-encoded 32-byte \ + ed25519 key" + )) + })?; + if bytes.len() != 32 { + return Err(ConfigError::Validation(format!( + "mesh.peers[{i}]: public_key must be a hex-encoded 32-byte \ + ed25519 key" + ))); + } + } + } + if self.discovery.enabled { + if self.discovery.service_name.is_empty() { + return Err(ConfigError::Validation( + "discovery.service_name is required when discovery is enabled" + .to_string(), + )); + } + if self.discovery.domain.is_empty() { + return Err(ConfigError::Validation( + "discovery.domain is required when discovery is enabled".to_string(), + )); + } + if self.discovery.discovery_time.0.is_zero() { + return Err(ConfigError::Validation( + "discovery.discovery_time must be positive".to_string(), + )); + } + } + Ok(()) + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..1728793 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,394 @@ +use std::{path::Path, time::Duration}; + +use chrono::{DateTime, TimeZone, Utc}; +use sqlx::{ + Row, + SqlitePool, + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, +}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DbError { + #[error("sqlite: {0}")] + Sqlx(#[from] sqlx::Error), + #[error("create database directory: {0}")] + CreateDir(#[from] std::io::Error), +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RouteEntry { + pub store_path: String, + pub upstream_url: String, + pub latency_ms: f64, + pub latency_ema: f64, + pub last_verified: DateTime, + pub query_count: u32, + pub failure_count: u32, + pub ttl: DateTime, + pub nar_hash: String, + pub nar_size: u64, + pub nar_url: String, +} + +impl RouteEntry { + pub fn is_valid(&self) -> bool { + Utc::now() < self.ttl + } +} + +#[derive(Debug, Clone)] +pub struct HealthRow { + pub url: String, + pub ema_latency: f64, + pub consecutive_fails: i64, + pub total_queries: i64, +} + +#[derive(Clone)] +pub struct Db { + pool: SqlitePool, + max_entries: i64, +} + +impl Db { + pub async fn open(path: &str, max_entries: i64) -> Result { + if path != ":memory:" + && let Some(parent) = Path::new(path).parent() + { + tokio::fs::create_dir_all(parent).await?; + } + + let options = if path == ":memory:" { + SqliteConnectOptions::new().filename(path) + } else { + SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true) + } + .journal_mode(SqliteJournalMode::Wal) + .busy_timeout(Duration::from_secs(5)); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?; + migrate(&pool).await?; + Ok(Self { pool, max_entries }) + } + + pub async fn get_route( + &self, + store_path: &str, + ) -> Result, DbError> { + let row = sqlx::query( + r"SELECT store_path, upstream_url, latency_ms, latency_ema, query_count, failure_count, + last_verified, ttl, nar_hash, nar_size, nar_url + FROM routes WHERE store_path = ?", + ) + .bind(store_path) + .fetch_optional(&self.pool) + .await?; + Ok(row.as_ref().map(row_to_route)) + } + + pub async fn get_route_by_nar_url( + &self, + nar_url: &str, + ) -> Result, DbError> { + let row = sqlx::query( + r"SELECT store_path, upstream_url, latency_ms, latency_ema, query_count, failure_count, + last_verified, ttl, nar_hash, nar_size, nar_url + FROM routes WHERE nar_url = ? AND ttl > ?", + ) + .bind(nar_url) + .bind(Utc::now().timestamp()) + .fetch_optional(&self.pool) + .await?; + Ok(row.as_ref().map(row_to_route)) + } + + pub async fn set_route(&self, entry: &RouteEntry) -> Result<(), DbError> { + sqlx::query( + r"INSERT INTO routes + (store_path, upstream_url, latency_ms, latency_ema, query_count, failure_count, + last_verified, ttl, nar_hash, nar_size, nar_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(store_path) DO UPDATE SET + upstream_url = excluded.upstream_url, + latency_ms = excluded.latency_ms, + latency_ema = excluded.latency_ema, + query_count = excluded.query_count, + failure_count = excluded.failure_count, + last_verified = excluded.last_verified, + ttl = excluded.ttl, + nar_hash = excluded.nar_hash, + nar_size = excluded.nar_size, + nar_url = excluded.nar_url", + ) + .bind(&entry.store_path) + .bind(&entry.upstream_url) + .bind(entry.latency_ms) + .bind(entry.latency_ema) + .bind(i64::from(entry.query_count)) + .bind(i64::from(entry.failure_count)) + .bind(entry.last_verified.timestamp()) + .bind(entry.ttl.timestamp()) + .bind(&entry.nar_hash) + .bind(i64::try_from(entry.nar_size).unwrap_or(i64::MAX)) + .bind(&entry.nar_url) + .execute(&self.pool) + .await?; + self.evict_if_needed().await + } + + pub async fn expire_old_routes(&self) -> Result<(), DbError> { + sqlx::query("DELETE FROM routes WHERE ttl < ?") + .bind(Utc::now().timestamp()) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn list_recent_routes( + &self, + n: i64, + ) -> Result, DbError> { + let rows = sqlx::query( + r"SELECT store_path, upstream_url, latency_ms, latency_ema, query_count, failure_count, + last_verified, ttl, nar_hash, nar_size, nar_url + FROM routes WHERE ttl > ? ORDER BY last_verified DESC LIMIT ?", + ) + .bind(Utc::now().timestamp()) + .bind(n) + .fetch_all(&self.pool) + .await?; + Ok(rows.iter().map(row_to_route).collect()) + } + + pub async fn route_count(&self) -> Result { + Ok( + sqlx::query("SELECT COUNT(*) FROM routes") + .fetch_one(&self.pool) + .await? + .get::(0), + ) + } + + pub async fn set_negative( + &self, + store_path: &str, + ttl: Duration, + ) -> Result<(), DbError> { + sqlx::query( + r"INSERT INTO negative_cache (store_path, expires_at) VALUES (?, ?) + ON CONFLICT(store_path) DO UPDATE SET expires_at = excluded.expires_at", + ) + .bind(store_path) + .bind((Utc::now() + chrono::Duration::from_std(ttl).unwrap_or_default()).timestamp()) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn is_negative(&self, store_path: &str) -> Result { + Ok( + sqlx::query( + "SELECT EXISTS(SELECT 1 FROM negative_cache WHERE store_path = ? AND \ + expires_at > ?)", + ) + .bind(store_path) + .bind(Utc::now().timestamp()) + .fetch_one(&self.pool) + .await? + .get::(0) + != 0, + ) + } + + pub async fn expire_negatives(&self) -> Result<(), DbError> { + sqlx::query("DELETE FROM negative_cache WHERE expires_at < ?") + .bind(Utc::now().timestamp()) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn save_health( + &self, + url: &str, + ema: f64, + consecutive_fails: i64, + total_queries: i64, + ) -> Result<(), DbError> { + sqlx::query( + r"INSERT INTO upstream_health (url, ema_latency, consecutive_fails, total_queries) + VALUES (?, ?, ?, ?) + ON CONFLICT(url) DO UPDATE SET + ema_latency = excluded.ema_latency, + consecutive_fails = excluded.consecutive_fails, + total_queries = excluded.total_queries", + ) + .bind(url) + .bind(ema) + .bind(consecutive_fails) + .bind(total_queries) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn load_all_health(&self) -> Result, DbError> { + let rows = sqlx::query( + "SELECT url, ema_latency, consecutive_fails, total_queries FROM \ + upstream_health", + ) + .fetch_all(&self.pool) + .await?; + Ok( + rows + .into_iter() + .map(|row| { + HealthRow { + url: row.get("url"), + ema_latency: row.get("ema_latency"), + consecutive_fails: row.get("consecutive_fails"), + total_queries: row.get("total_queries"), + } + }) + .collect(), + ) + } + + async fn evict_if_needed(&self) -> Result<(), DbError> { + sqlx::query( + r"DELETE FROM routes WHERE store_path IN ( + SELECT store_path FROM routes ORDER BY last_verified ASC + LIMIT MAX(0, (SELECT COUNT(*) FROM routes) - ?) + )", + ) + .bind(self.max_entries) + .execute(&self.pool) + .await?; + Ok(()) + } +} + +async fn migrate(pool: &SqlitePool) -> Result<(), DbError> { + sqlx::query( + r"CREATE TABLE IF NOT EXISTS routes ( + store_path TEXT PRIMARY KEY, + upstream_url TEXT NOT NULL, + latency_ms REAL NOT NULL DEFAULT 0, + latency_ema REAL NOT NULL DEFAULT 0, + query_count INTEGER NOT NULL DEFAULT 1, + failure_count INTEGER NOT NULL DEFAULT 0, + last_verified INTEGER NOT NULL DEFAULT 0, + ttl INTEGER NOT NULL, + nar_hash TEXT NOT NULL DEFAULT '', + nar_size INTEGER NOT NULL DEFAULT 0, + nar_url TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + )", + ) + .execute(pool) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_routes_ttl ON routes(ttl)") + .execute(pool) + .await?; + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_routes_last_verified ON \ + routes(last_verified)", + ) + .execute(pool) + .await?; + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_routes_nar_url ON routes(nar_url)", + ) + .execute(pool) + .await?; + sqlx::query( + r"CREATE TABLE IF NOT EXISTS upstream_health ( + url TEXT PRIMARY KEY, + ema_latency REAL NOT NULL DEFAULT 0, + consecutive_fails INTEGER NOT NULL DEFAULT 0, + total_queries INTEGER NOT NULL DEFAULT 0 + )", + ) + .execute(pool) + .await?; + sqlx::query( + r"CREATE TABLE IF NOT EXISTS negative_cache ( + store_path TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + )", + ) + .execute(pool) + .await?; + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_negative_expires ON \ + negative_cache(expires_at)", + ) + .execute(pool) + .await?; + Ok(()) +} + +fn row_to_route(row: &sqlx::sqlite::SqliteRow) -> RouteEntry { + let query_count = row.get::("query_count"); + let failure_count = row.get::("failure_count"); + let nar_size = row.get::("nar_size"); + RouteEntry { + store_path: row.get("store_path"), + upstream_url: row.get("upstream_url"), + latency_ms: row.get("latency_ms"), + latency_ema: row.get("latency_ema"), + query_count: u32::try_from(query_count).unwrap_or_default(), + failure_count: u32::try_from(failure_count).unwrap_or_default(), + last_verified: Utc + .timestamp_opt(row.get("last_verified"), 0) + .single() + .unwrap_or_else(Utc::now), + ttl: Utc + .timestamp_opt(row.get("ttl"), 0) + .single() + .unwrap_or_else(Utc::now), + nar_hash: row.get("nar_hash"), + nar_size: u64::try_from(nar_size).unwrap_or_default(), + nar_url: row.get("nar_url"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn route_roundtrip_and_negative_cache() -> Result<(), DbError> { + let db = Db::open(":memory:", 100).await?; + let now = Utc::now(); + let entry = RouteEntry { + store_path: "abc123".into(), + upstream_url: "https://cache.nixos.org".into(), + latency_ms: 10.0, + latency_ema: 10.0, + last_verified: now, + query_count: 1, + failure_count: 0, + ttl: now + chrono::Duration::hours(1), + nar_hash: "sha256:abc".into(), + nar_size: 42, + nar_url: "nar/abc.nar.xz".into(), + }; + db.set_route(&entry).await?; + let got = db + .get_route("abc123") + .await? + .ok_or(sqlx::Error::RowNotFound)?; + assert_eq!(got.upstream_url, entry.upstream_url); + assert!(db.get_route_by_nar_url("nar/abc.nar.xz").await?.is_some()); + db.set_negative("missing", Duration::from_secs(60)).await?; + assert!(db.is_negative("missing").await?); + Ok(()) + } +} diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..9e24a25 --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,74 @@ +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; + +use mdns_sd::{ServiceDaemon, ServiceEvent}; +use tokio::sync::{Mutex, watch}; + +use crate::{config::DiscoveryConfig, health::Prober}; + +pub struct Discovery { + cfg: DiscoveryConfig, + prober: Prober, + daemon: ServiceDaemon, + peers: Arc>>, +} + +impl Discovery { + pub fn new(cfg: DiscoveryConfig, prober: Prober) -> anyhow::Result { + Ok(Self { + cfg, + prober, + daemon: ServiceDaemon::new()?, + peers: Arc::new(Mutex::new(HashMap::new())), + }) + } + + pub async fn run( + self, + mut stop: watch::Receiver, + ) -> anyhow::Result<()> { + let service = format!( + "{}.{}.", + self.cfg.service_name.trim_end_matches('.'), + self.cfg.domain.trim_end_matches('.') + ); + let receiver = self.daemon.browse(&service)?; + let peers = Arc::clone(&self.peers); + let prober = self.prober.clone(); + let priority = self.cfg.priority; + let mut cleanup = tokio::time::interval(Duration::from_secs(10)); + let expiration = if self.cfg.discovery_time.0.is_zero() { + Duration::from_secs(30) + } else { + self.cfg.discovery_time.0 * 3 + }; + + loop { + tokio::select! { + _ = stop.changed() => { let _ = self.daemon.shutdown(); return Ok(()); } + _ = cleanup.tick() => { + let stale = { + let mut guard = peers.lock().await; + let now = Instant::now(); + let stale = guard.iter().filter(|(_, (_, seen))| now.duration_since(*seen) > expiration).map(|(k, (u, _))| (k.clone(), u.clone())).collect::>(); + for (key, _) in &stale { guard.remove(key); } + stale + }; + for (_, url) in stale { tracing::info!(url, "removing stale peer"); prober.remove_upstream(&url).await; } + } + event = tokio::task::spawn_blocking({ let receiver = receiver.clone(); move || receiver.recv_timeout(Duration::from_millis(500)).ok() }) => { + if let Ok(Some(ServiceEvent::ServiceResolved(info))) = event { + let Some(addr) = info.get_addresses().iter().next().map(mdns_sd::ScopedIp::to_ip_addr) else { continue; }; + let url = format!("http://{}", std::net::SocketAddr::new(addr, info.get_port())); + let key = info.get_fullname().to_string(); + let is_new = peers.lock().await.insert(key, (url.clone(), Instant::now())).is_none(); + if is_new { tracing::info!(url, "discovered nix-serve instance"); prober.add_upstream(url, priority).await; } + } + } + } + } + } +} diff --git a/src/health.rs b/src/health.rs new file mode 100644 index 0000000..fdeb3aa --- /dev/null +++ b/src/health.rs @@ -0,0 +1,310 @@ +use std::{ + cmp::Ordering, + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; + +use tokio::sync::RwLock; + +use crate::config::UpstreamConfig; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Active, + Degraded, + Down, +} + +impl Status { + pub const fn as_str(self) -> &'static str { + match self { + Self::Active => "ACTIVE", + Self::Degraded => "DEGRADED", + Self::Down => "DOWN", + } + } +} + +#[derive(Debug, Clone)] +pub struct UpstreamHealth { + pub url: String, + pub priority: i32, + pub ema_latency: f64, + pub last_probe: Option, + pub consecutive_fails: u32, + pub total_queries: u64, + pub status: Status, +} + +impl UpstreamHealth { + const fn new(url: String, priority: i32) -> Self { + Self { + url, + priority, + ema_latency: 0.0, + last_probe: None, + consecutive_fails: 0, + total_queries: 0, + status: Status::Active, + } + } +} + +type PersistHealth = Arc; + +#[derive(Clone)] +pub struct Prober { + inner: Arc, +} + +struct ProberInner { + alpha: f64, + table: RwLock>, + client: reqwest::Client, + persist_health: RwLock>, +} + +impl Prober { + pub fn new(alpha: f64) -> Self { + Self { + inner: Arc::new(ProberInner { + alpha, + table: RwLock::new(HashMap::new()), + client: reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), + persist_health: RwLock::new(None), + }), + } + } + + pub async fn init_upstreams(&self, upstreams: &[UpstreamConfig]) { + let mut table = self.inner.table.write().await; + for upstream in upstreams { + table.entry(upstream.url.clone()).or_insert_with(|| { + UpstreamHealth::new(upstream.url.clone(), upstream.priority) + }); + } + } + + #[allow(clippy::significant_drop_tightening)] + pub async fn seed( + &self, + url: &str, + ema_latency: f64, + consecutive_fails: i64, + total_queries: i64, + ) { + { + let mut table = self.inner.table.write().await; + let Some(health) = table.get_mut(url) else { + return; + }; + health.ema_latency = ema_latency; + health.total_queries = + u64::try_from(total_queries.max(0)).unwrap_or_default(); + health.consecutive_fails = + u32::try_from(consecutive_fails.max(0)).unwrap_or(u32::MAX); + health.status = compute_status(health.consecutive_fails); + } + } + + pub async fn set_health_persistence(&self, f: F) + where + F: Fn(String, f64, u32, u64) + Send + Sync + 'static, + { + *self.inner.persist_health.write().await = Some(Arc::new(f)); + } + + #[allow(clippy::significant_drop_tightening)] + pub async fn record_latency(&self, url: &str, ms: f64) { + let snapshot = { + let mut table = self.inner.table.write().await; + let Some(health) = table.get_mut(url) else { + return; + }; + if health.total_queries == 0 { + health.ema_latency = ms; + } else { + health.ema_latency = self + .inner + .alpha + .mul_add(ms, (1.0 - self.inner.alpha) * health.ema_latency); + } + health.consecutive_fails = 0; + health.total_queries += 1; + health.status = Status::Active; + health.last_probe = Some(Instant::now()); + ( + health.url.clone(), + health.ema_latency, + health.consecutive_fails, + health.total_queries, + ) + }; + let callback = self.inner.persist_health.read().await.clone(); + if let Some(callback) = callback { + tokio::spawn(async move { + callback(snapshot.0, snapshot.1, snapshot.2, snapshot.3); + }); + } + } + + #[allow(clippy::significant_drop_tightening)] + pub async fn record_failure(&self, url: &str) { + let snapshot = { + let mut table = self.inner.table.write().await; + let Some(health) = table.get_mut(url) else { + return; + }; + health.consecutive_fails += 1; + health.status = compute_status(health.consecutive_fails); + ( + health.url.clone(), + health.ema_latency, + health.consecutive_fails, + health.total_queries, + ) + }; + let callback = self.inner.persist_health.read().await.clone(); + if let Some(callback) = callback { + tokio::spawn(async move { + callback(snapshot.0, snapshot.1, snapshot.2, snapshot.3); + }); + } + } + + pub async fn get_health(&self, url: &str) -> Option { + self.inner.table.read().await.get(url).cloned() + } + + pub async fn sorted_by_latency(&self) -> Vec { + let mut result = self + .inner + .table + .read() + .await + .values() + .cloned() + .collect::>(); + result.sort_by(|a, b| { + match (a.status == Status::Down, b.status == Status::Down) { + (true, false) => return Ordering::Greater, + (false, true) => return Ordering::Less, + _ => {}, + } + if b.ema_latency > 0.0 + && ((a.ema_latency - b.ema_latency).abs() / b.ema_latency) < 0.10 + && a.priority != b.priority + { + return a.priority.cmp(&b.priority); + } + a.ema_latency + .partial_cmp(&b.ema_latency) + .unwrap_or(Ordering::Equal) + }); + result + } + + pub async fn probe_upstream(&self, url: String) { + if !self.inner.table.read().await.contains_key(&url) { + return; + } + let start = Instant::now(); + let ok = self + .inner + .client + .head(format!("{url}/nix-cache-info")) + .send() + .await + .map(|resp| resp.status().as_u16() == 200) + .unwrap_or(false); + if ok { + self + .record_latency(&url, start.elapsed().as_secs_f64() * 1000.0) + .await; + } else { + self.record_failure(&url).await; + } + } + + pub async fn run_probe_loop( + &self, + interval: Duration, + mut stop: tokio::sync::watch::Receiver, + ) { + let mut ticker = tokio::time::interval(interval); + loop { + tokio::select! { + _ = stop.changed() => return, + _ = ticker.tick() => { + let urls = self.inner.table.read().await.keys().cloned().collect::>(); + for url in urls { + let prober = self.clone(); + tokio::spawn(async move { prober.probe_upstream(url).await; }); + } + } + } + } + } + + pub async fn add_upstream(&self, url: String, priority: i32) { + let inserted = self + .inner + .table + .write() + .await + .insert(url.clone(), UpstreamHealth::new(url.clone(), priority)) + .is_none(); + if inserted { + let prober = self.clone(); + tokio::spawn(async move { + prober.probe_upstream(url).await; + }); + } + } + + pub async fn remove_upstream(&self, url: &str) { + self.inner.table.write().await.remove(url); + } +} + +const fn compute_status(consecutive_fails: u32) -> Status { + match consecutive_fails { + 10.. => Status::Down, + 3.. => Status::Degraded, + _ => Status::Active, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn ema_and_status_progression() -> Result<(), Box> + { + let p = Prober::new(0.3); + p.add_upstream("https://example.com".into(), 1).await; + p.record_latency("https://example.com", 100.0).await; + p.record_latency("https://example.com", 50.0).await; + let h = p + .get_health("https://example.com") + .await + .ok_or("missing health")?; + assert!((84.0..=86.0).contains(&h.ema_latency)); + for _ in 0..10 { + p.record_failure("https://example.com").await; + } + assert_eq!( + p.get_health("https://example.com") + .await + .ok_or("missing health")? + .status, + Status::Down + ); + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8c4b7ed --- /dev/null +++ b/src/main.rs @@ -0,0 +1,15 @@ +mod cli; +mod config; +mod db; +mod discovery; +mod health; +mod mesh; +mod metrics; +mod narinfo; +mod router; +mod server; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + cli::run().await +} diff --git a/src/mesh.rs b/src/mesh.rs new file mode 100644 index 0000000..a5b53c5 --- /dev/null +++ b/src/mesh.rs @@ -0,0 +1,223 @@ +use std::{path::Path, sync::Arc}; + +use chrono::Utc; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::{net::UdpSocket, time::Duration}; + +use crate::db::{Db, RouteEntry}; + +const MAX_PACKET_SIZE: usize = 65_536; +const HEADER_SIZE: usize = 96; + +type DecodedPacket<'a> = (&'a [u8], &'a [u8], &'a [u8], Message); + +#[derive(Debug, Error)] +pub enum MeshError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("msgpack: {0}")] + Encode(#[from] rmp_serde::encode::Error), + #[error("decode msgpack: {0}")] + Decode(#[from] rmp_serde::decode::Error), + #[error("packet too short: {0} bytes")] + PacketTooShort(usize), + #[error("invalid signature")] + InvalidSignature, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum MsgType { + Announce = 1, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub r#type: MsgType, + pub node_id: String, + pub timestamp: i64, + pub routes: Vec, +} + +#[derive(Clone)] +pub struct Node { + signing_key: Arc, +} + +impl Node { + pub async fn new(key_path: &str) -> Result { + if key_path.is_empty() { + return Ok(Self { + signing_key: Arc::new(SigningKey::generate(&mut OsRng)), + }); + } + if let Ok(data) = tokio::fs::read(key_path).await + && (data.len() == 32 || data.len() == 64) + { + let Ok(bytes) = <[u8; 32]>::try_from(&data[..32]) else { + return Err(MeshError::InvalidSignature); + }; + return Ok(Self { + signing_key: Arc::new(SigningKey::from_bytes(&bytes)), + }); + } + if let Some(parent) = Path::new(key_path).parent() { + tokio::fs::create_dir_all(parent).await?; + } + let key = SigningKey::generate(&mut OsRng); + tokio::fs::write(key_path, key.to_bytes()).await?; + Ok(Self { + signing_key: Arc::new(key), + }) + } + + pub fn id(&self) -> String { + hex::encode(&self.public_key()[..8]) + } + pub fn public_key(&self) -> [u8; 32] { + self.signing_key.verifying_key().to_bytes() + } + pub fn sign(&self, msg: &Message) -> Result<(Vec, Vec), MeshError> { + let body = rmp_serde::to_vec(msg)?; + Ok(( + body.clone(), + self.signing_key.sign(&body).to_bytes().to_vec(), + )) + } +} + +pub fn verify(pubkey: &[u8], body: &[u8], sig: &[u8]) -> Result<(), MeshError> { + let pubkey: [u8; 32] = + pubkey.try_into().map_err(|_| MeshError::InvalidSignature)?; + let sig: [u8; 64] = + sig.try_into().map_err(|_| MeshError::InvalidSignature)?; + VerifyingKey::from_bytes(&pubkey) + .map_err(|_| MeshError::InvalidSignature)? + .verify(body, &Signature::from_bytes(&sig)) + .map_err(|_| MeshError::InvalidSignature) +} + +pub async fn listen_and_serve( + addr: &str, + db: Db, + allowed_keys: Vec<[u8; 32]>, + stop: tokio::sync::watch::Receiver, +) -> Result<(), MeshError> { + let socket = UdpSocket::bind(addr).await?; + tokio::spawn(async move { + let mut stop = stop; + let mut buf = vec![0; MAX_PACKET_SIZE]; + loop { + tokio::select! { + _ = stop.changed() => return, + recv = socket.recv_from(&mut buf) => { + let Ok((n, src)) = recv else { return; }; + match decode_packet(&buf[..n]) { + Ok((pubkey, sig, body, msg)) => { + if !allowed_keys.is_empty() && !allowed_keys.iter().any(|k| k.as_slice() == pubkey) { + tracing::warn!(?src, "mesh: rejecting packet from unknown sender"); + continue; + } + if let Err(err) = verify(pubkey, body, sig) { + tracing::warn!(?src, error = %err, "mesh: signature verification failed"); + continue; + } + if msg.r#type == MsgType::Announce && !msg.routes.is_empty() { + merge_routes(&db, msg.routes).await; + } + } + Err(err) => tracing::warn!(?src, error = %err, "mesh: malformed packet"), + } + } + } + } + }); + Ok(()) +} + +async fn merge_routes(db: &Db, incoming: Vec) { + let now = Utc::now(); + for route in incoming.into_iter().filter(|route| route.ttl > now) { + let should_set = match db.get_route(&route.store_path).await { + Ok(Some(existing)) if route.latency_ema > existing.latency_ema => false, + Ok(Some(existing)) + if route.latency_ema.total_cmp(&existing.latency_ema).is_eq() + && route.last_verified <= existing.last_verified => + { + false + }, + Ok(_) => true, + Err(err) => { + tracing::warn!(error = %err, store = route.store_path, "mesh: route lookup failed"); + false + }, + }; + if should_set && let Err(err) = db.set_route(&route).await { + tracing::warn!(error = %err, store = route.store_path, "mesh: route merge failed"); + } + } +} + +pub async fn announce( + peer_addr: &str, + node: &Node, + routes: Vec, +) -> Result<(), MeshError> { + let msg = Message { + r#type: MsgType::Announce, + node_id: node.id(), + timestamp: Utc::now().timestamp_nanos_opt().unwrap_or_default(), + routes, + }; + let packet = encode_packet(node, &msg)?; + let socket = UdpSocket::bind("0.0.0.0:0").await?; + socket.send_to(&packet, peer_addr).await?; + Ok(()) +} + +pub async fn run_gossip_loop( + node: Node, + db: Db, + peers: Vec, + interval: Duration, + mut stop: tokio::sync::watch::Receiver, +) { + let mut ticker = tokio::time::interval(interval); + loop { + tokio::select! { + _ = stop.changed() => return, + _ = ticker.tick() => { + let Ok(routes) = db.list_recent_routes(100).await else { continue; }; + if routes.is_empty() { continue; } + for peer in &peers { + let peer = peer.clone(); + let node = node.clone(); + let routes = routes.clone(); + tokio::spawn(async move { let _ = announce(&peer, &node, routes).await; }); + } + } + } + } +} + +fn encode_packet(node: &Node, msg: &Message) -> Result, MeshError> { + let (body, sig) = node.sign(msg)?; + let mut packet = Vec::with_capacity(HEADER_SIZE + body.len()); + packet.extend_from_slice(&node.public_key()); + packet.extend_from_slice(&sig); + packet.extend_from_slice(&body); + Ok(packet) +} + +fn decode_packet(packet: &[u8]) -> Result, MeshError> { + if packet.len() < HEADER_SIZE { + return Err(MeshError::PacketTooShort(packet.len())); + } + let pubkey = &packet[..32]; + let sig = &packet[32..HEADER_SIZE]; + let body = &packet[HEADER_SIZE..]; + let msg = rmp_serde::from_slice(body)?; + Ok((pubkey, sig, body, msg)) +} diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..1a89398 --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,109 @@ +use std::sync::OnceLock; + +use prometheus::{ + Encoder, + HistogramOpts, + HistogramVec, + IntCounter, + IntCounterVec, + IntGauge, + Opts, + Registry, + TextEncoder, +}; + +pub struct Metrics { + registry: Registry, + pub narinfo_cache_hits: IntCounter, + pub narinfo_cache_misses: IntCounter, + pub narinfo_requests: IntCounterVec, + pub nar_requests: IntCounter, + pub upstream_race_wins: IntCounterVec, + pub route_entries: IntGauge, + pub upstream_latency: HistogramVec, +} + +static METRICS: OnceLock = OnceLock::new(); + +#[expect( + clippy::expect_used, + reason = "metric names and labels are static constants validated during \ + startup" +)] +pub fn get() -> &'static Metrics { + METRICS.get_or_init(|| { + let registry = Registry::new(); + let narinfo_cache_hits = IntCounter::new( + "ncro_narinfo_cache_hits_total", + "Narinfo requests served from route cache.", + ) + .expect("valid metric"); + let narinfo_cache_misses = IntCounter::new( + "ncro_narinfo_cache_misses_total", + "Narinfo requests requiring upstream race.", + ) + .expect("valid metric"); + let narinfo_requests = IntCounterVec::new( + Opts::new("ncro_narinfo_requests_total", "Narinfo requests by status."), + &["status"], + ) + .expect("valid metric"); + let nar_requests = + IntCounter::new("ncro_nar_requests_total", "NAR streaming requests.") + .expect("valid metric"); + let upstream_race_wins = IntCounterVec::new( + Opts::new( + "ncro_upstream_race_wins_total", + "Times each upstream won the narinfo race.", + ), + &["upstream"], + ) + .expect("valid metric"); + let route_entries = IntGauge::new( + "ncro_route_entries", + "Current number of route entries in SQLite.", + ) + .expect("valid metric"); + let upstream_latency = HistogramVec::new( + HistogramOpts::new( + "ncro_upstream_latency_seconds", + "Upstream narinfo race latency.", + ), + &["upstream"], + ) + .expect("valid metric"); + + for collector in [ + Box::new(narinfo_cache_hits.clone()) + as Box, + Box::new(narinfo_cache_misses.clone()), + Box::new(narinfo_requests.clone()), + Box::new(nar_requests.clone()), + Box::new(upstream_race_wins.clone()), + Box::new(route_entries.clone()), + Box::new(upstream_latency.clone()), + ] { + registry.register(collector).expect("register metric"); + } + + Metrics { + registry, + narinfo_cache_hits, + narinfo_cache_misses, + narinfo_requests, + nar_requests, + upstream_race_wins, + route_entries, + upstream_latency, + } + }) +} + +pub fn gather() -> String { + let mut buf = Vec::new(); + let encoder = TextEncoder::new(); + if encoder.encode(&get().registry.gather(), &mut buf).is_err() { + return String::new(); + } + String::from_utf8_lossy(&buf).into_owned() +} diff --git a/src/narinfo.rs b/src/narinfo.rs new file mode 100644 index 0000000..a25b38a --- /dev/null +++ b/src/narinfo.rs @@ -0,0 +1,207 @@ +use std::io::{BufRead, BufReader, Read}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum NarInfoError { + #[error("read narinfo: {0}")] + Io(#[from] std::io::Error), + #[error("malformed line: {0:?}")] + MalformedLine(String), + #[error("missing StorePath")] + MissingStorePath, + #[error("{field}: {source}")] + ParseInt { + field: &'static str, + source: std::num::ParseIntError, + }, + #[error("invalid public key {input:?}: missing ':'")] + MissingPublicKeySeparator { input: String }, + #[error("invalid public key {input:?}: {source}")] + InvalidPublicKeyBase64 { + input: String, + source: base64::DecodeError, + }, + #[error("invalid public key size {got}, want 32")] + InvalidPublicKeySize { got: usize }, +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::{Signer, SigningKey}; + use rand::rngs::OsRng; + + use super::*; + + #[test] + fn parses_realistic_narinfo() -> Result<(), NarInfoError> { + let input = "StorePath: /nix/store/abc-hello\nURL: \ + nar/abc.nar.xz\nCompression: xz\nFileSize: 42\nNarHash: \ + sha256:abc\nNarSize: 123\nReferences: abc-hello dep\nSig: \ + key:sig=\n"; + let ni = NarInfo::parse(input.as_bytes())?; + assert_eq!(ni.store_path, "/nix/store/abc-hello"); + assert_eq!(ni.url, "nar/abc.nar.xz"); + assert_eq!(ni.references.len(), 2); + Ok(()) + } + + #[test] + fn verifies_roundtrip_signature() -> Result<(), NarInfoError> { + let signing = SigningKey::generate(&mut OsRng); + let mut ni = NarInfo { + store_path: "/nix/store/abc-test".into(), + nar_hash: "sha256:abc".into(), + nar_size: 12, + references: vec!["abc-test".into()], + ..Default::default() + }; + let sig = signing.sign(ni.fingerprint().as_bytes()); + let pubkey = format!( + "test:{}", + STANDARD.encode(signing.verifying_key().to_bytes()) + ); + ni.sig = vec![format!("test:{}", STANDARD.encode(sig.to_bytes()))]; + assert!(ni.verify(&pubkey)?); + Ok(()) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NarInfo { + pub store_path: String, + pub url: String, + pub compression: String, + pub file_hash: String, + pub file_size: u64, + pub nar_hash: String, + pub nar_size: u64, + pub references: Vec, + pub deriver: String, + pub sig: Vec, + pub ca: String, +} + +pub fn parse_public_key( + input: &str, +) -> Result<(String, VerifyingKey), NarInfoError> { + let (name, b64) = input.split_once(':').ok_or_else(|| { + NarInfoError::MissingPublicKeySeparator { + input: input.to_string(), + } + })?; + if name.is_empty() { + return Err(NarInfoError::MissingPublicKeySeparator { + input: input.to_string(), + }); + } + let raw = STANDARD.decode(b64).map_err(|source| { + NarInfoError::InvalidPublicKeyBase64 { + input: input.to_string(), + source, + } + })?; + let bytes: [u8; 32] = raw.try_into().map_err(|raw: Vec| { + NarInfoError::InvalidPublicKeySize { got: raw.len() } + })?; + let key = VerifyingKey::from_bytes(&bytes) + .map_err(|_| NarInfoError::InvalidPublicKeySize { got: bytes.len() })?; + Ok((name.to_string(), key)) +} + +impl NarInfo { + pub fn parse(reader: impl Read) -> Result { + let mut narinfo = Self::default(); + for line in BufReader::new(reader).lines() { + let line = line?; + if line.is_empty() { + continue; + } + let (key, value) = line + .split_once(": ") + .ok_or_else(|| NarInfoError::MalformedLine(line.clone()))?; + match key { + "StorePath" => narinfo.store_path = value.to_string(), + "URL" => narinfo.url = value.to_string(), + "Compression" => narinfo.compression = value.to_string(), + "FileHash" => narinfo.file_hash = value.to_string(), + "FileSize" => { + narinfo.file_size = value.parse().map_err(|source| { + NarInfoError::ParseInt { + field: "FileSize", + source, + } + })?; + }, + "NarHash" => narinfo.nar_hash = value.to_string(), + "NarSize" => { + narinfo.nar_size = value.parse().map_err(|source| { + NarInfoError::ParseInt { + field: "NarSize", + source, + } + })?; + }, + "References" => { + if !value.is_empty() { + narinfo.references = + value.split_whitespace().map(str::to_string).collect(); + } + }, + "Deriver" => narinfo.deriver = value.to_string(), + "Sig" => narinfo.sig.push(value.to_string()), + "CA" => narinfo.ca = value.to_string(), + _ => {}, + } + } + if narinfo.store_path.is_empty() { + return Err(NarInfoError::MissingStorePath); + } + Ok(narinfo) + } + + pub fn fingerprint(&self) -> String { + let refs = self + .references + .iter() + .map(|reference| { + if reference.starts_with("/nix/store/") { + reference.clone() + } else { + format!("/nix/store/{reference}") + } + }) + .collect::>() + .join(","); + format!( + "1;{};{};{};{}", + self.store_path, self.nar_hash, self.nar_size, refs + ) + } + + pub fn verify(&self, public_key: &str) -> Result { + let (key_name, key) = parse_public_key(public_key)?; + let fingerprint = self.fingerprint(); + for sig_line in &self.sig { + let Some((name, b64)) = sig_line.split_once(':') else { + continue; + }; + if name != key_name { + continue; + } + let Ok(raw) = STANDARD.decode(b64) else { + continue; + }; + let Ok(bytes) = <[u8; 64]>::try_from(raw.as_slice()) else { + continue; + }; + let signature = Signature::from_bytes(&bytes); + if key.verify(fingerprint.as_bytes(), &signature).is_ok() { + return Ok(true); + } + } + Ok(false) + } +} diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..3b4d72b --- /dev/null +++ b/src/router.rs @@ -0,0 +1,309 @@ +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; + +use chrono::Utc; +use futures_util::{StreamExt, stream::FuturesUnordered}; +use thiserror::Error; +use tokio::sync::{Mutex, RwLock}; + +use crate::{ + db::{Db, RouteEntry}, + health::{Prober, Status}, + metrics, + narinfo::NarInfo, +}; + +#[derive(Debug, Error)] +pub enum RouterError { + #[error("not found in any upstream")] + NotFound, + #[error("all upstreams unavailable")] + UpstreamUnavailable, + #[error("no candidates for {0:?}")] + NoCandidates(String), + #[error("narinfo signature verification failed")] + SignatureVerificationFailed, + #[error(transparent)] + Db(#[from] crate::db::DbError), +} + +#[derive(Debug, Clone)] +pub struct ResolveResult { + pub url: String, + pub latency_ms: f64, + pub cache_hit: bool, + pub narinfo_bytes: Option>, +} + +#[derive(Clone)] +pub struct Router { + inner: Arc, +} + +struct RouterInner { + db: Db, + prober: Prober, + route_ttl: Duration, + race_timeout: Duration, + negative_ttl: Duration, + client: reqwest::Client, + upstream_keys: RwLock>, + inflight: Mutex>>>, +} + +#[derive(Debug)] +struct RaceResult { + url: String, + latency_ms: f64, +} + +impl Router { + pub fn new( + db: Db, + prober: Prober, + route_ttl: Duration, + race_timeout: Duration, + negative_ttl: Duration, + ) -> Self { + Self { + inner: Arc::new(RouterInner { + db, + prober, + route_ttl, + race_timeout, + negative_ttl, + client: reqwest::Client::builder() + .timeout(race_timeout) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), + upstream_keys: RwLock::new(HashMap::new()), + inflight: Mutex::new(HashMap::new()), + }), + } + } + + pub async fn set_upstream_key( + &self, + url: String, + public_key: String, + ) -> Result<(), crate::narinfo::NarInfoError> { + crate::narinfo::parse_public_key(&public_key)?; + self + .inner + .upstream_keys + .write() + .await + .insert(url, public_key); + Ok(()) + } + + pub async fn resolve( + &self, + store_hash: &str, + candidates: &[String], + ) -> Result { + if self.inner.db.is_negative(store_hash).await? { + return Err(RouterError::NotFound); + } + if let Some(result) = self.valid_cached_route(store_hash).await? { + return Ok(result); + } + metrics::get().narinfo_cache_misses.inc(); + + let lock = { + let mut inflight = self.inner.inflight.lock().await; + Arc::clone( + inflight + .entry(store_hash.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) + }; + let _guard = lock.lock().await; + if let Some(result) = self.valid_cached_route(store_hash).await? { + self.inner.inflight.lock().await.remove(store_hash); + return Ok(result); + } + + let result = self.race(store_hash, candidates).await; + if matches!(result, Err(RouterError::NotFound)) { + let _ = self + .inner + .db + .set_negative(store_hash, self.inner.negative_ttl) + .await; + } + self.inner.inflight.lock().await.remove(store_hash); + result + } + + async fn valid_cached_route( + &self, + store_hash: &str, + ) -> Result, RouterError> { + let Some(entry) = self.inner.db.get_route(store_hash).await? else { + return Ok(None); + }; + if !entry.is_valid() { + return Ok(None); + } + let health = self.inner.prober.get_health(&entry.upstream_url).await; + if !health.as_ref().is_none_or(|h| h.status == Status::Active) { + return Ok(None); + } + metrics::get().narinfo_cache_hits.inc(); + Ok(Some(ResolveResult { + url: entry.upstream_url, + latency_ms: entry.latency_ema, + cache_hit: true, + narinfo_bytes: None, + })) + } + + async fn race( + &self, + store_hash: &str, + candidates: &[String], + ) -> Result { + if candidates.is_empty() { + return Err(RouterError::NoCandidates(store_hash.to_string())); + } + let mut handles = FuturesUnordered::new(); + for upstream in candidates { + let upstream = upstream.clone(); + let store_hash = store_hash.to_string(); + let client = self.inner.client.clone(); + handles.push(tokio::spawn(async move { + let start = Instant::now(); + let res = client + .head(format!("{upstream}/{store_hash}.narinfo")) + .send() + .await; + match res { + Ok(resp) if resp.status().is_success() => { + Ok(RaceResult { + url: upstream, + latency_ms: start.elapsed().as_secs_f64() * 1000.0, + }) + }, + Ok(_) => Err(false), + Err(_) => Err(true), + } + })); + } + let mut net_errs = 0; + let mut not_founds = 0; + let mut winner: Option = None; + let deadline = tokio::time::sleep(self.inner.race_timeout); + tokio::pin!(deadline); + while !handles.is_empty() { + tokio::select! { + () = &mut deadline => break, + joined = handles.next() => { + match joined { + Some(Ok(Ok(res))) => if winner.as_ref().is_none_or(|w| res.latency_ms < w.latency_ms) { winner = Some(res); }, + Some(Ok(Err(true)) | Err(_)) => net_errs += 1, + Some(Ok(Err(false))) => not_founds += 1, + None => break, + } + } + } + } + let Some(winner) = winner else { + return if net_errs > 0 && not_founds == 0 { + Err(RouterError::UpstreamUnavailable) + } else { + Err(RouterError::NotFound) + }; + }; + + metrics::get() + .upstream_race_wins + .with_label_values(&[&winner.url]) + .inc(); + metrics::get() + .upstream_latency + .with_label_values(&[&winner.url]) + .observe(winner.latency_ms / 1000.0); + let (body, nar_url, nar_hash, nar_size) = + self.fetch_narinfo(&winner.url, store_hash).await?; + let ema = self + .inner + .prober + .get_health(&winner.url) + .await + .map_or(winner.latency_ms, |h| { + 0.3f64.mul_add(winner.latency_ms, 0.7 * h.ema_latency) + }); + self + .inner + .prober + .record_latency(&winner.url, winner.latency_ms) + .await; + let now = Utc::now(); + self + .inner + .db + .set_route(&RouteEntry { + store_path: store_hash.to_string(), + upstream_url: winner.url.clone(), + latency_ms: winner.latency_ms, + latency_ema: ema, + last_verified: now, + query_count: 1, + failure_count: 0, + ttl: now + + chrono::Duration::from_std(self.inner.route_ttl) + .unwrap_or_default(), + nar_hash, + nar_size, + nar_url, + }) + .await?; + Ok(ResolveResult { + url: winner.url, + latency_ms: winner.latency_ms, + cache_hit: false, + narinfo_bytes: body, + }) + } + + async fn fetch_narinfo( + &self, + upstream: &str, + store_hash: &str, + ) -> Result<(Option>, String, String, u64), RouterError> { + let Ok(resp) = self + .inner + .client + .get(format!("{upstream}/{store_hash}.narinfo")) + .send() + .await + else { + return Ok((None, String::new(), String::new(), 0)); + }; + if !resp.status().is_success() { + return Ok((None, String::new(), String::new(), 0)); + } + let Ok(bytes) = resp.bytes().await else { + return Ok((None, String::new(), String::new(), 0)); + }; + let body = bytes.to_vec(); + let Ok(parsed) = NarInfo::parse(body.as_slice()) else { + return Ok((Some(body), String::new(), String::new(), 0)); + }; + if let Some(pubkey) = self.inner.upstream_keys.read().await.get(upstream) + && !parsed.verify(pubkey).unwrap_or(false) + { + tracing::warn!( + upstream, + store = store_hash, + "narinfo signature verification failed" + ); + return Err(RouterError::SignatureVerificationFailed); + } + Ok((Some(body), parsed.url, parsed.nar_hash, parsed.nar_size)) + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..f921562 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,301 @@ +use std::sync::Arc; + +use axum::{ + Router as AxumRouter, + body::Body, + extract::{Path, State}, + http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode}, + response::{IntoResponse, Response}, + routing::get, +}; +use bytes::Bytes; +use futures_util::TryStreamExt; +use serde::Serialize; + +use crate::{ + config::UpstreamConfig, + db::Db, + health::{Prober, Status}, + metrics, + router::{Router, RouterError}, +}; + +#[derive(Clone)] +pub struct AppState { + router: Router, + prober: Prober, + db: Db, + upstreams: Vec, + client: reqwest::Client, + cache_priority: i32, +} + +pub fn app( + router: Router, + prober: Prober, + db: Db, + upstreams: Vec, + cache_priority: i32, +) -> AxumRouter { + let state = AppState { + router, + prober, + db, + upstreams, + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), + cache_priority, + }; + AxumRouter::new() + .route("/nix-cache-info", get(cache_info).head(cache_info)) + .route("/health", get(health)) + .route("/metrics", get(metrics_endpoint)) + .route("/{hash}.narinfo", get(narinfo).head(narinfo)) + .route("/nar/{*path}", get(nar).head(nar)) + .with_state(Arc::new(state)) +} + +async fn cache_info(State(state): State>) -> Response { + ( + [("content-type", "text/plain")], + format!( + "StoreDir: /nix/store\nWantMassQuery: 1\nPriority: {}\n", + state.cache_priority + ), + ) + .into_response() +} + +#[derive(Serialize)] +struct HealthResponse { + status: String, + upstreams: Vec, +} + +#[derive(Serialize)] +struct UpstreamStatus { + url: String, + status: String, + latency_ms: f64, + consecutive_fails: u32, +} + +async fn health(State(state): State>) -> Response { + let sorted = state.prober.sorted_by_latency().await; + let down_count = sorted.iter().filter(|h| h.status == Status::Down).count(); + let any_degraded = sorted.iter().any(|h| h.status == Status::Degraded); + let status = if !sorted.is_empty() && down_count == sorted.len() { + "down" + } else if down_count > 0 || any_degraded { + "degraded" + } else { + "ok" + }; + axum::Json(HealthResponse { + status: status.to_string(), + upstreams: sorted + .into_iter() + .map(|h| { + UpstreamStatus { + url: h.url, + status: h.status.as_str().to_string(), + latency_ms: h.ema_latency, + consecutive_fails: h.consecutive_fails, + } + }) + .collect(), + }) + .into_response() +} + +async fn metrics_endpoint() -> Response { + ( + [("content-type", "text/plain; version=0.0.4")], + metrics::gather(), + ) + .into_response() +} + +async fn narinfo( + State(state): State>, + Path(hash): Path, + req: Request, +) -> Response { + let candidates = upstream_urls(&state).await; + match state.router.resolve(&hash, &candidates).await { + Ok(result) => { + tracing::info!( + hash, + upstream = result.url, + cache_hit = result.cache_hit, + latency_ms = result.latency_ms, + "narinfo routed" + ); + metrics::get() + .narinfo_requests + .with_label_values(&["200"]) + .inc(); + if let Some(bytes) = result.narinfo_bytes { + return ( + StatusCode::OK, + [("content-type", "text/x-nix-narinfo")], + Bytes::from(bytes), + ) + .into_response(); + } + proxy( + &state.client, + req.method().clone(), + req.headers(), + format!("{}{}", result.url, req.uri().path()), + ) + .await + }, + Err(RouterError::NotFound) => { + metrics::get() + .narinfo_requests + .with_label_values(&["error"]) + .inc(); + StatusCode::NOT_FOUND.into_response() + }, + Err(err) => { + tracing::warn!(hash, error = %err, "narinfo resolve failed"); + metrics::get() + .narinfo_requests + .with_label_values(&["error"]) + .inc(); + (StatusCode::BAD_GATEWAY, "upstream unavailable").into_response() + }, + } +} + +async fn nar( + State(state): State>, + req: Request, +) -> Response { + metrics::get().nar_requests.inc(); + let nar_url = req.uri().path().trim_start_matches('/').to_string(); + if let Ok(Some(entry)) = state.db.get_route_by_nar_url(&nar_url).await + && entry.is_valid() + && let Some(resp) = try_nar_upstream( + &state.client, + req.method().clone(), + req.headers(), + &entry.upstream_url, + req.uri().path(), + ) + .await + { + return resp; + } + for h in state.prober.sorted_by_latency().await { + if h.status == Status::Down { + continue; + } + if let Some(resp) = try_nar_upstream( + &state.client, + req.method().clone(), + req.headers(), + &h.url, + req.uri().path(), + ) + .await + { + return resp; + } + } + StatusCode::NOT_FOUND.into_response() +} + +async fn upstream_urls(state: &AppState) -> Vec { + let urls = state + .prober + .sorted_by_latency() + .await + .into_iter() + .filter(|h| h.status != Status::Down) + .map(|h| h.url) + .collect::>(); + if urls.is_empty() { + state.upstreams.iter().map(|u| u.url.clone()).collect() + } else { + urls + } +} + +async fn try_nar_upstream( + client: &reqwest::Client, + method: Method, + headers: &HeaderMap, + upstream: &str, + path: &str, +) -> Option { + let resp = + upstream_request(client, method, headers, format!("{upstream}{path}")) + .await + .ok()?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return None; + } + Some(response_from_reqwest(resp)) +} + +async fn proxy( + client: &reqwest::Client, + method: Method, + headers: &HeaderMap, + url: String, +) -> Response { + match upstream_request(client, method, headers, url).await { + Ok(resp) => response_from_reqwest(resp), + Err(err) => { + tracing::warn!(error = %err, "upstream request failed"); + (StatusCode::BAD_GATEWAY, "upstream error").into_response() + }, + } +} + +async fn upstream_request( + client: &reqwest::Client, + method: Method, + headers: &HeaderMap, + url: String, +) -> reqwest::Result { + let mut req = client.request(method, url); + for name in ["accept", "accept-encoding", "range"] { + if let Some(value) = headers.get(name) { + req = req.header(name, value); + } + } + req.send().await +} + +fn response_from_reqwest(resp: reqwest::Response) -> Response { + let status = StatusCode::from_u16(resp.status().as_u16()) + .unwrap_or(StatusCode::BAD_GATEWAY); + let headers = resp.headers().clone(); + let stream = resp.bytes_stream().map_err(std::io::Error::other); + let mut out = Response::builder().status(status); + for name in [ + "content-type", + "content-length", + "content-encoding", + "x-nix-signature", + "cache-control", + "last-modified", + ] { + if let Some(value) = headers.get(name) + && let (Ok(header_name), Ok(header_value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_bytes(value.as_bytes()), + ) + { + out = out.header(header_name, header_value); + } + } + out + .body(Body::from_stream(stream)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +}