diff --git a/api-server/Cargo.lock b/api-server/Cargo.lock index 03f59ae..325c605 100644 --- a/api-server/Cargo.lock +++ b/api-server/Cargo.lock @@ -73,7 +73,7 @@ dependencies = [ "async-graphql-axum", "async-trait", "axum 0.8.9", - "base64", + "base64 0.22.1", "brotli", "chrono", "dashmap", @@ -99,10 +99,12 @@ dependencies = [ "serde_json", "sha2", "stellar-strkey", + "testcontainers", + "testcontainers-modules", "tokio", "tokio-stream", "tokio-tungstenite 0.21.0", - "tonic", + "tonic 0.12.3", "tower 0.5.3", "tower-http", "tracing", @@ -127,6 +129,22 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-graphql" version = "7.2.1" @@ -139,7 +157,7 @@ dependencies = [ "async-io", "async-trait", "asynk-strim", - "base64", + "base64 0.22.1", "bytes", "fast_chemail", "fnv", @@ -236,6 +254,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -347,7 +376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core 0.5.6", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -415,6 +444,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "base32" version = "0.4.0" @@ -427,12 +465,24 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -448,6 +498,80 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.0", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.4", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic 0.14.6", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost 0.14.4", + "prost-types", + "tonic 0.14.6", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost 0.14.4", + "serde", + "serde_json", + "serde_repr", + "time", +] + [[package]] name = "brotli" version = "7.0.0" @@ -469,6 +593,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -514,6 +647,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -591,6 +735,15 @@ 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 = "crate-git-revision" version = "0.0.6" @@ -643,7 +796,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -751,6 +904,37 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.7.10" @@ -766,6 +950,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derive_builder" @@ -820,12 +1007,29 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ed25519" version = "2.2.3" @@ -881,6 +1085,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fast_chemail" version = "0.9.6" @@ -896,6 +1130,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.2", + "web-time", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1083,6 +1328,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1241,6 +1487,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -1278,7 +1538,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1295,6 +1555,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1436,6 +1711,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1480,6 +1756,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1507,7 +1836,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -1604,7 +1933,7 @@ version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4f0c8427b39666bf970460908b213ec09b3b350f20c0c2eabcbba51704a08e6" dependencies = [ - "base64", + "base64 0.22.1", "http-body-util", "hyper", "hyper-rustls", @@ -1687,6 +2016,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.6", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1697,6 +2040,25 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1712,6 +2074,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.4" @@ -1727,6 +2099,17 @@ dependencies = [ "num-modular", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.6", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1784,10 +2167,10 @@ dependencies = [ "opentelemetry", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.13.5", "thiserror 1.0.69", "tokio", - "tonic", + "tonic 0.12.3", ] [[package]] @@ -1798,8 +2181,8 @@ checksum = "30ee9f20bff9c984511a02f082dc8ede839e4a9bf15cc2487c8d6fea5ad850d9" dependencies = [ "opentelemetry", "opentelemetry_sdk", - "prost", - "tonic", + "prost 0.13.5", + "tonic 0.12.3", ] [[package]] @@ -1836,16 +2219,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] -name = "parking_lot_core" -version = "0.9.12" +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", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.118", ] [[package]] @@ -1854,7 +2262,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -1963,6 +2371,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2036,7 +2453,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -2052,6 +2479,28 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost 0.14.4", +] + [[package]] name = "quanta" version = "0.12.6" @@ -2164,6 +2613,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2202,13 +2662,47 @@ 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 = "raw-cpuid" version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags", + "bitflags 2.13.0", +] + +[[package]] +name = "redis" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint 0.5.1", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.6.4", + "tokio", + "tokio-util", + "url", + "xxhash-rust", ] [[package]] @@ -2241,7 +2735,27 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", ] [[package]] @@ -2279,7 +2793,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -2346,7 +2860,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -2424,6 +2938,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2436,7 +2974,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation", "core-foundation-sys", "libc", @@ -2513,6 +3051,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2525,6 +3074,39 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2532,7 +3114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2549,7 +3131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2568,6 +3150,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -2589,7 +3181,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "thiserror 2.0.18", "time", @@ -2678,6 +3270,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.118", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "strum" version = "0.27.2" @@ -2726,6 +3341,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2759,6 +3385,46 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "testcontainers" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" +dependencies = [ + "testcontainers", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2873,6 +3539,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", @@ -2988,7 +3655,7 @@ dependencies = [ "async-stream", "async-trait", "axum 0.7.9", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -2999,7 +3666,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", + "prost 0.13.5", "socket2 0.5.10", "tokio", "tokio-stream", @@ -3009,6 +3676,46 @@ dependencies = [ "tracing", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum 0.8.9", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.4", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost 0.14.4", + "tonic 0.14.6", +] + [[package]] name = "tower" version = "0.4.13" @@ -3037,9 +3744,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -3051,7 +3761,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -3234,6 +3944,33 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -3244,6 +3981,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -3252,6 +3990,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3673,6 +4417,22 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "yoke" version = "0.8.3" diff --git a/api-server/src/rate_limit.rs b/api-server/src/rate_limit.rs index 2aa4a5a..1934a4b 100644 --- a/api-server/src/rate_limit.rs +++ b/api-server/src/rate_limit.rs @@ -1,8 +1,14 @@ //! Concurrent token-bucket rate limiting for HTTP requests. //! //! Limits are enforced atomically across global, source-IP, and authenticated -//! user scopes. A single lock deliberately covers the check-and-consume step: -//! a request can never consume one quota and then fail another quota. +//! user scopes: a request can never consume one quota and then fail another +//! quota. The token-bucket accounting itself lives behind the [`RateLimitStore`] +//! trait so it can be backed either by process-local memory (the default, +//! [`InProcessStore`]) or by a shared Redis instance ([`RedisStore`]) so that +//! multiple replicas behind a load balancer enforce one combined quota per +//! client instead of one quota per replica. Everything else (tier lookup, +//! violation backoff, cardinality bounding) stays process-local in both +//! modes, since it does not need to be shared for the quota to be correct. use crate::auth::AuthExtension; use axum::{ @@ -56,6 +62,24 @@ impl BucketQuota { } } +/// Where token-bucket counters are stored. See the module docs for how this +/// interacts with multi-instance deployments. +#[derive(Debug, Clone, Default)] +pub enum RateLimitBackend { + /// Counters live in this process's memory only. Each replica enforces + /// its own independent quota — **not safe for a multi-instance + /// deployment**, where a client distributed across N replicas would + /// effectively receive N times the documented quota. Use this only for + /// tests and single-instance deployments. + #[default] + InProcess, + /// Counters live in Redis at the given connection URL (e.g. + /// `redis://127.0.0.1:6379`), shared by every replica that points at the + /// same instance. Required for correct quota enforcement behind a load + /// balancer running multiple replicas. + Redis(String), +} + #[derive(Debug, Clone)] pub struct RateLimitConfig { pub global: BucketQuota, @@ -71,6 +95,10 @@ pub struct RateLimitConfig { pub max_tracked_ips: usize, pub max_tracked_users: usize, pub idle_ttl: Duration, + /// Where token-bucket counters are stored. Defaults to + /// [`RateLimitBackend::InProcess`]; set to [`RateLimitBackend::Redis`] + /// for correct enforcement across multiple replicas. + pub backend: RateLimitBackend, } impl Default for RateLimitConfig { @@ -87,6 +115,7 @@ impl Default for RateLimitConfig { max_tracked_ips: 100_000, max_tracked_users: 100_000, idle_ttl: Duration::from_secs(15 * 60), + backend: RateLimitBackend::InProcess, } } } @@ -125,6 +154,212 @@ impl Bucket { } } +/// Result of atomically refilling and (if every bucket had capacity) +/// consuming one token from a set of buckets. +#[derive(Debug, Clone)] +struct RefillResult { + allowed: bool, + /// Post-refill, pre-consumption token count for each bucket, aligned + /// with the order the buckets were requested in. + tokens: Vec, +} + +/// Storage for token-bucket counters. Implementations must refill and, if +/// (and only if) every requested bucket has at least one token available, +/// consume one token from all of them as a single atomic step — a request +/// must never consume one quota and then fail another. +#[async_trait::async_trait] +trait RateLimitStore: Send + Sync + std::fmt::Debug { + async fn refill_and_consume( + &self, + buckets: &[(String, BucketQuota)], + now: Instant, + idle_ttl: Duration, + ) -> RefillResult; +} + +/// Process-local counter storage. Not safe for multi-instance deployment — +/// see [`RateLimitBackend::InProcess`]. +#[derive(Debug, Default)] +struct InProcessStore { + buckets: Mutex, +} + +#[derive(Debug, Default)] +struct InProcessBuckets { + buckets: HashMap, + checks: u64, +} + +#[async_trait::async_trait] +impl RateLimitStore for InProcessStore { + async fn refill_and_consume( + &self, + buckets: &[(String, BucketQuota)], + now: Instant, + idle_ttl: Duration, + ) -> RefillResult { + let mut store = self.buckets.lock().unwrap(); + store.checks += 1; + if store.checks % 1024 == 0 { + store + .buckets + .retain(|_, b| now.saturating_duration_since(b.last_seen) < idle_ttl); + } + + let mut tokens = Vec::with_capacity(buckets.len()); + for (key, quota) in buckets { + let bucket = store + .buckets + .entry(key.clone()) + .or_insert_with(|| Bucket::full(*quota, now)); + bucket.refill(*quota, now); + tokens.push(bucket.tokens); + } + + let allowed = tokens.iter().all(|t| *t >= 1.0); + if allowed { + for (key, _) in buckets { + store.buckets.get_mut(key).unwrap().tokens -= 1.0; + } + } + RefillResult { allowed, tokens } + } +} + +/// Refills and checks every bucket, then consumes from all of them, in a +/// single Lua script executed atomically by Redis. Time comes from Redis's +/// own `TIME` command rather than the caller's clock, so refill accounting +/// cannot be skewed by clock drift between application replicas. +const REFILL_AND_CONSUME_SCRIPT: &str = r#" +local n = tonumber(ARGV[1]) +local ttl = tonumber(ARGV[2]) +local time = redis.call('TIME') +local now = tonumber(time[1]) + tonumber(time[2]) / 1000000 + +local tokens = {} +for i = 1, n do + local rate = tonumber(ARGV[2 + i]) + local burst = tonumber(ARGV[2 + n + i]) + local state = redis.call('HMGET', KEYS[i], 'tokens', 'updated_at') + local cur = tonumber(state[1]) + local updated_at = tonumber(state[2]) + if cur == nil then + cur = burst + updated_at = now + end + local elapsed = now - updated_at + if elapsed < 0 then + elapsed = 0 + end + cur = math.min(cur + elapsed * rate, burst) + tokens[i] = cur +end + +local allowed = true +for i = 1, n do + if tokens[i] < 1.0 then + allowed = false + end +end + +local reply = { allowed and 1 or 0 } +for i = 1, n do + local final_tokens = tokens[i] + if allowed then + final_tokens = final_tokens - 1.0 + end + redis.call('HSET', KEYS[i], 'tokens', tostring(final_tokens), 'updated_at', tostring(now)) + redis.call('EXPIRE', KEYS[i], ttl) + reply[#reply + 1] = tostring(tokens[i]) +end +return reply +"#; + +/// Shared counter storage backed by Redis. See [`RateLimitBackend::Redis`]. +/// +/// A connection is established lazily on first use rather than in the +/// (synchronous, infallible) constructor. If Redis is unreachable or the +/// script invocation fails, the store fails open — it allows the request and +/// logs a warning — so a Redis outage degrades to unlimited-but-available +/// rather than taking the whole API down with false `429`s. +#[derive(Debug)] +struct RedisStore { + client: redis::Client, + script: redis::Script, + conn: tokio::sync::OnceCell, +} + +impl RedisStore { + fn new(url: &str) -> redis::RedisResult { + Ok(Self { + client: redis::Client::open(url)?, + script: redis::Script::new(REFILL_AND_CONSUME_SCRIPT), + conn: tokio::sync::OnceCell::new(), + }) + } + + async fn connection(&self) -> redis::RedisResult { + self.conn + .get_or_try_init(|| async { + redis::aio::ConnectionManager::new(self.client.clone()).await + }) + .await + .cloned() + } +} + +#[async_trait::async_trait] +impl RateLimitStore for RedisStore { + async fn refill_and_consume( + &self, + buckets: &[(String, BucketQuota)], + _now: Instant, + idle_ttl: Duration, + ) -> RefillResult { + let fail_open = || RefillResult { + allowed: true, + tokens: buckets.iter().map(|(_, q)| q.burst as f64).collect(), + }; + + let mut conn = match self.connection().await { + Ok(conn) => conn, + Err(err) => { + tracing::warn!(error = %err, "rate-limit redis backend unreachable, failing open"); + return fail_open(); + } + }; + + let mut invocation = self.script.prepare_invoke(); + for (key, _) in buckets { + invocation.key(key); + } + invocation.arg(buckets.len()); + invocation.arg(idle_ttl.as_secs().max(1)); + for (_, quota) in buckets { + invocation.arg(quota.refill_per_second()); + } + for (_, quota) in buckets { + invocation.arg(quota.burst); + } + + let reply: Vec = match invocation.invoke_async(&mut conn).await { + Ok(reply) => reply, + Err(err) => { + tracing::warn!(error = %err, "rate-limit redis script failed, failing open"); + return fail_open(); + } + }; + + let allowed = reply.first().map(|v| v == "1").unwrap_or(true); + let tokens = reply[1..] + .iter() + .map(|v| v.parse::().unwrap_or(f64::MAX)) + .collect(); + RefillResult { allowed, tokens } + } +} + #[derive(Debug, Default, Clone)] struct Violation { count: u32, @@ -134,22 +369,29 @@ struct Violation { limit: u32, } -#[derive(Debug)] -struct Store { - global: Bucket, - ips: HashMap, - users: HashMap, +/// Process-local bookkeeping that intentionally stays per-instance even with +/// a shared [`RateLimitStore`]: violation backoff state and tracked-identity +/// cardinality bounding are memory-protection heuristics for this replica, +/// not part of the documented quota, so they don't need to be distributed +/// for the quota fix to be correct. `user_tiers` also stays local, matching +/// the pre-existing behavior of `set_user_tier`. +#[derive(Debug, Default)] +struct LocalState { + cardinality_ips: HashMap, + cardinality_users: HashMap, violations: HashMap, user_tiers: HashMap, checks: u64, } -/// Cloneable middleware state. Instances are application-owned, making tests and -/// multiple server instances independent. +/// Cloneable middleware state. Instances are application-owned; whether +/// multiple instances (e.g. one per replica) share one enforced quota +/// depends on `RateLimitConfig::backend` — see [`RateLimitBackend`]. #[derive(Debug, Clone)] pub struct RateLimitMiddleware { config: Arc, - store: Arc>, + store: Arc, + local: Arc>, } #[derive(Debug)] @@ -178,123 +420,145 @@ impl RateLimitMiddleware { ); assert!(quota.burst > 0, "rate-limit burst must be positive"); } - let now = Instant::now(); - let global = Bucket::full(config.global, now); + let store: Arc = match &config.backend { + RateLimitBackend::InProcess => Arc::new(InProcessStore::default()), + RateLimitBackend::Redis(url) => { + Arc::new(RedisStore::new(url).expect("invalid redis rate-limit backend URL")) + } + }; Self { config: Arc::new(config), - store: Arc::new(Mutex::new(Store { - global, - ips: HashMap::new(), - users: HashMap::new(), - violations: HashMap::new(), - user_tiers: HashMap::new(), - checks: 0, - })), + store, + local: Arc::new(Mutex::new(LocalState::default())), } } /// Assign a verified user to a billing tier. Unknown users use the free tier. pub fn set_user_tier(&self, user_id: impl Into, tier: RateLimitTier) { - self.store + self.local .lock() .unwrap() .user_tiers .insert(user_id.into(), tier); } - fn check(&self, ip: &str, user: Option<&str>, now: Instant) -> Decision { - let mut store = self.store.lock().unwrap(); - store.checks += 1; - if store.checks % 1024 == 0 { - let ttl = self.config.idle_ttl; - store - .ips - .retain(|_, b| now.saturating_duration_since(b.last_seen) < ttl); - store - .users - .retain(|_, b| now.saturating_duration_since(b.last_seen) < ttl); - store.violations.retain(|_, v| { - v.last_seen - .map(|t| now.saturating_duration_since(t) < ttl) - .unwrap_or(false) - }); - } + async fn check(&self, ip: &str, user: Option<&str>, now: Instant) -> Decision { + let (tier, violation_key, bucket_keys, early_denial) = { + let mut local = self.local.lock().unwrap(); + local.checks += 1; + if local.checks % 1024 == 0 { + let ttl = self.config.idle_ttl; + local + .cardinality_ips + .retain(|_, t| now.saturating_duration_since(*t) < ttl); + local + .cardinality_users + .retain(|_, t| now.saturating_duration_since(*t) < ttl); + local.violations.retain(|_, v| { + v.last_seen + .map(|t| now.saturating_duration_since(t) < ttl) + .unwrap_or(false) + }); + } - let tier = user - .and_then(|u| store.user_tiers.get(u).copied()) - .unwrap_or(RateLimitTier::Free); - let user_quota = self.config.quota_for(tier); - - // Cardinality overflow keys ensure unknown identities remain limited without - // allowing unbounded memory allocation during a distributed attack. - let ip_key = if store.ips.contains_key(ip) || store.ips.len() < self.config.max_tracked_ips - { - ip.to_owned() - } else { - "__overflow__".to_owned() - }; - let user_key = user.map(|u| { - if store.users.contains_key(u) || store.users.len() < self.config.max_tracked_users { - u.to_owned() + let tier = user + .and_then(|u| local.user_tiers.get(u).copied()) + .unwrap_or(RateLimitTier::Free); + let user_quota = self.config.quota_for(tier); + + // Cardinality overflow keys ensure unknown identities remain limited without + // allowing unbounded memory allocation during a distributed attack. + let ip_key = if local.cardinality_ips.contains_key(ip) + || local.cardinality_ips.len() < self.config.max_tracked_ips + { + ip.to_owned() } else { "__overflow__".to_owned() + }; + local.cardinality_ips.insert(ip_key.clone(), now); + + let user_key = user.map(|u| { + if local.cardinality_users.contains_key(u) + || local.cardinality_users.len() < self.config.max_tracked_users + { + u.to_owned() + } else { + "__overflow__".to_owned() + } + }); + if let Some(ref key) = user_key { + local.cardinality_users.insert(key.clone(), now); } - }); - let violation_key = user_key - .as_ref() - .map(|u| format!("user:{u}")) - .unwrap_or_else(|| format!("ip:{ip_key}")); - - if let Some(until) = store - .violations - .get(&violation_key) - .and_then(|v| v.blocked_until) - { - if until > now { - // Retrying inside the advertised penalty is itself a repeated - // violation, so abusive tight loops rapidly reach the cap. - let violation = store.violations.get_mut(&violation_key).unwrap(); - violation.count = violation.count.saturating_add(1).min(31); - violation.last_seen = Some(now); - let multiplier = 1u32 << (violation.count - 1).min(16); - let penalty = self - .config - .base_backoff - .saturating_mul(multiplier) - .min(self.config.max_backoff); - let retry = until.duration_since(now).max(penalty); - violation.blocked_until = Some(now + retry); - return Decision { - allowed: false, - limit: violation.limit, - remaining: 0, - reset_after: retry, - retry_after: retry, - scope: violation.scope, - tier, - }; + + let violation_key = user_key + .as_ref() + .map(|u| format!("user:{u}")) + .unwrap_or_else(|| format!("ip:{ip_key}")); + + let early_denial = if let Some(until) = local + .violations + .get(&violation_key) + .and_then(|v| v.blocked_until) + { + if until > now { + // Retrying inside the advertised penalty is itself a repeated + // violation, so abusive tight loops rapidly reach the cap. + let violation = local.violations.get_mut(&violation_key).unwrap(); + violation.count = violation.count.saturating_add(1).min(31); + violation.last_seen = Some(now); + let multiplier = 1u32 << (violation.count - 1).min(16); + let penalty = self + .config + .base_backoff + .saturating_mul(multiplier) + .min(self.config.max_backoff); + let retry = until.duration_since(now).max(penalty); + violation.blocked_until = Some(now + retry); + Some(Decision { + allowed: false, + limit: violation.limit, + remaining: 0, + reset_after: retry, + retry_after: retry, + scope: violation.scope, + tier, + }) + } else { + None + } + } else { + None + }; + + let mut bucket_keys: Vec<(String, BucketQuota, &'static str)> = vec![ + ("global".to_owned(), self.config.global, "global"), + (format!("ip:{ip_key}"), self.config.per_ip, "ip"), + ]; + if let Some(ref key) = user_key { + bucket_keys.push((format!("user:{key}"), user_quota, "user")); } - } - store.global.refill(self.config.global, now); - let ip_bucket = store - .ips - .entry(ip_key.clone()) - .or_insert_with(|| Bucket::full(self.config.per_ip, now)); - ip_bucket.refill(self.config.per_ip, now); - if let Some(ref key) = user_key { - store - .users - .entry(key.clone()) - .or_insert_with(|| Bucket::full(user_quota, now)) - .refill(user_quota, now); - } + (tier, violation_key, bucket_keys, early_denial) + }; - let mut candidates = vec![("global", self.config.global, store.global.tokens)]; - candidates.push(("ip", self.config.per_ip, store.ips[&ip_key].tokens)); - if let Some(ref key) = user_key { - candidates.push(("user", user_quota, store.users[key].tokens)); + if let Some(decision) = early_denial { + return decision; } + + let store_buckets: Vec<(String, BucketQuota)> = bucket_keys + .iter() + .map(|(key, quota, _)| (key.clone(), *quota)) + .collect(); + let result = self + .store + .refill_and_consume(&store_buckets, now, self.config.idle_ttl) + .await; + + let mut candidates: Vec<(&'static str, BucketQuota, f64)> = bucket_keys + .iter() + .zip(result.tokens.iter()) + .map(|((_, quota, scope), tokens)| (*scope, *quota, *tokens)) + .collect(); // Report the bucket with the least proportional quota remaining. candidates.sort_by(|a, b| { (a.2 / a.1.burst as f64) @@ -303,17 +567,22 @@ impl RateLimitMiddleware { .then_with(|| a.1.burst.cmp(&b.1.burst)) }); let (scope, quota, tokens) = candidates[0]; - let exhausted = candidates - .iter() - .filter(|(_, _, t)| *t < 1.0) - .map(|(scope, quota, tokens)| { - let wait = Duration::from_secs_f64((1.0 - tokens) / quota.refill_per_second()); - (*scope, *quota, wait) - }) - .max_by_key(|(_, _, wait)| *wait); - if let Some((failed_scope, failed_quota, token_wait)) = exhausted { - let violation = store.violations.entry(violation_key).or_default(); + if !result.allowed { + let (failed_scope, failed_quota, token_wait) = candidates + .iter() + .filter(|(_, _, t)| *t < 1.0) + .map(|(scope, quota, tokens)| { + let wait = Duration::from_secs_f64( + ((1.0 - tokens) / quota.refill_per_second()).max(0.0), + ); + (*scope, *quota, wait) + }) + .max_by_key(|(_, _, wait)| *wait) + .expect("store reported denial without an exhausted bucket"); + + let mut local = self.local.lock().unwrap(); + let violation = local.violations.entry(violation_key).or_default(); violation.count = violation.count.saturating_add(1).min(31); violation.last_seen = Some(now); violation.scope = failed_scope; @@ -337,12 +606,7 @@ impl RateLimitMiddleware { }; } - store.global.tokens -= 1.0; - store.ips.get_mut(&ip_key).unwrap().tokens -= 1.0; - if let Some(ref key) = user_key { - store.users.get_mut(key).unwrap().tokens -= 1.0; - } - store.violations.remove(&violation_key); + self.local.lock().unwrap().violations.remove(&violation_key); let remaining = tokens.floor().max(1.0) as u32 - 1; let reset_after = Duration::from_secs_f64( ((quota.burst as f64 - (tokens - 1.0)) / quota.refill_per_second()).max(0.0), @@ -430,7 +694,7 @@ pub async fn rate_limit_middleware( .and_then(|v| v.to_str().ok()) .filter(|key| key.len() <= 256) }); - let decision = limiter.check(&ip, user, Instant::now()); + let decision = limiter.check(&ip, user, Instant::now()).await; let mut response = if decision.allowed { next.run(req).await } else { @@ -468,49 +732,51 @@ mod tests { } } - #[test] - fn burst_is_limited_and_tokens_recover() { + #[tokio::test] + async fn burst_is_limited_and_tokens_recover() { let limiter = RateLimitMiddleware::new(config()); let start = Instant::now(); for _ in 0..3 { - assert!(limiter.check("1.2.3.4", Some("free"), start).allowed); + assert!(limiter.check("1.2.3.4", Some("free"), start).await.allowed); } - let denied = limiter.check("1.2.3.4", Some("free"), start); + let denied = limiter.check("1.2.3.4", Some("free"), start).await; assert!(!denied.allowed); assert_eq!(denied.scope, "user"); assert!( limiter .check("1.2.3.4", Some("free"), start + Duration::from_secs(2)) + .await .allowed ); } - #[test] - fn tiers_and_users_are_isolated() { + #[tokio::test] + async fn tiers_and_users_are_isolated() { let limiter = RateLimitMiddleware::new(config()); limiter.set_user_tier("paid", RateLimitTier::Premium); let now = Instant::now(); for _ in 0..5 { - assert!(limiter.check("10.0.0.1", Some("paid"), now).allowed); + assert!(limiter.check("10.0.0.1", Some("paid"), now).await.allowed); } - assert!(!limiter.check("10.0.0.1", Some("paid"), now).allowed); // IP bucket - assert!(limiter.check("10.0.0.2", Some("other"), now).allowed); + assert!(!limiter.check("10.0.0.1", Some("paid"), now).await.allowed); // IP bucket + assert!(limiter.check("10.0.0.2", Some("other"), now).await.allowed); } - #[test] - fn repeated_violations_back_off_exponentially() { + #[tokio::test] + async fn repeated_violations_back_off_exponentially() { let limiter = RateLimitMiddleware::new(config()); let start = Instant::now(); for _ in 0..3 { - limiter.check("1.1.1.1", Some("u"), start); + limiter.check("1.1.1.1", Some("u"), start).await; } - let first = limiter.check("1.1.1.1", Some("u"), start); - let second = limiter.check("1.1.1.1", Some("u"), start); + let first = limiter.check("1.1.1.1", Some("u"), start).await; + let second = limiter.check("1.1.1.1", Some("u"), start).await; assert!(second.retry_after > first.retry_after); } #[test] fn concurrent_requests_cannot_overspend_bucket() { + let rt = tokio::runtime::Runtime::new().unwrap(); let limiter = Arc::new(RateLimitMiddleware::new(config())); let barrier = Arc::new(Barrier::new(20)); let now = Instant::now(); @@ -518,30 +784,37 @@ mod tests { .map(|_| { let limiter = limiter.clone(); let barrier = barrier.clone(); + let handle = rt.handle().clone(); std::thread::spawn(move || { barrier.wait(); - limiter.check("2.2.2.2", Some("same"), now).allowed + handle + .block_on(limiter.check("2.2.2.2", Some("same"), now)) + .allowed }) }) .collect(); - let allowed = handles.into_iter().filter(|h| h.join().unwrap()).count(); + let allowed = handles + .into_iter() + .map(|h| h.join().unwrap()) + .filter(|allowed| *allowed) + .count(); assert_eq!(allowed, 3); } - #[test] - fn zero_tracking_capacity_uses_bounded_overflow_bucket() { + #[tokio::test] + async fn zero_tracking_capacity_uses_bounded_overflow_bucket() { let mut cfg = config(); cfg.max_tracked_ips = 0; cfg.max_tracked_users = 0; let limiter = RateLimitMiddleware::new(cfg); let now = Instant::now(); - assert!(limiter.check("a", Some("a"), now).allowed); - assert!(limiter.check("b", Some("b"), now).allowed); - assert!(limiter.check("c", Some("c"), now).allowed); - assert!(!limiter.check("d", Some("d"), now).allowed); - let store = limiter.store.lock().unwrap(); - assert_eq!(store.ips.len(), 1); - assert_eq!(store.users.len(), 1); + assert!(limiter.check("a", Some("a"), now).await.allowed); + assert!(limiter.check("b", Some("b"), now).await.allowed); + assert!(limiter.check("c", Some("c"), now).await.allowed); + assert!(!limiter.check("d", Some("d"), now).await.allowed); + let local = limiter.local.lock().unwrap(); + assert_eq!(local.cardinality_ips.len(), 1); + assert_eq!(local.cardinality_users.len(), 1); } #[tokio::test] @@ -577,4 +850,78 @@ mod tests { "rate_limit_exceeded" ); } + + /// Two independent `RateLimitMiddleware` instances (standing in for two + /// replicas behind a load balancer) pointed at the same Redis backend + /// must enforce one combined quota for a given client, not two + /// independent ones. This is the behavior #787 reports as broken for the + /// in-process store. + #[tokio::test] + async fn redis_backend_enforces_one_combined_quota_across_instances() { + use testcontainers::runners::AsyncRunner; + use testcontainers_modules::redis::Redis; + + let container = Redis::default() + .start() + .await + .expect("failed to start redis container - is Docker available?"); + let port = container + .get_host_port_ipv4(6379) + .await + .expect("failed to map redis port"); + let url = format!("redis://127.0.0.1:{port}"); + + let mut cfg = config(); + cfg.backend = RateLimitBackend::Redis(url.clone()); + let replica_a = RateLimitMiddleware::new(cfg.clone()); + cfg.backend = RateLimitBackend::Redis(url); + let replica_b = RateLimitMiddleware::new(cfg); + + // Free-tier burst is 3. Six requests split evenly across two + // "replicas" for the same client must still only allow 3 total. + let now = Instant::now(); + let mut allowed = 0; + for i in 0..6 { + let replica = if i % 2 == 0 { &replica_a } else { &replica_b }; + if replica + .check("9.9.9.9", Some("shared-client"), now) + .await + .allowed + { + allowed += 1; + } + } + assert_eq!( + allowed, 3, + "combined quota across both replicas must equal one bucket's capacity, not 2x" + ); + } + + /// The in-process backend, by contrast, does not share state: two + /// instances really do enforce independent quotas. This documents that + /// gap (matching the updated docs/api-reference.md caveat) rather than + /// leaving it as an untested assumption. + #[tokio::test] + async fn in_process_backend_does_not_share_quota_across_instances() { + let cfg = config(); + let replica_a = RateLimitMiddleware::new(cfg.clone()); + let replica_b = RateLimitMiddleware::new(cfg); + let now = Instant::now(); + + for _ in 0..3 { + assert!( + replica_a + .check("8.8.8.8", Some("client"), now) + .await + .allowed + ); + } + assert!( + replica_b + .check("8.8.8.8", Some("client"), now) + .await + .allowed, + "independent in-process stores each grant their own full quota" + ); + } } diff --git a/docs/api-reference.md b/docs/api-reference.md index 2930596..35677e2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1171,6 +1171,26 @@ capped at 60 seconds. A successful request clears the client's violation streak. Idle client state expires after 15 minutes, and tracked IP/user counts are bounded to prevent a distributed attack from exhausting limiter memory. +### Deployment topology + +The quotas above are enforced correctly **only when every replica shares the +same counter backend**. `RateLimitConfig::backend` controls this: + +- `RateLimitBackend::Redis(url)` — token-bucket counters live in Redis, shared + by every replica pointed at that instance. **Required** behind a load + balancer running more than one replica; otherwise each replica enforces its + own independent bucket, and a client whose requests are distributed across + N replicas effectively receives up to N times the quota documented above. +- `RateLimitBackend::InProcess` (the default) — counters live in that + process's memory only. Safe for a single-instance deployment and for tests, + but **not safe for multi-instance deployment** for the reason above. + +Violation backoff tracking, tier assignment (`set_user_tier`), and the +tracked-identity cardinality bound stay process-local under both backends — +they are memory-protection heuristics for that replica, not part of the +enforced quota, so they don't need to be shared for the quota itself to be +correct. + ### Response headers All HTTP responses include: