diff --git a/.envrc b/.envrc index 3550a30..df0bb43 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ use flake +export CGO_ENABLED=0 diff --git a/.gitignore b/.gitignore index 6fdd73b..62db839 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ # Build output -/target +/ncro diff --git a/.rustfmt.toml b/.rustfmt.toml deleted file mode 100644 index 9d5c77e..0000000 --- a/.rustfmt.toml +++ /dev/null @@ -1,26 +0,0 @@ -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/.taplo.toml b/.taplo.toml deleted file mode 100644 index fae0c57..0000000 --- a/.taplo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[formatting] -align_entries = true -column_width = 110 -compact_arrays = false -reorder_inline_tables = false -reorder_keys = true - -[[rule]] -include = [ "**/Cargo.toml" ] -keys = [ "package" ] - -[rule.formatting] -reorder_keys = false diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 762197e..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,3732 +0,0 @@ -# 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 = "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 = "aws-lc-rs" -version = "1.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[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", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[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 = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[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" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[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 = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -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 0.2.17", - "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 = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[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 = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[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", - "rand_core 0.10.1", - "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", -] - -[[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.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[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 = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[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.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2bb8ce26633738d98ffcef71ec58bff967c6675be50229823c2835f6316e67e" -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 = "2.0.0" -dependencies = [ - "anyhow", - "axum", - "clap", - "hex", - "ncro-config", - "ncro-db", - "ncro-discovery", - "ncro-health", - "ncro-mesh", - "ncro-metrics", - "ncro-router", - "ncro-server", - "tempfile", - "tokio", - "tower", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "ncro-config" -version = "2.0.0" -dependencies = [ - "hex", - "humantime-serde", - "serde", - "thiserror 2.0.18", - "toml", - "url", -] - -[[package]] -name = "ncro-db" -version = "2.0.0" -dependencies = [ - "chrono", - "serde", - "sqlx", - "thiserror 2.0.18", - "tokio", -] - -[[package]] -name = "ncro-discovery" -version = "2.0.0" -dependencies = [ - "anyhow", - "mdns-sd", - "ncro-config", - "ncro-health", - "tokio", - "tracing", -] - -[[package]] -name = "ncro-health" -version = "2.0.0" -dependencies = [ - "ncro-config", - "reqwest", - "tokio", -] - -[[package]] -name = "ncro-mesh" -version = "2.0.0" -dependencies = [ - "chrono", - "ed25519-dalek", - "hex", - "ncro-db", - "rand 0.10.1", - "rmp-serde", - "serde", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "ncro-metrics" -version = "2.0.0" -dependencies = [ - "prometheus", -] - -[[package]] -name = "ncro-narinfo" -version = "2.0.0" -dependencies = [ - "base64", - "ed25519-dalek", - "rand 0.10.1", - "thiserror 2.0.18", -] - -[[package]] -name = "ncro-router" -version = "2.0.0" -dependencies = [ - "chrono", - "futures-util", - "ncro-db", - "ncro-health", - "ncro-metrics", - "ncro-narinfo", - "reqwest", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "ncro-server" -version = "2.0.0" -dependencies = [ - "axum", - "bytes", - "futures-util", - "ncro-config", - "ncro-db", - "ncro-health", - "ncro-metrics", - "ncro-router", - "reqwest", - "serde", - "tracing", -] - -[[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 = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[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 = [ - "aws-lc-rs", - "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" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_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 = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[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.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" -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", - "rustls-platform-verifier", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[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 = [ - "aws-lc-rs", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[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-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "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 = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[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_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[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 = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[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 = "toml" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - -[[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", - "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 = "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 = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[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.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -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-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - -[[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 = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[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.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 = "winnow" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" - -[[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 deleted file mode 100644 index 1267aa2..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,126 +0,0 @@ -[workspace] -members = [ "crates/*", "ncro" ] -resolver = "3" - -[workspace.package] -description = "Nix Cache Route Optimizer" -edition = "2024" -homepage = "https://github.com/notashelf/ncro" -license = "EUPL-1.2" -repository = "https://github.com/notashelf/ncro" -version = "2.0.0" - -[workspace.dependencies] -# Workspace components -ncro-config = { path = "./crates/config", version = "2.0.0" } -ncro-db = { path = "./crates/db", version = "2.0.0" } -ncro-discovery = { path = "./crates/discovery", version = "2.0.0" } -ncro-health = { path = "./crates/health", version = "2.0.0" } -ncro-mesh = { path = "./crates/mesh", version = "2.0.0" } -ncro-metrics = { path = "./crates/metrics", version = "2.0.0" } -ncro-narinfo = { path = "./crates/narinfo", version = "2.0.0" } -ncro-router = { path = "./crates/router", version = "2.0.0" } -ncro-server = { path = "./crates/server", version = "2.0.0" } - -# Other deps -anyhow = "1.0.102" -axum = "0.8.9" -base64 = "0.22.1" -bytes = "1.11.1" -chrono = "0.4.44" -clap = "4.6.1" -ed25519-dalek = "2.2.0" -futures-util = "0.3.32" -hex = "0.4.3" -humantime-serde = "1.1.1" -mdns-sd = "0.19.1" -prometheus = "0.14.0" -rand = "0.10.1" -reqwest = { version = "0.13.3", default-features = false } -rmp-serde = "1.3.1" -serde = "1.0.228" -serde_json = "1.0.149" -sqlx = { version = "0.8.6", default-features = false } -tempfile = "3.27.0" -thiserror = "2.0.18" -tokio = "1.52.3" -toml = "1.1.2" -tower = "0.5.3" -tower-http = "0.6.10" -tracing = "0.1.44" -tracing-subscriber = "0.3.23" -url = "2.5.8" - -# See: -# -[workspace.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/LICENSE b/LICENSE deleted file mode 100644 index 9ba5d42..0000000 --- a/LICENSE +++ /dev/null @@ -1,289 +0,0 @@ - EUROPEAN UNION PUBLIC LICENCE v. 1.2 - EUPL © the European Union 2007, 2016 - -This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined -below) which is provided under the terms of this Licence. Any use of the Work, -other than as authorised under this Licence is prohibited (to the extent such -use is covered by a right of the copyright holder of the Work). - -The Work is provided under the terms of this Licence when the Licensor (as -defined below) has placed the following notice immediately following the -copyright notice for the Work: - - Licensed under the EUPL - -or has expressed by any other means his willingness to license under the EUPL. - -1. Definitions - -In this Licence, the following terms have the following meaning: - -- ‘The Licence’: this Licence. - -- ‘The Original Work’: the work or software distributed or communicated by the - Licensor under this Licence, available as Source Code and also as Executable - Code as the case may be. - -- ‘Derivative Works’: the works or software that could be created by the - Licensee, based upon the Original Work or modifications thereof. This Licence - does not define the extent of modification or dependence on the Original Work - required in order to classify a work as a Derivative Work; this extent is - determined by copyright law applicable in the country mentioned in Article 15. - -- ‘The Work’: the Original Work or its Derivative Works. - -- ‘The Source Code’: the human-readable form of the Work which is the most - convenient for people to study and modify. - -- ‘The Executable Code’: any code which has generally been compiled and which is - meant to be interpreted by a computer as a program. - -- ‘The Licensor’: the natural or legal person that distributes or communicates - the Work under the Licence. - -- ‘Contributor(s)’: any natural or legal person who modifies the Work under the - Licence, or otherwise contributes to the creation of a Derivative Work. - -- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of - the Work under the terms of the Licence. - -- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, - renting, distributing, communicating, transmitting, or otherwise making - available, online or offline, copies of the Work or providing access to its - essential functionalities at the disposal of any other natural or legal - person. - -2. Scope of the rights granted by the Licence - -The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, -sublicensable licence to do the following, for the duration of copyright vested -in the Original Work: - -- use the Work in any circumstance and for all usage, -- reproduce the Work, -- modify the Work, and make Derivative Works based upon the Work, -- communicate to the public, including the right to make available or display - the Work or copies thereof to the public and perform publicly, as the case may - be, the Work, -- distribute the Work or copies thereof, -- lend and rent the Work or copies thereof, -- sublicense rights in the Work or copies thereof. - -Those rights can be exercised on any media, supports and formats, whether now -known or later invented, as far as the applicable law permits so. - -In the countries where moral rights apply, the Licensor waives his right to -exercise his moral right to the extent allowed by law in order to make effective -the licence of the economic rights here above listed. - -The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to -any patents held by the Licensor, to the extent necessary to make use of the -rights granted on the Work under this Licence. - -3. Communication of the Source Code - -The Licensor may provide the Work either in its Source Code form, or as -Executable Code. If the Work is provided as Executable Code, the Licensor -provides in addition a machine-readable copy of the Source Code of the Work -along with each copy of the Work that the Licensor distributes or indicates, in -a notice following the copyright notice attached to the Work, a repository where -the Source Code is easily and freely accessible for as long as the Licensor -continues to distribute or communicate the Work. - -4. Limitations on copyright - -Nothing in this Licence is intended to deprive the Licensee of the benefits from -any exception or limitation to the exclusive rights of the rights owners in the -Work, of the exhaustion of those rights or of other applicable limitations -thereto. - -5. Obligations of the Licensee - -The grant of the rights mentioned above is subject to some restrictions and -obligations imposed on the Licensee. Those obligations are the following: - -Attribution right: The Licensee shall keep intact all copyright, patent or -trademarks notices and all notices that refer to the Licence and to the -disclaimer of warranties. The Licensee must include a copy of such notices and a -copy of the Licence with every copy of the Work he/she distributes or -communicates. The Licensee must cause any Derivative Work to carry prominent -notices stating that the Work has been modified and the date of modification. - -Copyleft clause: If the Licensee distributes or communicates copies of the -Original Works or Derivative Works, this Distribution or Communication will be -done under the terms of this Licence or of a later version of this Licence -unless the Original Work is expressly distributed only under this version of the -Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee -(becoming Licensor) cannot offer or impose any additional terms or conditions on -the Work or Derivative Work that alter or restrict the terms of the Licence. - -Compatibility clause: If the Licensee Distributes or Communicates Derivative -Works or copies thereof based upon both the Work and another work licensed under -a Compatible Licence, this Distribution or Communication can be done under the -terms of this Compatible Licence. For the sake of this clause, ‘Compatible -Licence’ refers to the licences listed in the appendix attached to this Licence. -Should the Licensee's obligations under the Compatible Licence conflict with -his/her obligations under this Licence, the obligations of the Compatible -Licence shall prevail. - -Provision of Source Code: When distributing or communicating copies of the Work, -the Licensee will provide a machine-readable copy of the Source Code or indicate -a repository where this Source will be easily and freely available for as long -as the Licensee continues to distribute or communicate the Work. - -Legal Protection: This Licence does not grant permission to use the trade names, -trademarks, service marks, or names of the Licensor, except as required for -reasonable and customary use in describing the origin of the Work and -reproducing the content of the copyright notice. - -6. Chain of Authorship - -The original Licensor warrants that the copyright in the Original Work granted -hereunder is owned by him/her or licensed to him/her and that he/she has the -power and authority to grant the Licence. - -Each Contributor warrants that the copyright in the modifications he/she brings -to the Work are owned by him/her or licensed to him/her and that he/she has the -power and authority to grant the Licence. - -Each time You accept the Licence, the original Licensor and subsequent -Contributors grant You a licence to their contributions to the Work, under the -terms of this Licence. - -7. Disclaimer of Warranty - -The Work is a work in progress, which is continuously improved by numerous -Contributors. It is not a finished work and may therefore contain defects or -‘bugs’ inherent to this type of development. - -For the above reason, the Work is provided under the Licence on an ‘as is’ basis -and without warranties of any kind concerning the Work, including without -limitation merchantability, fitness for a particular purpose, absence of defects -or errors, accuracy, non-infringement of intellectual property rights other than -copyright as stated in Article 6 of this Licence. - -This disclaimer of warranty is an essential part of the Licence and a condition -for the grant of any rights to the Work. - -8. Disclaimer of Liability - -Except in the cases of wilful misconduct or damages directly caused to natural -persons, the Licensor will in no event be liable for any direct or indirect, -material or moral, damages of any kind, arising out of the Licence or of the use -of the Work, including without limitation, damages for loss of goodwill, work -stoppage, computer failure or malfunction, loss of data or any commercial -damage, even if the Licensor has been advised of the possibility of such damage. -However, the Licensor will be liable under statutory product liability laws as -far such laws apply to the Work. - -9. Additional agreements - -While distributing the Work, You may choose to conclude an additional agreement, -defining obligations or services consistent with this Licence. However, if -accepting obligations, You may act only on your own behalf and on your sole -responsibility, not on behalf of the original Licensor or any other Contributor, -and only if You agree to indemnify, defend, and hold each Contributor harmless -for any liability incurred by, or claims asserted against such Contributor by -the fact You have accepted any warranty or additional liability. - -10. Acceptance of the Licence - -The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ -placed under the bottom of a window displaying the text of this Licence or by -affirming consent in any other similar way, in accordance with the rules of -applicable law. Clicking on that icon indicates your clear and irrevocable -acceptance of this Licence and all of its terms and conditions. - -Similarly, you irrevocably accept this Licence and all of its terms and -conditions by exercising any rights granted to You by Article 2 of this Licence, -such as the use of the Work, the creation by You of a Derivative Work or the -Distribution or Communication by You of the Work or copies thereof. - -11. Information to the public - -In case of any Distribution or Communication of the Work by means of electronic -communication by You (for example, by offering to download the Work from a -remote location) the distribution channel or media (for example, a website) must -at least provide to the public the information requested by the applicable law -regarding the Licensor, the Licence and the way it may be accessible, concluded, -stored and reproduced by the Licensee. - -12. Termination of the Licence - -The Licence and the rights granted hereunder will terminate automatically upon -any breach by the Licensee of the terms of the Licence. - -Such a termination will not terminate the licences of any person who has -received the Work from the Licensee under the Licence, provided such persons -remain in full compliance with the Licence. - -13. Miscellaneous - -Without prejudice of Article 9 above, the Licence represents the complete -agreement between the Parties as to the Work. - -If any provision of the Licence is invalid or unenforceable under applicable -law, this will not affect the validity or enforceability of the Licence as a -whole. Such provision will be construed or reformed so as necessary to make it -valid and enforceable. - -The European Commission may publish other linguistic versions or new versions of -this Licence or updated versions of the Appendix, so far this is required and -reasonable, without reducing the scope of the rights granted by the Licence. New -versions of the Licence will be published with a unique version number. - -All linguistic versions of this Licence, approved by the European Commission, -have identical value. Parties can take advantage of the linguistic version of -their choice. - -14. Jurisdiction - -Without prejudice to specific agreement between parties, - -- any litigation resulting from the interpretation of this License, arising - between the European Union institutions, bodies, offices or agencies, as a - Licensor, and any Licensee, will be subject to the jurisdiction of the Court - of Justice of the European Union, as laid down in article 272 of the Treaty on - the Functioning of the European Union, - -- any litigation arising between other parties and resulting from the - interpretation of this License, will be subject to the exclusive jurisdiction - of the competent court where the Licensor resides or conducts its primary - business. - -15. Applicable Law - -Without prejudice to specific agreement between parties, - -- this Licence shall be governed by the law of the European Union Member State - where the Licensor has his seat, resides or has his registered office, - -- this licence shall be governed by Belgian law if the Licensor has no seat, - residence or registered office inside a European Union Member State. - -Appendix - -‘Compatible Licences’ according to Article 5 EUPL are: - -- GNU General Public License (GPL) v. 2, v. 3 -- GNU Affero General Public License (AGPL) v. 3 -- Open Software License (OSL) v. 2.1, v. 3.0 -- Eclipse Public License (EPL) v. 1.0 -- CeCILL v. 2.0, v. 2.1 -- Mozilla Public Licence (MPL) v. 2 -- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 -- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for - works other than software -- European Union Public Licence (EUPL) v. 1.1, v. 1.2 -- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong - Reciprocity (LiLiQ-R+). - -The European Commission may update this Appendix to later versions of the above -licences without producing a new version of the EUPL, as long as they provide -the rights granted in Article 2 of this Licence and protect the covered Source -Code from exclusive appropriation. - -All other changes or additions to this Appendix require the production of a new -EUPL version. - - diff --git a/README.md b/README.md index 567415b..97b88de 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ measurements current and detect unhealthy upstreams. $ ncro # Point at a config file -$ ncro --config /etc/ncro/config.toml +$ ncro -config /etc/ncro/config.yaml # Tell Nix to use it $ nix-shell -p hello --substituters http://localhost:8080 @@ -63,38 +63,36 @@ $ nix-shell -p hello --substituters http://localhost:8080 ## Configuration -Default config is embedded; create a TOML file to override any field. +Default config is embedded; create a YAML file to override any field. -```toml -[server] -listen = ":8080" -read_timeout = "30s" -write_timeout = "30s" +```yaml +server: + listen: ":8080" + read_timeout: 30s + write_timeout: 30s -[[upstreams]] -url = "https://cache.nixos.org" -priority = 10 # lower = preferred on latency ties (within 10%) +upstreams: + - url: "https://cache.nixos.org" + priority: 10 # lower = preferred on latency ties (within 10%) + - url: "https://nix-community.cachix.org" + priority: 20 -[[upstreams]] -url = "https://nix-community.cachix.org" -priority = 20 +cache: + db_path: "/var/lib/ncro/routes.db" + max_entries: 100000 # LRU eviction above this + ttl: 1h # how long a routing decision is trusted + latency_alpha: 0.3 # EMA smoothing factor (0 < α < 1) -[cache] -db_path = "/var/lib/ncro/routes.db" -max_entries = 100000 # LRU eviction above this -ttl = "1h" # how long a routing decision is trusted -latency_alpha = 0.3 # EMA smoothing factor (0 < alpha < 1) +logging: + level: info # debug | info | warn | error + format: json # json | text -[logging] -level = "info" # debug | info | warn | error -format = "json" # json | text - -[mesh] -enabled = false -bind_addr = "0.0.0.0:7946" -peers = [] # list of {addr, public_key} peer entries -private_key = "" # path to ed25519 key file; empty = ephemeral -gossip_interval = "30s" +mesh: + enabled: false + bind_addr: "0.0.0.0:7946" + peers: [] # list of {addr, public_key} peer entries + private_key: "" # path to ed25519 key file; empty = ephemeral + gossip_interval: 30s ``` ### Environment Overrides @@ -134,7 +132,7 @@ Systemd service: Description=Nix Cache Route Optimizer [Service] -ExecStart=ncro --config /etc/ncro/config.toml +ExecStart=ncro --config /etc/ncro/config.yaml DynamicUser=true StateDirectory=ncro Restart=on-failure @@ -159,18 +157,15 @@ Each peer entry takes an address and an optional ed25519 public key. When a public key is provided, incoming gossip packets are verified against it; packets from unlisted senders or with invalid signatures are silently dropped. -```toml -[mesh] -enabled = true -private_key = "/var/lib/ncro/node.key" - -[[mesh.peers]] -addr = "100.64.1.2:7946" -public_key = "a1b2c3..." # hex-encoded ed25519 public key (32 bytes) - -[[mesh.peers]] -addr = "100.64.1.3:7946" -public_key = "d4e5f6..." +```yaml +mesh: + enabled: true + peers: + - addr: "100.64.1.2:7946" + public_key: "a1b2c3..." # hex-encoded ed25519 public key (32 bytes) + - addr: "100.64.1.3:7946" + public_key: "d4e5f6..." + private_key: "/var/lib/ncro/node.key" ``` The node logs its public key on startup (`mesh node identity` log line). You @@ -200,10 +195,10 @@ Prometheus metrics are available at `/metrics`. # With Nix (recommended) $ nix build -# With Cargo directly -$ cargo build --release +# With Go directly +$ go build ./cmd/ncro/ # Development shell $ nix develop -$ cargo test +$ go test ./... ``` diff --git a/cmd/ncro/main.go b/cmd/ncro/main.go new file mode 100644 index 0000000..736ef31 --- /dev/null +++ b/cmd/ncro/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + Execute() +} diff --git a/cmd/ncro/root.go b/cmd/ncro/root.go new file mode 100644 index 0000000..ef5827e --- /dev/null +++ b/cmd/ncro/root.go @@ -0,0 +1,256 @@ +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.toml b/config.example.toml deleted file mode 100644 index 9f30b3f..0000000 --- a/config.example.toml +++ /dev/null @@ -1,39 +0,0 @@ -[server] -listen = ":8080" -read_timeout = "30s" -write_timeout = "30s" - -[[upstreams]] -priority = 10 -public_key = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" -url = "https://cache.nixos.org" - -# Try without a public key. -[[upstreams]] -priority = 20 -url = "https://nix-community.cachix.org" - -[cache] -db_path = "/var/lib/ncro/routes.db" -latency_alpha = 0.3 -max_entries = 100000 -negative_ttl = "10m" -ttl = "1h" - -[discovery] -discovery_time = "5s" -domain = "local" -enabled = false -priority = 20 -service_name = "_nix-serve._tcp" - -[mesh] -bind_addr = "0.0.0.0:7946" -enabled = false -gossip_interval = "30s" -peers = [ ] -private_key = "/etc/ncro/node.key" - -[logging] -format = "json" -level = "info" diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..7664f40 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,30 @@ +server: + listen: ":8080" + read_timeout: 30s + write_timeout: 30s + +upstreams: + - url: "https://cache.nixos.org" + priority: 10 + public_key: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" + + # Try without a public key + - url: "https://nix-community.cachix.org" + priority: 20 + +cache: + db_path: "/var/lib/ncro/routes.db" + max_entries: 100000 + ttl: 1h + latency_alpha: 0.3 + +mesh: + enabled: false + bind_addr: "0.0.0.0:7946" + peers: [] + private_key: "/etc/ncro/node.key" + gossip_interval: 30s + +logging: + level: "info" + format: "json" diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml deleted file mode 100644 index 6a850d1..0000000 --- a/crates/config/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "ncro-config" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -hex.workspace = true -humantime-serde.workspace = true -serde = { workspace = true, features = [ "derive" ] } -thiserror.workspace = true -toml.workspace = true -url.workspace = true - -[lints] -workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs deleted file mode 100644 index 6b9c71e..0000000 --- a/crates/config/src/lib.rs +++ /dev/null @@ -1,336 +0,0 @@ -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] toml::de::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_toml() -> Result<(), toml::de::Error> { - let cfg: Config = toml::from_str( - "[server]\nread_timeout = \"30s\"\n\n[cache]\nttl = \"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)?; - toml::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/crates/db/Cargo.toml b/crates/db/Cargo.toml deleted file mode 100644 index 7317ab8..0000000 --- a/crates/db/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "ncro-db" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -chrono = { workspace = true, features = [ "serde" ] } -serde = { workspace = true, features = [ "derive" ] } -sqlx = { workspace = true, features = [ "runtime-tokio-rustls", "sqlite", "macros", "migrate", "chrono" ] } -thiserror.workspace = true -tokio = { workspace = true, features = [ "fs" ] } - -[lints] -workspace = true diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs deleted file mode 100644 index 6bd430d..0000000 --- a/crates/db/src/lib.rs +++ /dev/null @@ -1,395 +0,0 @@ -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 { - #[must_use] - 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/crates/discovery/Cargo.toml b/crates/discovery/Cargo.toml deleted file mode 100644 index 9eb5f53..0000000 --- a/crates/discovery/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "ncro-discovery" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -anyhow.workspace = true -mdns-sd.workspace = true -ncro-config.workspace = true -ncro-health.workspace = true -tokio = { workspace = true, features = [ "rt", "sync", "time" ] } -tracing.workspace = true - -[lints] -workspace = true diff --git a/crates/discovery/src/lib.rs b/crates/discovery/src/lib.rs deleted file mode 100644 index 45197aa..0000000 --- a/crates/discovery/src/lib.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::{ - collections::HashMap, - sync::Arc, - time::{Duration, Instant}, -}; - -use mdns_sd::{ServiceDaemon, ServiceEvent}; -use ncro_config::DiscoveryConfig; -use ncro_health::Prober; -use tokio::sync::{Mutex, watch}; - -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/crates/health/Cargo.toml b/crates/health/Cargo.toml deleted file mode 100644 index 7fe77f6..0000000 --- a/crates/health/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "ncro-health" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - - -[dependencies] -ncro-config.workspace = true -reqwest = { workspace = true, features = [ "rustls" ] } -tokio = { workspace = true, features = [ "macros", "sync", "time", "rt" ] } - -[lints] -workspace = true diff --git a/crates/health/src/lib.rs b/crates/health/src/lib.rs deleted file mode 100644 index cf38748..0000000 --- a/crates/health/src/lib.rs +++ /dev/null @@ -1,311 +0,0 @@ -use std::{ - cmp::Ordering, - collections::HashMap, - sync::Arc, - time::{Duration, Instant}, -}; - -use ncro_config::UpstreamConfig; -use tokio::sync::RwLock; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Status { - Active, - Degraded, - Down, -} - -impl Status { - #[must_use] - 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 { - #[must_use] - 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/crates/mesh/Cargo.toml b/crates/mesh/Cargo.toml deleted file mode 100644 index 69f2bde..0000000 --- a/crates/mesh/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "ncro-mesh" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -chrono.workspace = true -ed25519-dalek = { workspace = true, features = [ "rand_core" ] } -hex.workspace = true -ncro-db.workspace = true -rand.workspace = true -rmp-serde.workspace = true -serde = { workspace = true, features = [ "derive" ] } -thiserror.workspace = true -tokio = { workspace = true, features = [ "fs", "net", "rt", "sync", "time" ] } -tracing.workspace = true - -[lints] -workspace = true diff --git a/crates/mesh/src/lib.rs b/crates/mesh/src/lib.rs deleted file mode 100644 index 711d7a4..0000000 --- a/crates/mesh/src/lib.rs +++ /dev/null @@ -1,230 +0,0 @@ -use std::{path::Path, sync::Arc}; - -use chrono::Utc; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; -use ncro_db::{Db, RouteEntry}; -use rand::RngExt; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::{net::UdpSocket, time::Duration}; - -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::from_bytes(&random_key_bytes())), - }); - } - 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::from_bytes(&random_key_bytes()); - tokio::fs::write(key_path, key.to_bytes()).await?; - Ok(Self { - signing_key: Arc::new(key), - }) - } - - #[must_use] - pub fn id(&self) -> String { - hex::encode(&self.public_key()[..8]) - } - #[must_use] - 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(), - )) - } -} - -fn random_key_bytes() -> [u8; 32] { - let mut bytes = [0_u8; 32]; - rand::rng().fill(&mut bytes); - bytes -} - -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/crates/metrics/Cargo.toml b/crates/metrics/Cargo.toml deleted file mode 100644 index cc0d019..0000000 --- a/crates/metrics/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "ncro-metrics" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -prometheus.workspace = true - -[lints] -workspace = true diff --git a/crates/metrics/src/lib.rs b/crates/metrics/src/lib.rs deleted file mode 100644 index 443d7fd..0000000 --- a/crates/metrics/src/lib.rs +++ /dev/null @@ -1,110 +0,0 @@ -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, - } - }) -} - -#[must_use] -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/crates/narinfo/Cargo.toml b/crates/narinfo/Cargo.toml deleted file mode 100644 index 1c08cb5..0000000 --- a/crates/narinfo/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "ncro-narinfo" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -base64.workspace = true -ed25519-dalek.workspace = true -thiserror.workspace = true - -[dev-dependencies] -ed25519-dalek = { workspace = true, features = [ "rand_core" ] } -rand.workspace = true - -[lints] -workspace = true diff --git a/crates/narinfo/src/lib.rs b/crates/narinfo/src/lib.rs deleted file mode 100644 index 041fec9..0000000 --- a/crates/narinfo/src/lib.rs +++ /dev/null @@ -1,210 +0,0 @@ -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::RngExt; - - 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 mut key_bytes = [0_u8; 32]; - rand::rng().fill(&mut key_bytes); - let signing = SigningKey::from_bytes(&key_bytes); - 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) - } - - #[must_use] - 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/crates/router/Cargo.toml b/crates/router/Cargo.toml deleted file mode 100644 index 1f878bf..0000000 --- a/crates/router/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "ncro-router" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -chrono.workspace = true -futures-util.workspace = true -ncro-db.workspace = true -ncro-health.workspace = true -ncro-metrics.workspace = true -ncro-narinfo.workspace = true -reqwest = { workspace = true, features = [ "rustls" ] } -thiserror.workspace = true -tokio = { workspace = true, features = [ "sync", "time", "rt" ] } -tracing.workspace = true - -[lints] -workspace = true diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs deleted file mode 100644 index 0d95590..0000000 --- a/crates/router/src/lib.rs +++ /dev/null @@ -1,306 +0,0 @@ -use std::{ - collections::HashMap, - sync::Arc, - time::{Duration, Instant}, -}; - -use chrono::Utc; -use futures_util::{StreamExt, stream::FuturesUnordered}; -use ncro_db::{Db, DbError, RouteEntry}; -use ncro_health::{Prober, Status}; -use ncro_narinfo::{NarInfo, NarInfoError, parse_public_key}; -use thiserror::Error; -use tokio::sync::{Mutex, RwLock}; - -#[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] 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 { - #[must_use] - 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<(), NarInfoError> { - 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); - } - ncro_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); - } - ncro_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) - }; - }; - - ncro_metrics::get() - .upstream_race_wins - .with_label_values(&[&winner.url]) - .inc(); - ncro_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/crates/server/Cargo.toml b/crates/server/Cargo.toml deleted file mode 100644 index 94355de..0000000 --- a/crates/server/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "ncro-server" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -axum = { workspace = true, features = [ "macros" ] } -bytes.workspace = true -futures-util.workspace = true -ncro-config.workspace = true -ncro-db.workspace = true -ncro-health.workspace = true -ncro-metrics.workspace = true -ncro-router.workspace = true -reqwest = { workspace = true, features = [ "rustls", "stream" ] } -serde = { workspace = true, features = [ "derive" ] } -tracing.workspace = true - -[lints] -workspace = true diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs deleted file mode 100644 index e3c49cf..0000000 --- a/crates/server/src/lib.rs +++ /dev/null @@ -1,297 +0,0 @@ -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 ncro_config::UpstreamConfig; -use ncro_db::Db; -use ncro_health::{Prober, Status}; -use ncro_router::{Router, RouterError}; -use serde::Serialize; - -#[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")], - ncro_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" - ); - ncro_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) => { - ncro_metrics::get() - .narinfo_requests - .with_label_values(&["error"]) - .inc(); - StatusCode::NOT_FOUND.into_response() - }, - Err(err) => { - tracing::warn!(hash, error = %err, "narinfo resolve failed"); - ncro_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 { - ncro_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()) -} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..145f52b --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..22948cf --- /dev/null +++ b/go.sum @@ -0,0 +1,153 @@ +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 new file mode 100644 index 0000000..3932bbc --- /dev/null +++ b/internal/cache/db.go @@ -0,0 +1,313 @@ +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 new file mode 100644 index 0000000..b74dbab --- /dev/null +++ b/internal/cache/db_test.go @@ -0,0 +1,324 @@ +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 new file mode 100644 index 0000000..dd20ee1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,220 @@ +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 new file mode 100644 index 0000000..b71801e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,213 @@ +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 new file mode 100644 index 0000000..3755cca --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,218 @@ +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 new file mode 100644 index 0000000..a5f10f6 --- /dev/null +++ b/internal/mesh/gossip.go @@ -0,0 +1,152 @@ +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 new file mode 100644 index 0000000..fcb82bc --- /dev/null +++ b/internal/mesh/gossip_test.go @@ -0,0 +1,117 @@ +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 new file mode 100644 index 0000000..ee3e04f --- /dev/null +++ b/internal/mesh/mesh.go @@ -0,0 +1,152 @@ +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 new file mode 100644 index 0000000..f6fdf2b --- /dev/null +++ b/internal/mesh/mesh_test.go @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..8dda137 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..3a7444b --- /dev/null +++ b/internal/narinfo/narinfo.go @@ -0,0 +1,139 @@ +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 new file mode 100644 index 0000000..a7b8463 --- /dev/null +++ b/internal/narinfo/narinfo_test.go @@ -0,0 +1,318 @@ +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 new file mode 100644 index 0000000..3bf8dd8 --- /dev/null +++ b/internal/prober/prober.go @@ -0,0 +1,267 @@ +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 new file mode 100644 index 0000000..2521bf8 --- /dev/null +++ b/internal/prober/prober_test.go @@ -0,0 +1,188 @@ +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 new file mode 100644 index 0000000..872e470 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,258 @@ +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 new file mode 100644 index 0000000..4e56140 --- /dev/null +++ b/internal/router/router_test.go @@ -0,0 +1,251 @@ +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 new file mode 100644 index 0000000..eee8d82 --- /dev/null +++ b/internal/server/integration_test.go @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000..8679f17 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,252 @@ +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 new file mode 100644 index 0000000..533da21 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,487 @@ +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/ncro/Cargo.toml b/ncro/Cargo.toml deleted file mode 100644 index 0765852..0000000 --- a/ncro/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "ncro" -version.workspace = true -edition.workspace = true -license.workspace = true -description.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -anyhow.workspace = true -axum.workspace = true -clap = { workspace = true, features = [ "derive", "env" ] } -hex.workspace = true -tokio = { workspace = true, features = [ "macros", "rt-multi-thread", "signal", "time", "net", "fs" ] } -tracing.workspace = true -tracing-subscriber = { workspace = true, features = [ "env-filter", "json" ] } - -ncro-config.workspace = true -ncro-db.workspace = true -ncro-discovery.workspace = true -ncro-health.workspace = true -ncro-mesh.workspace = true -ncro-metrics.workspace = true -ncro-router.workspace = true -ncro-server.workspace = true - -[dev-dependencies] -tempfile.workspace = true -tower = { workspace = true, features = [ "util" ] } - -[lints] -workspace = true diff --git a/ncro/src/cli.rs b/ncro/src/cli.rs deleted file mode 100644 index 990b76a..0000000 --- a/ncro/src/cli.rs +++ /dev/null @@ -1,185 +0,0 @@ -use clap::Parser; -use ncro_config::Config; -use ncro_db::Db; -use ncro_discovery::Discovery; -use ncro_health::Prober; -use ncro_router::Router; -use tokio::net::TcpListener; -use tracing_subscriber::{EnvFilter, fmt}; - -#[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 _ = ncro_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 { ncro_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 = ncro_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::>(); - ncro_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(ncro_mesh::run_gossip_loop( - node, - db.clone(), - peers, - cfg.mesh.gossip_interval.0, - stop_rx.clone(), - )); - } - - let app = ncro_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/ncro/src/main.rs b/ncro/src/main.rs deleted file mode 100644 index 7f868a3..0000000 --- a/ncro/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod cli; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - cli::run().await -} diff --git a/nix/module.nix b/nix/module.nix index 46daedb..5b70e76 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -7,10 +7,10 @@ self: { inherit (lib.modules) mkIf; inherit (lib.options) mkOption mkEnableOption mkPackageOption; - format = pkgs.formats.toml {}; + format = pkgs.formats.yaml {}; cfg = config.services.ncro; - configFile = format.generate "ncro.toml" cfg.settings; + configFile = format.generate "ncro.yaml" cfg.settings; in { options.services.ncro = { enable = mkEnableOption "ncro, the Nix cache route optimizer"; @@ -22,7 +22,7 @@ in { default = {}; description = '' ncro configuration as an attribute set. Keys and structure match the - TOML config file format; all defaults are handled by the ncro binary. + YAML config file format; all defaults are handled by the ncro binary. ''; example = { logging.level = "info"; diff --git a/nix/package.nix b/nix/package.nix index a902d17..70d02ab 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,9 +1,8 @@ { lib, - rustPlatform, - pkg-config, + buildGoModule, }: -rustPlatform.buildRustPackage (finalAttrs: { +buildGoModule (finalAttrs: { pname = "ncro"; version = "1.0.0"; @@ -14,14 +13,15 @@ rustPlatform.buildRustPackage (finalAttrs: { fs.toSource { root = s; fileset = fs.unions [ - (s + /src) - (s + /Cargo.toml) - (s + /Cargo.lock) + (s + /cmd) + (s + /internal) + (s + /go.mod) + (s + /go.sum) ]; }; - cargoLock.lockFile = "${finalAttrs.src}/Cargo.lock"; - nativeBuildInputs = [pkg-config]; + vendorHash = "sha256-9OkQIj2g5mZ+IpjIKvy8Il7J4xL4PJimEsXJP10FhmU="; + ldflags = ["-s" "-w" "-X main.version=${finalAttrs.version}"]; meta = { mainProgram = "ncro"; diff --git a/nix/shell.nix b/nix/shell.nix index 21514ee..a144088 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,25 +1,18 @@ { mkShell, - cargo, - clippy, - pkg-config, - rust-analyzer, - rustc, - rustfmt, - taplo, + go, + gopls, + delve, + gofumpt, + golines, }: mkShell { - name = "rust"; - - strictDeps = true; - nativeBuildInputs = [ - cargo - rustc - pkg-config - - rust-analyzer - clippy - (rustfmt.override {asNightly = true;}) - taplo + name = "go"; + packages = [ + delve + go + gopls + gofumpt + golines ]; }