diff --git a/.github/workflows/daphneci.yml b/.github/workflows/daphneci.yml index 67d40a41a..68de16dfa 100644 --- a/.github/workflows/daphneci.yml +++ b/.github/workflows/daphneci.yml @@ -1,4 +1,4 @@ -# Copyright (c) 2024 Cloudflare, Inc. All rights reserved. +# Copyright (c) 2025 Cloudflare, Inc. All rights reserved. # SPDX-License-Identifier: BSD-3-Clause --- name: DaphneCI @@ -80,3 +80,15 @@ jobs: env: HPKE_SIGNING_KEY: ${{ steps.hpke_signing_key.outputs.hpke_signing_key }} E2E_TEST_HPKE_SIGNING_CERTIFICATE: ${{ steps.hpke_signing_cert.outputs.hpke_signing_cert }} + + e2e-worker-aggregator: + runs-on: ubuntu-latest + steps: + - name: Checking out + uses: actions/checkout@v3 + + - name: Run integration tests + uses: hoverkraft-tech/compose-action@v2.0.1 + with: + compose-file: "./crates/daphne-worker-test/docker/docker-compose-e2e.yaml" + up-flags: "--build --abort-on-container-exit --exit-code-from test" diff --git a/Cargo.lock b/Cargo.lock index 8453c8159..7d4329657 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.2" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" dependencies = [ "gimli", ] [[package]] -name = "adler2" -version = "2.0.0" +name = "adler" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "aead" @@ -95,9 +95,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" dependencies = [ "anstyle", "anstyle-parse", @@ -110,43 +110,43 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.6" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" dependencies = [ "anstyle", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] name = "anyhow" -version = "1.0.94" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "asn1-rs" @@ -160,7 +160,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 1.0.69", + "thiserror 1.0.63", "time", ] @@ -195,9 +195,9 @@ checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" [[package]] name = "async-trait" -version = "0.1.83" +version = "0.1.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", @@ -212,26 +212,27 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "aws-lc-rs" -version = "1.11.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47bb8cc16b669d267eeccf585aea077d0882f4777b1c1f740217885d6e6e5a3" +checksum = "4ae74d9bd0a7530e8afd1770739ad34b36838829d6ad61818f9230f683f5ad77" dependencies = [ "aws-lc-sys", + "mirai-annotations", "paste", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.23.1" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2101df3813227bbaaaa0b04cd61c534c7954b22bd68d399b440be937dc63ff7" +checksum = "0f0e249228c6ad2d240c2dc94b714d711629d52bad946075d8e9b2f5391f0703" dependencies = [ "bindgen", "cc", @@ -244,9 +245,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" dependencies = [ "async-trait", "axum-core", @@ -268,19 +269,18 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.1", "tokio", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] name = "axum-core" -version = "0.4.5" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" dependencies = [ "async-trait", "bytes", @@ -291,7 +291,7 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "sync_wrapper", + "sync_wrapper 0.1.2", "tower-layer", "tower-service", "tracing", @@ -299,26 +299,25 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.9.6" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" dependencies = [ "axum", "axum-core", "bytes", - "fastrand", "futures-util", "headers", "http", "http-body", "http-body-util", "mime", - "multer", "pin-project-lite", "serde", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -329,17 +328,17 @@ checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" dependencies = [ "addr2line", + "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", - "windows-targets", ] [[package]] @@ -374,9 +373,9 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bindgen" -version = "0.69.5" +version = "0.69.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" dependencies = [ "bitflags 2.6.0", "cexpr", @@ -436,9 +435,9 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytemuck" -version = "1.20.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" +checksum = "773d90827bc3feecfb67fab12e24de0749aad83c74b9504ecde46237b5cd24e2" [[package]] name = "byteorder" @@ -448,9 +447,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.9.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" [[package]] name = "cap" @@ -484,9 +483,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.3" +version = "1.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d" +checksum = "57b6a275aa2903740dc87da01c62040406b8812552e97129a63ea8850a17c6e6" dependencies = [ "jobserver", "libc", @@ -508,12 +507,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[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.9.1" @@ -540,9 +533,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", @@ -603,9 +596,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.23" +version = "4.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" +checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019" dependencies = [ "clap_builder", "clap_derive", @@ -613,9 +606,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.23" +version = "4.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" +checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" dependencies = [ "anstream", "anstyle", @@ -625,9 +618,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.18" +version = "4.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" dependencies = [ "heck", "proc-macro2", @@ -637,24 +630,24 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" [[package]] name = "cmake" -version = "0.1.52" +version = "0.1.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c682c223677e0e5b6b7f63a64b9351844c3f1b1678a68b7ee617e30fb082620e" +checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" dependencies = [ "cc", ] [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" [[package]] name = "config" @@ -693,9 +686,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "constcat" -version = "0.5.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4938185353434999ef52c81753c8cca8955ed38042fc29913db3751916f3b7ab" +checksum = "1f2e5af989b1955b092db01462980c0a286217f86817e12b2c09aea46bd03651" [[package]] name = "core-foundation" @@ -707,16 +700,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -725,9 +708,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.16" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" dependencies = [ "libc", ] @@ -873,8 +856,8 @@ dependencies = [ "prometheus", "rand", "reqwest", - "rustls 0.23.19", - "rustls-native-certs 0.7.3", + "rustls 0.23.12", + "rustls-native-certs", "rustls-pemfile", "sentry", "serde", @@ -914,7 +897,7 @@ dependencies = [ "serde_json", "strum", "subtle", - "thiserror 1.0.69", + "thiserror 1.0.63", "tokio", "tracing", "url", @@ -948,9 +931,9 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 1.0.69", + "thiserror 1.0.63", "tokio", - "tower 0.4.13", + "tower", "tracing", "tracing-subscriber", "url", @@ -976,6 +959,7 @@ dependencies = [ name = "daphne-worker" version = "0.3.0" dependencies = [ + "async-trait", "axum", "axum-extra", "bytes", @@ -983,25 +967,35 @@ dependencies = [ "constcat", "daphne", "daphne-service-utils", + "either", "futures", "getrandom", "headers", "hex", "http", + "http-body-util", + "mappable-rc", + "p256", "paste", "prio 0.16.7", "prio 0.17.0-alpha.0", "prometheus", "rand", + "rcgen", "reqwest", "serde", "serde-wasm-bindgen 0.6.5", "serde_json", + "static_assertions", + "thiserror 1.0.63", + "tokio", + "tower", "tower-service", "tracing", "tracing-core", "tracing-subscriber", "url", + "webpki", "worker", ] @@ -1186,15 +1180,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bbadc628dc286b9ae02f0cb0f5411c056eb7487b72f0083203f115de94060" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.1" @@ -1203,20 +1188,14 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "ff" version = "0.13.0" @@ -1286,9 +1265,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" dependencies = [ "futures-channel", "futures-core", @@ -1301,9 +1280,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", "futures-sink", @@ -1311,15 +1290,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" dependencies = [ "futures-core", "futures-task", @@ -1328,15 +1307,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", @@ -1345,21 +1324,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-channel", "futures-core", @@ -1409,9 +1388,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" [[package]] name = "glob" @@ -1470,9 +1449,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "headers" @@ -1504,6 +1483,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "hermit-abi" version = "0.4.0" @@ -1599,9 +1584,9 @@ dependencies = [ [[package]] name = "http" -version = "1.2.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", @@ -1633,9 +1618,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.5" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" [[package]] name = "httpdate" @@ -1645,9 +1630,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.5.1" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" +checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" dependencies = [ "bytes", "futures-channel", @@ -1666,28 +1651,28 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" dependencies = [ "futures-util", "http", "hyper", "hyper-util", - "rustls 0.23.19", - "rustls-native-certs 0.8.1", + "rustls 0.23.12", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 0.26.7", + "webpki-roots 0.26.3", ] [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" dependencies = [ "bytes", "futures-channel", @@ -1698,15 +1683,16 @@ dependencies = [ "pin-project-lite", "socket2", "tokio", + "tower", "tower-service", "tracing", ] [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1866,12 +1852,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.14.5", ] [[package]] @@ -1885,9 +1871,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.10.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" [[package]] name = "is-terminal" @@ -1895,7 +1881,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" dependencies = [ - "hermit-abi", + "hermit-abi 0.4.0", "libc", "windows-sys 0.52.0", ] @@ -1926,9 +1912,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "jobserver" @@ -1941,11 +1927,10 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.76" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" dependencies = [ - "once_cell", "wasm-bindgen", ] @@ -1983,15 +1968,15 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.168" +version = "0.2.158" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" [[package]] name = "libloading" -version = "0.8.6" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", "windows-targets", @@ -2072,11 +2057,11 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ - "adler2", + "adler", ] [[package]] @@ -2087,31 +2072,21 @@ checksum = "9bec4598fddb13cc7b528819e697852653252b760f1228b7642679bf2ff2cd07" [[package]] name = "mio" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ + "hermit-abi 0.3.9", "libc", "wasi", "windows-sys 0.52.0", ] [[package]] -name = "multer" -version = "3.1.0" +name = "mirai-annotations" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] +checksum = "c9be0862c1b3f26a88803c4a49de6889c10e608b3ee9344e6ef5b45fb37ad3d1" [[package]] name = "nom" @@ -2194,9 +2169,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.5" +version = "0.36.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" dependencies = [ "memchr", ] @@ -2212,9 +2187,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.2" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "oorandom" @@ -2246,9 +2221,9 @@ dependencies = [ [[package]] name = "os_info" -version = "3.9.0" +version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ca711d8b83edbb00b44d504503cd247c9c0bd8b0fa2694f2a1a3d8165379ce" +checksum = "ae99c7fa6dd38c7cafe1ec085e804f8f555a2f8659b0dbe03f1f9963a9b51092" dependencies = [ "log", "serde", @@ -2314,9 +2289,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pathdiff" -version = "0.2.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" [[package]] name = "pem" @@ -2345,20 +2320,20 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" -version = "2.7.15" +version = "2.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95" dependencies = [ "memchr", - "thiserror 2.0.6", + "thiserror 1.0.63", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.7.15" +version = "2.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a" dependencies = [ "pest", "pest_generator", @@ -2366,9 +2341,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.7.15" +version = "2.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183" dependencies = [ "pest", "pest_meta", @@ -2379,9 +2354,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.7.15" +version = "2.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f" dependencies = [ "once_cell", "pest", @@ -2390,18 +2365,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.7" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.7" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", @@ -2410,9 +2385,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.15" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" @@ -2432,9 +2407,9 @@ dependencies = [ [[package]] name = "plotters" -version = "0.3.7" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +checksum = "a15b6eccb8484002195a3e44fe65a4ce8e93a625797a063735536fd59cb01cf3" dependencies = [ "num-traits", "plotters-backend", @@ -2445,15 +2420,15 @@ dependencies = [ [[package]] name = "plotters-backend" -version = "0.3.7" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" +checksum = "414cec62c6634ae900ea1c56128dfe87cf63e7caece0852ec76aba307cebadb7" [[package]] name = "plotters-svg" -version = "0.3.7" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +checksum = "81b30686a7d9c3e010b84284bdd26a29f2138574f52f5eb6f794fc0ad924e705" dependencies = [ "plotters-backend", ] @@ -2498,9 +2473,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.25" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba" dependencies = [ "proc-macro2", "syn 2.0.90", @@ -2542,7 +2517,7 @@ dependencies = [ "sha2", "sha3", "subtle", - "thiserror 1.0.69", + "thiserror 1.0.63", "zipf", ] @@ -2572,7 +2547,7 @@ dependencies = [ "sha2", "sha3", "subtle", - "thiserror 2.0.6", + "thiserror 2.0.8", "zipf", ] @@ -2597,7 +2572,7 @@ dependencies = [ "memchr", "parking_lot", "protobuf", - "thiserror 1.0.69", + "thiserror 1.0.63", ] [[package]] @@ -2608,54 +2583,50 @@ checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" [[package]] name = "quinn" -version = "0.11.6" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +checksum = "b22d8e7369034b9a7132bc2008cac12f2013c8132b45e0554e6e20e2617f2156" dependencies = [ "bytes", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.0", - "rustls 0.23.19", + "rustc-hash 2.0.0", + "rustls 0.23.12", "socket2", - "thiserror 2.0.6", + "thiserror 1.0.63", "tokio", "tracing", ] [[package]] name = "quinn-proto" -version = "0.11.9" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" +checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" dependencies = [ "bytes", - "getrandom", "rand", "ring", - "rustc-hash 2.1.0", - "rustls 0.23.19", - "rustls-pki-types", + "rustc-hash 2.0.0", + "rustls 0.23.12", "slab", - "thiserror 2.0.6", + "thiserror 1.0.63", "tinyvec", "tracing", - "web-time", ] [[package]] name = "quinn-udp" -version = "0.5.8" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd4b1eff68bf27940dd39811292c49e007f4d0b4c357358dc9b0197be6b527" +checksum = "8bffec3605b73c6f1754535084a85229fa8a30f86014e6c81aeec4abb68b0285" dependencies = [ - "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2737,23 +2708,23 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" dependencies = [ "bitflags 2.6.0", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", ] [[package]] @@ -2767,13 +2738,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.4", ] [[package]] @@ -2784,15 +2755,15 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "reqwest" -version = "0.12.9" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" +checksum = "f8f4955649ef5c38cc7f9e8aa41761d48fb9677197daea9984dc54f56aad5e63" dependencies = [ "base64 0.22.1", "bytes", @@ -2813,14 +2784,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.19", - "rustls-native-certs 0.8.1", + "rustls 0.23.12", + "rustls-native-certs", "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.1", "tokio", "tokio-rustls", "tower-service", @@ -2828,7 +2799,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.26.7", + "webpki-roots 0.26.3", "windows-registry", ] @@ -2892,15 +2863,15 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" +checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" [[package]] name = "rustc_version" -version = "0.4.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" dependencies = [ "semver", ] @@ -2916,15 +2887,15 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.42" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2941,62 +2912,48 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.19" +version = "0.23.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1" +checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.102.8", + "rustls-webpki 0.102.7", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.7.3" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +checksum = "04182dffc9091a404e0fc069ea5cd60e5b866c3adf881eff99a32d048242dffa" dependencies = [ "openssl-probe", "rustls-pemfile", "rustls-pki-types", "schannel", - "security-framework 2.11.1", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework 3.0.1", + "security-framework", ] [[package]] name = "rustls-pemfile" -version = "2.2.0" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" dependencies = [ + "base64 0.22.1", "rustls-pki-types", ] [[package]] name = "rustls-pki-types" -version = "1.10.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" -dependencies = [ - "web-time", -] +checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" [[package]] name = "rustls-webpki" @@ -3010,9 +2967,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.102.8" +version = "0.102.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "84678086bd54edf2b415183ed7a94d0efb049f1b646a33e22a36f3794be6ae56" dependencies = [ "aws-lc-rs", "ring", @@ -3022,9 +2979,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.18" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] name = "ryu" @@ -3043,11 +3000,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3087,20 +3044,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.6.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1415a607e92bec364ea2cf9264646dcce0f91e6d65281bd6f2819cca3bf39c8" -dependencies = [ - "bitflags 2.6.0", - "core-foundation 0.10.0", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -3108,9 +3052,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.12.1" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" +checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" dependencies = [ "core-foundation-sys", "libc", @@ -3225,7 +3169,7 @@ dependencies = [ "rand", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 1.0.63", "time", "url", "uuid", @@ -3233,9 +3177,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.216" +version = "1.0.209" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" dependencies = [ "serde_derive", ] @@ -3264,9 +3208,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.216" +version = "1.0.209" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" dependencies = [ "proc-macro2", "quote", @@ -3275,9 +3219,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.133" +version = "1.0.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad" dependencies = [ "itoa", "memchr", @@ -3403,9 +3347,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.8" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", "windows-sys 0.52.0", @@ -3433,6 +3377,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -3491,9 +3441,15 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "1.0.2" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" dependencies = [ "futures-core", ] @@ -3529,27 +3485,27 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "thiserror" -version = "1.0.69" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ - "thiserror-impl 1.0.69", + "thiserror-impl 1.0.63", ] [[package]] name = "thiserror" -version = "2.0.6" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec2a1820ebd077e2b90c4df007bebf344cd394098a13c563957d0afc83ea47" +checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a" dependencies = [ - "thiserror-impl 2.0.6", + "thiserror-impl 2.0.8", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", @@ -3558,9 +3514,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.6" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d65750cab40f4ff1929fb1ba509e9914eb756131cef4210da8d5d700d26f6312" +checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943" dependencies = [ "proc-macro2", "quote", @@ -3585,9 +3541,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.37" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", @@ -3606,9 +3562,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.19" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", @@ -3673,9 +3629,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.42.0" +version = "1.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" +checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" dependencies = [ "backtrace", "bytes", @@ -3701,19 +3657,20 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.1" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.19", + "rustls 0.23.12", + "rustls-pki-types", "tokio", ] [[package]] name = "tokio-util" -version = "0.7.13" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" dependencies = [ "bytes", "futures-core", @@ -3741,24 +3698,10 @@ dependencies = [ "futures-util", "pin-project", "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3775,9 +3718,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "log", "pin-project-lite", @@ -3787,9 +3730,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", @@ -3798,9 +3741,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", "valuable", @@ -3819,9 +3762,9 @@ dependencies = [ [[package]] name = "tracing-serde" -version = "0.2.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" dependencies = [ "serde", "tracing-core", @@ -3829,9 +3772,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ "matchers", "nu-ansi-term", @@ -3862,9 +3805,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "ucd-trie" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" [[package]] name = "uname" @@ -3877,15 +3820,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] name = "unicode-xid" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "229730647fbc343e3a80e463c1db7f78f3855d3f3739bee0dda773c9a037c90a" [[package]] name = "universal-hash" @@ -3911,17 +3854,17 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.12.1" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" dependencies = [ "base64 0.22.1", "log", "once_cell", - "rustls 0.23.19", + "rustls 0.23.12", "rustls-pki-types", "url", - "webpki-roots 0.26.7", + "webpki-roots 0.26.3", ] [[package]] @@ -3956,9 +3899,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.11.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ "serde", ] @@ -4002,9 +3945,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.99" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" dependencies = [ "cfg-if", "once_cell", @@ -4013,12 +3956,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.99" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" dependencies = [ "bumpalo", "log", + "once_cell", "proc-macro2", "quote", "syn 2.0.90", @@ -4027,9 +3971,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.39" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac36a15a220124ac510204aec1c3e5db8a22ab06fd6706d881dc6149f8ed9a12" +checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" dependencies = [ "cfg-if", "js-sys", @@ -4039,9 +3983,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.99" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4049,9 +3993,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.99" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", @@ -4062,15 +4006,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.99" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] name = "wasm-streams" -version = "0.4.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e072d4e72f700fb3443d8fe94a39315df013eef1104903cdb0a2abd322bbecd" +checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" dependencies = [ "futures-util", "js-sys", @@ -4081,19 +4025,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.76" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" -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" +checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" dependencies = [ "js-sys", "wasm-bindgen", @@ -4117,9 +4051,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "0.26.7" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" dependencies = [ "rustls-pki-types", ] @@ -4300,9 +4234,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "worker" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8aca53ec63e508176a89a573c972266f0f98bcc48bd866def7be0d939ef9268" +checksum = "727789ca7eff9733efbea9d0e97779edc1cf1926e98aee7d7d8afe32805458aa" dependencies = [ "async-trait", "bytes", @@ -4339,16 +4273,16 @@ dependencies = [ "serde", "serde-wasm-bindgen 0.5.0", "serde_json", - "thiserror 1.0.69", + "thiserror 1.0.63", "wasm-bindgen", "wasm-bindgen-futures", ] [[package]] name = "worker-macros" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1118a0ceb59ddde7fdbaff6c47b6fa6ee47848975ea38b4ae9bb4080f96541cd" +checksum = "7d625c24570ba9207a2617476013335f28a95cbe513e59bb814ffba092a18058" dependencies = [ "async-trait", "proc-macro2", @@ -4362,9 +4296,9 @@ dependencies = [ [[package]] name = "worker-sys" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5643a2ba07df61aa50e37212ffcb0944417db7d3960d4331f36aeb2fa5e2fd7" +checksum = "34563340d41016b4381257c5a16b0d2bc590dbe00500ecfbebcaa16f5f85ce90" dependencies = [ "cfg-if", "js-sys", @@ -4418,7 +4352,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror 1.0.69", + "thiserror 1.0.63", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 1d200af01..c7ba9a577 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ -# Copyright (c) 2024 Cloudflare, Inc. All rights reserved. +# Copyright (c) 2025 Cloudflare, Inc. All rights reserved. # SPDX-License-Identifier: BSD-3-Clause [workspace] @@ -56,6 +56,7 @@ hpke-rs = "0.2.0" hpke-rs-crypto = "0.2.0" hpke-rs-rust-crypto = "0.2.0" http = "1" +http-body-util = "0.1.2" mappable-rc = "0.1.1" matchit = "0.7.3" p256 = { version = "0.13.2", features = ["ecdsa-core", "ecdsa", "pem"] } @@ -76,6 +77,7 @@ serde = { version = "1.0.203", features = ["derive"] } serde-wasm-bindgen = "0.6.5" serde_json = "1.0.118" serde_yaml = "0.9.33" +static_assertions = "1" strum = { version = "0.26.3", features = ["derive"] } subtle = "2.6.1" thiserror = "1.0.61" @@ -87,7 +89,7 @@ tracing-core = "0.1.32" tracing-subscriber = "0.3.18" url = { version = "2.5.4", features = ["serde"] } webpki = "0.22.4" -worker = { version = "0.4", features = ["http"] } +worker = { version = "0.5", features = ["http"] } x509-parser = "0.15.1" [workspace.dependencies.sentry] diff --git a/Makefile b/Makefile index e68011f4b..0e5780680 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# Copyright (c) 2024 Cloudflare, Inc. All rights reserved. +# Copyright (c) 2025 Cloudflare, Inc. All rights reserved. # SPDX-License-Identifier: BSD-3-Clause .PHONY: accept acceptance e2e load leader l helper h storage-proxy s @@ -21,6 +21,16 @@ helper: -c ./crates/daphne-server/examples/configuration-helper.toml h: helper +helper-worker: + cd ./crates/daphne-worker-test/ && \ + wrangler dev -c wrangler.aggregator.toml --port 8788 -e helper +hw: helper-worker + +leader-worker: + cd ./crates/daphne-worker-test/ && \ + wrangler dev -c wrangler.aggregator.toml --port 8788 -e leader +lw: leader-worker + storage-proxy: docker compose -f ./crates/daphne-worker-test/docker-compose-storage-proxy.yaml up --build s: storage-proxy @@ -35,6 +45,12 @@ e2e: /tmp/private-key /tmp/certificate --abort-on-container-exit \ --exit-code-from test +e2e-worker: + docker compose -f ./crates/daphne-worker-test/docker/docker-compose-e2e.yaml up \ + --build \ + --abort-on-container-exit \ + --exit-code-from test + build_interop: docker build . -f ./interop/Dockerfile.interop_helper --tag daphne-interop diff --git a/crates/daphne-worker-test/docker/aggregator.Dockerfile b/crates/daphne-worker-test/docker/aggregator.Dockerfile new file mode 100644 index 000000000..8d21a4a92 --- /dev/null +++ b/crates/daphne-worker-test/docker/aggregator.Dockerfile @@ -0,0 +1,37 @@ +# Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +# SPDX-License-Identifier: BSD-3-Clause + +FROM rust:1.83-bookworm AS builder +RUN apt update && apt install -y capnproto clang cmake + +# Pre-install worker-build and Rust's wasm32 target to speed up our custom build command +RUN rustup target add wasm32-unknown-unknown +RUN cargo install --git https://github.com/cloudflare/workers-rs + +# Build the worker. +WORKDIR /tmp/dap_test +COPY Cargo.toml Cargo.lock ./ +COPY crates/daphne-worker-test ./crates/daphne-worker-test +COPY crates/daphne-worker ./crates/daphne-worker +COPY crates/daphne-service-utils ./crates/daphne-service-utils +COPY crates/daphne ./crates/daphne +WORKDIR /tmp/dap_test/crates/daphne-worker-test +RUN worker-build --dev + +FROM node:bookworm AS leader +RUN npm install -g wrangler@3.60.1 && npm cache clean --force +COPY --from=builder /tmp/dap_test/crates/daphne-worker-test/build/ /build +COPY crates/daphne-worker-test/wrangler.aggregator.toml / +# this container doesn't need worker-build but the wrangler.toml requires it, so we just fake it +RUN ln -s /usr/bin/true /usr/bin/worker-build + +ENTRYPOINT ["wrangler", "dev", "--config", "wrangler.aggregator.toml", "-e", "leader", "--port", "8787"] + +FROM node:bookworm AS helper +RUN npm install -g wrangler@3.60.1 && npm cache clean --force +COPY --from=builder /tmp/dap_test/crates/daphne-worker-test/build/ /build +COPY crates/daphne-worker-test/wrangler.aggregator.toml / +# this container doesn't need worker-build but the wrangler.toml requires it, so we just fake it +RUN ln -s /usr/bin/true /usr/bin/worker-build + +ENTRYPOINT ["wrangler", "dev", "--config", "wrangler.aggregator.toml", "-e", "helper", "--port", "8788"] diff --git a/crates/daphne-worker-test/docker/docker-compose-e2e.yaml b/crates/daphne-worker-test/docker/docker-compose-e2e.yaml new file mode 100644 index 000000000..c8f6a545e --- /dev/null +++ b/crates/daphne-worker-test/docker/docker-compose-e2e.yaml @@ -0,0 +1,43 @@ +# Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +# SPDX-License-Identifier: BSD-3-Clause +--- +version: "3.9" + +networks: + dap_network: + driver: bridge + +services: + leader: + networks: + - dap_network + ports: + - "8787" + build: + context: ../../.. + dockerfile: crates/daphne-worker-test/docker/aggregator.Dockerfile + target: leader + environment: + - RUST_LOG=info + helper: + networks: + - dap_network + ports: + - "8788" + build: + context: ../../.. + dockerfile: crates/daphne-worker-test/docker/aggregator.Dockerfile + target: helper + environment: + - RUST_LOG=info + test: + networks: + - dap_network + build: + context: ../../.. + dockerfile: crates/daphne-worker-test/docker/runtests.Dockerfile + depends_on: + - leader + - helper + environment: + - "E2E_TEST_HPKE_SIGNING_CERTIFICATE=-----BEGIN CERTIFICATE-----\nMIICCTCCAa+gAwIBAgIUBECNyioI8d+hgXsgmVI+TcRD8wUwCgYIKoZIzj0EAwIw\nWjELMAkGA1UEBhMCUFQxDjAMBgNVBAcMBUJyYWdhMRcwFQYDVQQKDA5DbG91ZGZs\nYXJlIExkYTEiMCAGA1UEAwwZaGVscGVyLmRhcC5jbG91ZGZsYXJlLmNvbTAeFw0y\nNTAxMDYxMTAwNDdaFw0yNjAxMDYxMTAwNDdaMFoxCzAJBgNVBAYTAlBUMQ4wDAYD\nVQQHDAVCcmFnYTEXMBUGA1UECgwOQ2xvdWRmbGFyZSBMZGExIjAgBgNVBAMMGWhl\nbHBlci5kYXAuY2xvdWRmbGFyZS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\nAASheYdDsJLsG4UG95bs2qlVr1QQcK6+k6emAJSDAlr7bIGjHUoLwUdIQK818g/N\ngVL0vig90b4uGTS7KdKJ9o4Ko1MwUTAdBgNVHQ4EFgQUeOUaahWphjiaQotYoRfb\nVBdby+wwHwYDVR0jBBgwFoAUeOUaahWphjiaQotYoRfbVBdby+wwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAl0pg+5iQC3yskSbZrz8gyEgAaKx2\niyrASYsFh2gdfkICIAgkOlAOHsUHlhh0zRt9m283dLR0/ZYVoEhII8ZMkb1/\n-----END CERTIFICATE-----" diff --git a/crates/daphne-worker-test/src/lib.rs b/crates/daphne-worker-test/src/lib.rs index 71412647c..dc1456a68 100644 --- a/crates/daphne-worker-test/src/lib.rs +++ b/crates/daphne-worker-test/src/lib.rs @@ -1,7 +1,7 @@ -// Copyright (c) 2022 Cloudflare, Inc. All rights reserved. +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause -use daphne_worker::initialize_tracing; +use daphne_worker::{aggregator::App, initialize_tracing}; use tracing::info; use worker::{event, Env, HttpRequest}; @@ -26,5 +26,29 @@ pub async fn main( info!(method = ?req.method(), "{}", req.uri().path()); - Ok(daphne_worker::storage_proxy::handle_request(req, env, &prometheus::Registry::new()).await) + let registry = prometheus::Registry::new(); + let response = match env + .var("DAP_WORKER_MODE") + .map(|t| t.to_string()) + .ok() + .as_deref() + { + Some("storage-proxy") | None => { + daphne_worker::storage_proxy::handle_request(req, env, ®istry).await + } + Some("aggregator") => { + daphne_worker::aggregator::handle_dap_request( + App::new(env, ®istry, None).unwrap(), + req, + ) + .await + } + Some(invalid) => { + return Err(worker::Error::RustError(format!( + "{invalid} is not a valid DAP_WORKER_MODE" + ))) + } + }; + + Ok(response) } diff --git a/crates/daphne-worker-test/wrangler.aggregator.toml b/crates/daphne-worker-test/wrangler.aggregator.toml new file mode 100644 index 000000000..7173056ef --- /dev/null +++ b/crates/daphne-worker-test/wrangler.aggregator.toml @@ -0,0 +1,157 @@ +# Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +# SPDX-License-Identifier: BSD-3-Clause + +main = "build/worker/shim.mjs" +compatibility_date = "2025-01-08" + +# Don't ask to send metrics to Cloudflare. The worker may be run from a container. +send_metrics = false + +# Before starting the worker, run `worker-build`. +[build] +command = "worker-build --dev" + +[[rules]] +globs = ["**/*.wasm"] +type = "CompiledWasm" +fallthrough = false + +# NOTE: Variables marked as SECRET need to be provisioned securely in +# production. In particular, they will not be passed as environment variables +# as they are here. See +# https://developers.cloudflare.com/workers/wrangler/commands/#secret. + +[env.helper] +name = "daphne-helper-aggregator" + +[env.helper.vars] +DAP_DEPLOYMENT = "dev" +DAP_WORKER_MODE = "aggregator" +DAP_DURABLE_HELPER_STATE_STORE_GC_AFTER_SECS = "30" +DAP_DURABLE_AGGREGATE_STORE_GC_AFTER_SECS = "30" + +# SECRET +TASKPROV_SECRETS_ENABLED = "true" +TASKPROV_SECRETS_VDAF_VERIFY_KEY_INIT = "b029a72fa327931a5cb643dcadcaafa098fcbfac07d990cb9e7c9a8675fafb18" +TASKPROV_SECRETS_PEER_AUTH_EXPECT_LEADER_TOKEN = "I-am-the-leader" +SIGNING_KEY = """-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIPg1ObFKii4YgTwltHaC/vgD6pwg5EtvW1YoyHVB5HfKoAoGCCqGSM49 +AwEHoUQDQgAEoXmHQ7CS7BuFBveW7NqpVa9UEHCuvpOnpgCUgwJa+2yBox1KC8FH +SECvNfIPzYFS9L4oPdG+Lhk0uynSifaOCg== +-----END EC PRIVATE KEY-----""" + +[env.helper.vars.SERVICE_CONFIG] +env = "oxy" +role = "helper" +max_batch_duration = 360000 +min_batch_interval_start = 259200 +max_batch_interval_end = 259200 +supported_hpke_kems = ["x25519_hkdf_sha256"] +default_version = "v09" +report_storage_epoch_duration = 300000 +base_url = "http://127.0.0.1:8788" +default_num_agg_span_shards = 4 + +[env.helper.vars.TASKPROV_HPKE_COLLECTOR_CONFIG] +id = 23 +kem_id = "p256_hkdf_sha256" +kdf_id = "hkdf_sha256" +aead_id = "aes128_gcm" +public_key = "047dab625e0d269abcc28c611bebf5a60987ddf7e23df0e0aa343e5774ad81a1d0160d9252b82b4b5c52354205f5ec945645cb79facff8d85c9c31b490cdf35466" +# PRIVATE KEY: 9ce9851512df3ea674b108b305c3f8c424955a94d93fd53ecf3c3f17f7d1df9e + +[dev] +ip = "0.0.0.0" + +[env.helper.durable_objects] +bindings = [ + { name = "DAP_AGGREGATE_STORE", class_name = "AggregateStore" }, + { name = "DAP_TEST_STATE_CLEANER", class_name = "TestStateCleaner" }, +] + + +[[env.helper.kv_namespaces]] +binding = "DAP_CONFIG" +# KV bindings are in a looked up in a namespace identified by a 16-byte id number. +# This number is assigned by calling +# +# wrangler kv:namespace create +# +# for some unique name you specify, and it returns a unique id number to use. +# Here we should use something like "leader" for the . +id = "" +# A "preview id" is an id used when running in "wrangler dev" mode locally, and +# can just be made up. We generated the number below with the following python +# code: +# +# import secrets +# print(secrets.token_hex(16)) +# +preview_id = "24c4dc92d5cf4680e508fe18eb8f0281" + +[env.leader] +name = "daphne-leader-aggregator" + +[env.leader.vars] +DAP_DEPLOYMENT = "dev" +DAP_WORKER_MODE = "aggregator" +DAP_DURABLE_HELPER_STATE_STORE_GC_AFTER_SECS = "30" +DAP_DURABLE_AGGREGATE_STORE_GC_AFTER_SECS = "30" + +# SECRET +TASKPROV_SECRETS_ENABLED = "true" +TASKPROV_SECRETS_VDAF_VERIFY_KEY_INIT = "b029a72fa327931a5cb643dcadcaafa098fcbfac07d990cb9e7c9a8675fafb18" +TASKPROV_SECRETS_PEER_AUTH_EXPECT_COLLECTOR_TOKEN = "I-am-the-collector" +TASKPROV_SECRETS_SELF_BEARER_TOKEN = "I-am-the-leader" + +[env.leader.vars.SERVICE_CONFIG] +env = "oxy" +role = "leader" +max_batch_duration = 360000 +min_batch_interval_start = 259200 +max_batch_interval_end = 259200 +supported_hpke_kems = ["x25519_hkdf_sha256"] +default_version = "v09" +report_storage_epoch_duration = 300000 +base_url = "http://127.0.0.1:8787" +default_num_agg_span_shards = 4 + +[env.leader.vars.TASKPROV_HPKE_COLLECTOR_CONFIG] +id = 23 +kem_id = "p256_hkdf_sha256" +kdf_id = "hkdf_sha256" +aead_id = "aes128_gcm" +public_key = "047dab625e0d269abcc28c611bebf5a60987ddf7e23df0e0aa343e5774ad81a1d0160d9252b82b4b5c52354205f5ec945645cb79facff8d85c9c31b490cdf35466" +# PRIVATE KEY: 9ce9851512df3ea674b108b305c3f8c424955a94d93fd53ecf3c3f17f7d1df9e + +[env.leader.durable_objects] +bindings = [ + { name = "DAP_AGGREGATE_STORE", class_name = "AggregateStore" }, + { name = "DAP_TEST_STATE_CLEANER", class_name = "TestStateCleaner" }, +] + +[[env.leader.kv_namespaces]] +binding = "DAP_CONFIG" +# KV bindings are in a looked up in a namespace identified by a 16-byte id number. +# This number is assigned by calling +# +# wrangler kv:namespace create +# +# for some unique name you specify, and it returns a unique id number to use. +# Here we should use something like "leader" for the . +id = "" +# A "preview id" is an id used when running in "wrangler dev" mode locally, and +# can just be made up. We generated the number below with the following python +# code: +# +# import secrets +# print(secrets.token_hex(16)) +# +preview_id = "24c4dc92d5cf4680e508fe18eb8f0281" + +[[migrations]] +tag = "v1" +new_classes = [ + "AggregateStore", + "GarbageCollector", +] diff --git a/crates/daphne-worker/Cargo.toml b/crates/daphne-worker/Cargo.toml index d1b197398..b09facb9b 100644 --- a/crates/daphne-worker/Cargo.toml +++ b/crates/daphne-worker/Cargo.toml @@ -1,4 +1,4 @@ -# Copyright (c) 2024 Cloudflare, Inc. All rights reserved. +# Copyright (c) 2025 Cloudflare, Inc. All rights reserved. # SPDX-License-Identifier: BSD-3-Clause [package] @@ -16,32 +16,44 @@ description = "Workers backend for Daphne" crate-type = ["cdylib", "rlib"] [dependencies] -axum.workspace = true +async-trait = { workspace = true } axum-extra = { workspace = true, features = ["typed-header"] } bytes.workspace = true chrono = { workspace = true, default-features = false, features = ["clock", "wasmbind"] } constcat.workspace = true daphne = { path = "../daphne", features = ["prometheus"] } -futures = { workspace = true, optional = true } +either = { workspace = true } +futures = { workspace = true } # We don't use getrandom directly but this allows us to enable the 'js' feature # of getrandom in the crates we depend on, that depend on getrandom getrandom = { workspace = true, features = ["js"] } headers.workspace = true hex.workspace = true +http-body-util.workspace = true http.workspace = true -prio_draft09.workspace = true +mappable-rc.workspace = true +p256 = { workspace = true } prio.workspace = true +prio_draft09.workspace = true prometheus.workspace = true rand.workspace = true +reqwest.workspace = true serde-wasm-bindgen.workspace = true serde.workspace = true serde_json.workspace = true +static_assertions.workspace = true +thiserror.workspace = true +tower-service.workspace = true +tower.workspace = true tracing-core.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter", "json"]} tracing.workspace = true url.workspace = true worker.workspace = true -tower-service.workspace = true + +[dependencies.axum] +workspace = true +features = ["query", "json", "http1", "http2"] [dependencies.daphne-service-utils] path = "../daphne-service-utils" @@ -50,10 +62,13 @@ features = ["durable_requests"] [dev-dependencies] daphne = { path = "../daphne", features = ["test-utils"] } paste.workspace = true +rcgen.workspace = true reqwest.workspace = true # used in doc tests +tokio.workspace = true +webpki.workspace = true [features] -test-utils = ["daphne-service-utils/test-utils", "dep:futures"] +test-utils = ["daphne-service-utils/test-utils"] [lints] workspace = true diff --git a/crates/daphne-worker/clippy.toml b/crates/daphne-worker/clippy.toml index 08ccd5c10..ac9695952 100644 --- a/crates/daphne-worker/clippy.toml +++ b/crates/daphne-worker/clippy.toml @@ -1,6 +1,7 @@ -# Copyright (c) 2024 Cloudflare, Inc. All rights reserved. +# Copyright (c) 2025 Cloudflare, Inc. All rights reserved. # SPDX-License-Identifier: BSD-3-Clause disallowed-methods = [ - { path = "std::time::Instant::now", reason = "not implemented in wasm" }, + { path = "std::time::Instant::now", reason = "not implemented in wasm. Use worker::Date::now()" }, + { path = "std::time::SystemTime::now", reason = "not implemented in wasm. Use worker::Date::now()" }, ] diff --git a/crates/daphne-worker/src/aggregator/config.rs b/crates/daphne-worker/src/aggregator/config.rs new file mode 100644 index 000000000..d07498c04 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/config.rs @@ -0,0 +1,290 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use daphne::{ + constants::DapAggregatorRole, fatal_error, hpke::HpkeConfig, DapError, DapGlobalConfig, + DapVersion, +}; +use daphne_service_utils::bearer_token::BearerToken; +use p256::ecdsa::SigningKey; +use serde::{Deserialize, Serialize}; +use url::Url; + +/// draft-wang-ppm-dap-taskprov: Long-lived parameters for the taskprov extension. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TaskprovConfig { + /// HPKE collector configuration for all taskprov tasks. + pub hpke_collector_config: HpkeConfig, + + /// VDAF verify key init secret, used to generate the VDAF verification key for a taskprov task. + #[serde(with = "hex")] + pub vdaf_verify_key_init: [u8; 32], + + /// Peer's bearer token. + pub peer_auth: PeerBearerToken, + + /// Bearer token used when trying to communicate with an aggregator using taskprov. + pub self_bearer_token: Option, +} + +/// Peer authentication tokens for incomming requests. Different roles have different peers. +/// - Helpers have a Leader peer. +/// - Leaders have a Collector peer. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "lowercase")] +pub enum PeerBearerToken { + Leader { expected_token: BearerToken }, + Collector { expected_token: BearerToken }, +} + +/// Daphne service configuration, including long-lived parameters used across DAP tasks. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct DaphneServiceConfig { + /// Indicates the role the service should play. + pub role: DapAggregatorRole, + + /// Global DAP configuration. + #[serde(flatten)] + pub global: DapGlobalConfig, + + /// draft-dcook-ppm-dap-interop-test-design: Base URL of the Aggregator (unversioned). If set, + /// this field is used for endpoint configuration for interop testing. + pub base_url: Option, + + /// draft-wang-ppm-dap-taskprov: Long-lived parameters for the taskprov extension. If not set, + /// then taskprov will be disabled. + pub taskprov: Option, + + /// Default DAP version to use if not specified by the API URL + pub default_version: DapVersion, + + /// The report storage epoch duration. This value is used to control the period of time for + /// which an Aggregator guarantees storage of reports and/or report metadata. + /// + /// A report will be accepted if its timestamp is no more than the specified number of seconds + /// before the current time. + pub report_storage_epoch_duration: daphne::messages::Duration, + + /// The report storage maximum future time skew. Reports with timestamps greater than the + /// current time plus this value will be rejected. + #[serde(default = "default_report_storage_max_future_time_skew")] + pub report_storage_max_future_time_skew: daphne::messages::Duration, + + /// ECDSA signing key for signing messages. If set, then every response to HPKE + /// configuration endpoint will include a header "x-hpke-config-signature" with a + /// URL-safe, base64-encoded signature of the HPKE config. + /// + /// The expected payload is a is standard DER-encoded EC private key for use with + /// ECDSA-P256-SHA256, i.e., the output of + /// + /// ```text + /// $ openssl ecparam -name prime256v1 -genkey -noout -out private-key.pem + /// ``` + #[serde( + default, + deserialize_with = "signing_key_serializer::deserialize_opt", + skip_serializing + )] + pub signing_key: Option, +} + +fn default_report_storage_max_future_time_skew() -> daphne::messages::Duration { + 300 +} + +mod signing_key_serializer { + use p256::ecdsa::SigningKey; + use serde::{de, Deserialize, Deserializer}; + + fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + D::Error: serde::de::Error, + { + struct Visitor; + impl de::Visitor<'_> for Visitor { + type Value = SigningKey; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a sec1 pem key") + } + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + Ok(SigningKey::from( + p256::SecretKey::from_sec1_pem(v).map_err(E::custom)?, + )) + } + + fn visit_string(self, v: String) -> Result + where + E: de::Error, + { + self.visit_str(&v) + } + } + deserializer.deserialize_str(Visitor) + } + + pub(super) fn deserialize_opt<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + D::Error: serde::de::Error, + { + #[derive(Deserialize)] + struct Wrapper(#[serde(deserialize_with = "deserialize")] SigningKey); + Option::::deserialize(deserializer).map(|w| w.map(|w| w.0)) + } + + #[cfg(test)] + mod test { + use p256::ecdsa::SigningKey; + use serde::Deserialize; + + #[test] + fn deserialize() { + #[derive(Deserialize)] + struct F { + #[serde(deserialize_with = "super::deserialize")] + key: SigningKey, + } + + let test_key = p256::SecretKey::random(&mut rand::rngs::OsRng); + let F { key } = serde_json::from_value( + serde_json::json!({ "key": &*test_key.to_sec1_pem(Default::default()).unwrap() }), + ) + .unwrap(); + assert_eq!(key, SigningKey::from(test_key)); + } + + #[test] + fn deserialize_opt_some() { + #[derive(Deserialize)] + struct F { + #[serde(deserialize_with = "super::deserialize_opt")] + key: Option, + } + + let test_key = p256::SecretKey::random(&mut rand::rngs::OsRng); + let F { key } = serde_json::from_value( + serde_json::json!({ "key": &*test_key.to_sec1_pem(Default::default()).unwrap() }), + ) + .unwrap(); + assert_eq!(key.unwrap(), SigningKey::from(test_key)); + } + + #[test] + fn deserialize_opt_none() { + #[derive(Deserialize)] + struct F { + #[serde(default, deserialize_with = "super::deserialize_opt")] + key: Option, + } + + let F { key } = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(key, None); + + let F { key } = serde_json::from_value(serde_json::json!({ "key": null })).unwrap(); + assert_eq!(key, None); + } + } +} + +pub fn load_config_from_env(env: &worker::Env) -> Result { + const SERVICE_CONFIG: &str = "SERVICE_CONFIG"; + const SIGNING_KEY: &str = "SIGNING_KEY"; + + let mut config = env + .object_var::(SERVICE_CONFIG) + .map_err(|e| fatal_error!(err = ?e, "failed to load SERVICE_CONFIG variable"))?; + + if config.taskprov.is_some() { + tracing::warn!("taskprov secrets are defined in plain text. Prefer using wrangler secrets"); + } else if matches!(env.var(taskprov_secrets::ENABLED), Ok(s) if s.to_string() == "true") { + config.taskprov = Some(taskprov_secrets::load(env)?); + } + + if config.signing_key.is_some() { + tracing::warn!("signing key is defined in plain text. Prefer using wrangler secrets"); + } else { + config.signing_key = env + .var(SIGNING_KEY) + .ok() + .map(|s| p256::SecretKey::from_sec1_pem(&s.to_string()).map(SigningKey::from)) + .transpose() + .map_err(|e| fatal_error!(err = ?e, "failed to deserialize SIGNING_KEY"))? + } + Ok(config) +} + +mod taskprov_secrets { + use super::{PeerBearerToken, TaskprovConfig}; + use daphne::{fatal_error, DapError}; + use daphne_service_utils::bearer_token::BearerToken; + + pub const ENABLED: &str = constcat::concat!(TASKPROV_SECRETS, "_", "ENABLED"); + + const TASKPROV_SECRETS: &str = "TASKPROV_SECRETS"; + const VDAF_VERIFY_KEY_INIT: &str = + constcat::concat!(TASKPROV_SECRETS, "_", "VDAF_VERIFY_KEY_INIT"); + const PEER_AUTH_LEADER_EXPECTED_TOKEN: &str = + constcat::concat!(TASKPROV_SECRETS, "_", "PEER_AUTH_EXPECT_LEADER_TOKEN"); + const PEER_AUTH_COLLECTOR_EXPECTED_TOKEN: &str = + constcat::concat!(TASKPROV_SECRETS, "_", "PEER_AUTH_EXPECT_COLLECTOR_TOKEN"); + const SELF_BEARER_TOKEN: &str = constcat::concat!(TASKPROV_SECRETS, "_", "SELF_BEARER_TOKEN"); + const TASKPROV_HPKE_COLLECTOR_CONFIG: &str = "TASKPROV_HPKE_COLLECTOR_CONFIG"; + + pub fn load(env: &worker::Env) -> Result { + Ok(super::TaskprovConfig { + hpke_collector_config: env.object_var(TASKPROV_HPKE_COLLECTOR_CONFIG).map_err( + |e| fatal_error!(err = ?e, "failed to load TASKPROV_HPKE_COLLECTOR_CONFIG"), + )?, + vdaf_verify_key_init: { + let key = VDAF_VERIFY_KEY_INIT; + hex::decode( + env.var(key) + .map(|t| t.to_string()) + .map_err(|e| fatal_error!(err = ?e, "failed to load {key}"))?, + ) + .map_err(|e| fatal_error!(err = ?e, "invalid {key}"))? + .try_into() + .map_err(|e: Vec<_>| { + fatal_error!( + err = format!("{key} of invalid length. Got {} expected 32", e.len()) + ) + })? + }, + peer_auth: match ( + env.var(PEER_AUTH_LEADER_EXPECTED_TOKEN), + env.var(PEER_AUTH_COLLECTOR_EXPECTED_TOKEN), + ) { + (Ok(_), Ok(_)) => { + return Err(fatal_error!( + err = format!( + "{} and {} were defined simultaneously, this is not allowed", + PEER_AUTH_LEADER_EXPECTED_TOKEN, PEER_AUTH_COLLECTOR_EXPECTED_TOKEN + ) + )) + } + (Ok(leader), _) => PeerBearerToken::Leader { + expected_token: leader.to_string().into(), + }, + (_, Ok(collector)) => PeerBearerToken::Collector { + expected_token: collector.to_string().into(), + }, + (Err(e), _) => { + return Err(fatal_error!( + err = ?e, + "failed to load {} or {}", + PEER_AUTH_LEADER_EXPECTED_TOKEN, + PEER_AUTH_COLLECTOR_EXPECTED_TOKEN + )) + } + }, + self_bearer_token: env + .var(SELF_BEARER_TOKEN) + .ok() + .map(|t| BearerToken::from(t.to_string())), + }) + } +} diff --git a/crates/daphne-worker/src/aggregator/metrics.rs b/crates/daphne-worker/src/aggregator/metrics.rs new file mode 100644 index 000000000..f9ada3d64 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/metrics.rs @@ -0,0 +1,139 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! Daphne-Worker metrics. + +use daphne::metrics::DaphneMetrics; + +pub trait DaphneServiceMetrics { + fn abort_count_inc(&self, label: &str); + fn count_http_status_code(&self, status_code: u16); + fn daphne(&self) -> &dyn DaphneMetrics; + fn auth_method_inc(&self, method: AuthMethod); + fn aggregate_job_latency(&self, time: std::time::Duration); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AuthMethod { + BearerToken, + TlsClientAuth, +} + +mod prometheus { + use super::DaphneServiceMetrics; + use daphne::{ + fatal_error, + metrics::{prometheus::DaphnePromMetrics, DaphneMetrics, ReportStatus}, + DapError, + }; + use prometheus::{register_int_counter_vec_with_registry, IntCounterVec, Registry}; + use std::time::Duration; + + impl DaphneMetrics for DaphnePromServiceMetrics { + fn report_inc_by(&self, status: ReportStatus, val: u64) { + self.daphne.report_inc_by(status, val); + } + + fn inbound_req_inc(&self, request_type: daphne::metrics::DaphneRequestType) { + self.daphne.inbound_req_inc(request_type); + } + + fn agg_job_started_inc(&self) { + self.daphne.agg_job_started_inc(); + } + + fn agg_job_completed_inc(&self) { + self.daphne.agg_job_completed_inc(); + } + + fn agg_job_observe_batch_size(&self, val: usize) { + self.daphne.agg_job_observe_batch_size(val); + } + + fn agg_job_put_span_retry_inc(&self) { + self.daphne.agg_job_put_span_retry_inc(); + } + } + + impl DaphneServiceMetrics for DaphnePromServiceMetrics { + fn abort_count_inc(&self, label: &str) { + self.dap_abort_counter.with_label_values(&[label]).inc(); + } + + fn count_http_status_code(&self, status_code: u16) { + self.http_status_code_counter + .with_label_values(&[&status_code.to_string()]) + .inc(); + } + + fn auth_method_inc(&self, method: super::AuthMethod) { + let method = match method { + super::AuthMethod::TlsClientAuth => "mutual_tls", + super::AuthMethod::BearerToken => "tls_client_auth", + }; + self.auth_method.with_label_values(&[method]).inc(); + } + + fn daphne(&self) -> &dyn DaphneMetrics { + self + } + + fn aggregate_job_latency(&self, _time: Duration) { + // unimplemented by default due to elevated cardinality + } + } + + #[derive(Clone)] + pub struct DaphnePromServiceMetrics { + /// Daphne metrics. + daphne: DaphnePromMetrics, + + /// HTTP response status. + http_status_code_counter: IntCounterVec, + + /// DAP aborts. + dap_abort_counter: IntCounterVec, + + /// Counts the used authentication methods + auth_method: IntCounterVec, + } + + impl DaphnePromServiceMetrics { + pub fn register(registry: &Registry) -> Result { + let http_status_code_counter = register_int_counter_vec_with_registry!( + "http_status_code", + "HTTP response status code.", + &["code"], + registry + ) + .map_err(|e| fatal_error!(err = ?e, "failed to register http_status_code"))?; + + let dap_abort_counter = register_int_counter_vec_with_registry!( + "dap_abort", + "DAP aborts.", + &["reason"], + registry + ) + .map_err(|e| fatal_error!(err = ?e, "failed to register dap_abort"))?; + + let auth_method = register_int_counter_vec_with_registry!( + "auth_method", + "The authentication method used", + &["method"], + registry + ) + .map_err(|e| fatal_error!(err = ?e, "failed to register dap_abort"))?; + + let daphne = DaphnePromMetrics::register(registry)?; + + Ok(Self { + daphne, + http_status_code_counter, + dap_abort_counter, + auth_method, + }) + } + } +} + +pub use prometheus::DaphnePromServiceMetrics; diff --git a/crates/daphne-worker/src/aggregator/mod.rs b/crates/daphne-worker/src/aggregator/mod.rs new file mode 100644 index 000000000..fb6fc4228 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/mod.rs @@ -0,0 +1,155 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +mod config; +mod metrics; +mod roles; +mod router; + +use crate::storage::{kv, Do, Kv}; +use config::{DaphneServiceConfig, PeerBearerToken}; +use daphne::{ + audit_log::{AuditLog, NoopAuditLog}, + constants::DapRole, + fatal_error, + messages::TaskId, + roles::{leader::in_memory_leader::InMemoryLeaderState, DapAggregator as _}, + DapError, +}; +use daphne_service_utils::bearer_token::BearerToken; +use either::Either::{self, Left, Right}; +use metrics::DaphneServiceMetrics; +use roles::BearerTokens; +use router::DaphneService; +use std::sync::{Arc, LazyLock, Mutex}; +use worker::send::SendWrapper; + +pub use router::handle_dap_request; + +pub struct App { + http: reqwest::Client, + env: SendWrapper, + kv_state: kv::State, + metrics: Box, + service_config: DaphneServiceConfig, + audit_log: Box, + + /// Volatile memory for the Leader, including the work queue, pending reports, and pending + /// colleciton requests. Note that in a production Leader, it is necessary to store this state + /// across requsets. + test_leader_state: Arc>, +} + +static_assertions::assert_impl_all!(App: Send, Sync); + +#[async_trait::async_trait] +impl DaphneService for App { + fn server_metrics(&self) -> &dyn DaphneServiceMetrics { + &*self.metrics + } + + fn signing_key(&self) -> Option<&p256::ecdsa::SigningKey> { + self.service_config.signing_key.as_ref() + } + + async fn check_bearer_token( + &self, + presented_token: &BearerToken, + sender: DapRole, + task_id: TaskId, + is_taskprov: bool, + ) -> Result<(), Either> { + let reject = |extra_args| { + Err(Left(format!( + "the indicated bearer token is incorrect for the {sender:?} {extra_args}", + ))) + }; + if let Some(taskprov) = self + .service_config + .taskprov + .as_ref() + // we only use taskprov auth if it's allowed by config and if the request is using taskprov + .filter(|_| self.service_config.taskprov.is_some() && is_taskprov) + { + match (&taskprov.peer_auth, sender) { + (PeerBearerToken::Leader { expected_token }, DapRole::Leader) + | (PeerBearerToken::Collector { expected_token }, DapRole::Collector) + if expected_token == presented_token => + { + Ok(()) + } + (PeerBearerToken::Leader { .. }, DapRole::Collector) => Err(Right(fatal_error!( + err = "expected a leader sender but got a collector sender" + ))), + (PeerBearerToken::Collector { .. }, DapRole::Leader) => Err(Right(fatal_error!( + err = "expected a collector sender but got a leader sender" + ))), + _ => reject(format_args!("using taskprov")), + } + } else if self + .bearer_tokens() + .matches(sender, task_id, presented_token) + .await + .map_err(|e| { + Right(fatal_error!( + err = ?e, + "internal error occurred while running authentication" + )) + })? + { + Ok(()) + } else { + reject(format_args!("with task_id {task_id}")) + } + } + + async fn is_using_taskprov(&self, req: &daphne::DapRequestMeta) -> Result { + if req.taskprov_advertisement.is_some() { + Ok(true) + } else if self + .get_task_config_for(&req.task_id) + .await? + .is_some_and(|task_config| task_config.method_is_taskprov()) + { + tracing::warn!("Client referencing a taskprov task id without taskprov advertisement"); + Ok(true) + } else { + Ok(false) + } + } +} + +impl App { + /// Create a new configured app. See [`App`] for details. + pub fn new( + env: worker::Env, + registry: &prometheus::Registry, + audit_log: impl Into>>, + ) -> Result { + static PERSISTENT_ENOUGH_STATE: LazyLock>> = + LazyLock::new(Default::default); + let metrics = metrics::DaphnePromServiceMetrics::register(registry)?; + let service_config = config::load_config_from_env(&env)?; + Ok(Self { + http: reqwest::Client::new(), + env: SendWrapper(env), + kv_state: Default::default(), + metrics: Box::new(metrics), + audit_log: audit_log.into().unwrap_or_else(|| Box::new(NoopAuditLog)), + service_config, + test_leader_state: PERSISTENT_ENOUGH_STATE.clone(), + }) + } + + fn durable(&self) -> Do<'_> { + Do::new(&self.env) + } + + fn kv(&self) -> Kv<'_> { + Kv::new(&self.env, &self.kv_state) + } + + fn bearer_tokens(&self) -> BearerTokens<'_> { + BearerTokens::from(Kv::new(&self.env, &self.kv_state)) + } +} diff --git a/crates/daphne-worker/src/aggregator/roles/aggregator.rs b/crates/daphne-worker/src/aggregator/roles/aggregator.rs new file mode 100644 index 000000000..7ffc4e938 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/roles/aggregator.rs @@ -0,0 +1,430 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use crate::{ + aggregator::App, + storage::kv::{self, KvGetOptions}, +}; +use daphne::{ + audit_log::AuditLog, + constants::DapAggregatorRole, + error::DapAbort, + fatal_error, + hpke::{self, info_and_aad, HpkeConfig, HpkeReceiverConfig}, + messages::{self, BatchId, BatchSelector, HpkeCiphertext, TaskId}, + metrics::DaphneMetrics, + roles::{ + aggregator::{MergeAggShareError, TaskprovConfig}, + DapAggregator, + }, + taskprov, DapAggregateShare, DapAggregateSpan, DapError, DapGlobalConfig, DapTaskConfig, + DapVersion, +}; +use daphne_service_utils::durable_requests::bindings::{ + self, AggregateStoreMergeOptions, AggregateStoreMergeReq, AggregateStoreMergeResp, +}; +use futures::{future::try_join_all, StreamExt as _, TryFutureExt as _, TryStreamExt as _}; +use mappable_rc::Marc; +use std::{num::NonZeroUsize, ops::Range}; +use worker::send::SendFuture; + +#[async_trait::async_trait] +impl DapAggregator for App { + #[tracing::instrument(skip(self, task_config, agg_share_span))] + async fn try_put_agg_share_span( + &self, + task_id: &TaskId, + task_config: &DapTaskConfig, + agg_share_span: DapAggregateSpan, + ) -> DapAggregateSpan> { + let durable = self.durable(); + + let replay_protection = super::fetch_replay_protection_override(self.kv()).await; + + futures::stream::iter(agg_share_span) + .map(|(bucket, (agg_share, report_metadatas))| async { + let result = durable + .request( + bindings::AggregateStore::Merge, + (task_config.version, task_id, &bucket), + ) + .encode(&AggregateStoreMergeReq { + contained_reports: report_metadatas.iter().map(|(id, _)| *id).collect(), + agg_share_delta: agg_share, + options: AggregateStoreMergeOptions { + skip_replay_protection: replay_protection.disabled(), + }, + }) + .send::() + .await + .map_err(|e| fatal_error!(err = ?e, "failed to merge aggregate share")); + let result = match result { + Ok(AggregateStoreMergeResp::Ok) => Ok(()), + Ok(AggregateStoreMergeResp::AlreadyCollected) => { + Err(MergeAggShareError::AlreadyCollected) + } + Ok(AggregateStoreMergeResp::ReplaysDetected(replays)) => { + Err(MergeAggShareError::ReplaysDetected(replays)) + } + Err(e) => Err(MergeAggShareError::Other(e)), + }; + (bucket, (result, report_metadatas)) + }) + .buffer_unordered(usize::MAX) + .collect() + .await + } + + #[tracing::instrument(skip(self))] + async fn get_agg_share( + &self, + task_id: &TaskId, + batch_sel: &BatchSelector, + ) -> Result { + let task_config = self + .get_task_config_for(task_id) + .await? + .ok_or(DapError::Abort(DapAbort::UnrecognizedTask { + task_id: *task_id, + }))?; + + let durable = self.durable(); + let mut requests = Vec::new(); + for bucket in task_config.as_ref().batch_span_for_sel(batch_sel)? { + requests.push( + durable + .request( + bindings::AggregateStore::Get, + (task_config.as_ref().version, task_id, &bucket), + ) + .send(), + ); + } + let responses: Vec = try_join_all(requests) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to get agg shares from durable objects"))?; + let mut agg_share = DapAggregateShare::default(); + for agg_share_delta in responses { + agg_share.merge(agg_share_delta)?; + } + + Ok(agg_share) + } + + #[tracing::instrument(skip(self))] + async fn mark_collected( + &self, + task_id: &TaskId, + batch_sel: &BatchSelector, + ) -> Result<(), DapError> { + let task_config = self + .get_task_config_for(task_id) + .await? + .ok_or(DapError::Abort(DapAbort::UnrecognizedTask { + task_id: *task_id, + }))?; + + let durable = self.durable(); + let mut requests = Vec::new(); + for bucket in task_config.as_ref().batch_span_for_sel(batch_sel)? { + requests.push( + durable + .request( + bindings::AggregateStore::MarkCollected, + (task_config.as_ref().version, task_id, &bucket), + ) + .send::<()>(), + ); + } + + try_join_all(requests) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to mark agg shares as collected"))?; + Ok(()) + } + + async fn get_global_config(&self) -> Result { + let mut global_config = self.service_config.global.clone(); + + // Check KV for overrides to the global configuration. + let opt = KvGetOptions { + // If an override is not found, then don't try again until the cache line expires. + cache_not_found: true, + }; + + // "global_config/override/default_num_agg_span_shards" + if let Some(default_num_agg_span_shards) = self + .kv() + .get_cloned::>( + &kv::prefix::GlobalOverrides::DefaultNumAggSpanShards, + &opt, + ) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to get global override for the default_num_agg_span_shards"))? + { + global_config.default_num_agg_span_shards = default_num_agg_span_shards; + } + + Ok(global_config) + } + + fn get_taskprov_config(&self) -> Option> { + self.service_config + .taskprov + .as_ref() + .map(|t| TaskprovConfig { + hpke_collector_config: &t.hpke_collector_config, + vdaf_verify_key_init: &t.vdaf_verify_key_init, + }) + } + + async fn taskprov_opt_in( + &self, + task_id: &TaskId, + task_config: taskprov::DapTaskConfigNeedsOptIn, + ) -> Result { + let param = SendFuture::new( + self.kv() + .get_or_insert_with::( + task_id, + &KvGetOptions::default(), + || async { + let global_config = self.get_global_config().await?; + Ok::<_, DapError>(taskprov::OptInParam { + not_before: self.get_current_time(), + num_agg_span_shards: global_config.default_num_agg_span_shards, + }) + }, + Some(task_config.task_expiration), + ), + ) + .await + .map_err(|e| match e { + kv::GetOrInsertError::Other(e) => e.clone(), + kv::GetOrInsertError::StorageProxy(e) => { + fatal_error!(err = ?e, "failed to get TaskprovOptInParam from kv") + } + })?; + + Ok(task_config.into_opted_in(¶m)) + } + + async fn taskprov_put( + &self, + task_id: &TaskId, + task_config: DapTaskConfig, + ) -> Result<(), DapError> { + let expiration_time = task_config.not_after; + + match self.service_config.role { + DapAggregatorRole::Leader => { + SendFuture::new(self.kv().put_with_expiration::( + task_id, + task_config, + expiration_time, + )) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to put the a task config in kv"))?; + } + DapAggregatorRole::Helper => { + self.kv() + .only_cache_put::(task_id, task_config) + .await; + } + } + Ok(()) + } + + async fn get_task_config_for<'req>( + &'req self, + task_id: &'req TaskId, + ) -> Result, DapError> { + self.kv() + .get_cloned::(task_id, &KvGetOptions::default()) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to get a task config from kv: {task_id}")) + } + + fn get_current_time(&self) -> messages::Time { + worker::Date::now().as_millis() / 1000 + } + + async fn is_batch_overlapping( + &self, + task_id: &TaskId, + batch_sel: &BatchSelector, + ) -> Result { + let task_config = self + .get_task_config_for(task_id) + .await? + .ok_or(DapError::Abort(DapAbort::UnrecognizedTask { + task_id: *task_id, + }))?; + + // Check whether the request overlaps with previous requests. This is done by + // checking the AggregateStore and seeing whether it requests for aggregate + // shares that have already been marked collected. + let durable = self.durable(); + Ok( + futures::stream::iter(task_config.batch_span_for_sel(batch_sel)?) + .map(|bucket| { + durable + .request( + bindings::AggregateStore::CheckCollected, + (task_config.as_ref().version, task_id, &bucket), + ) + .send() + }) + .buffer_unordered(usize::MAX) + .try_any(std::future::ready) + .await + .map_err( + |e| fatal_error!(err = ?e, "failed to check if agg shares are collected"), + )?, + ) + } + + async fn batch_exists(&self, task_id: &TaskId, batch_id: &BatchId) -> Result { + let task_config = self + .get_task_config_for(task_id) + .await? + .ok_or(DapError::Abort(DapAbort::UnrecognizedTask { + task_id: *task_id, + }))?; + let version = task_config.as_ref().version; + + let agg_span = task_config.batch_span_for_sel(&BatchSelector::LeaderSelectedByBatchId { + batch_id: *batch_id, + })?; + + futures::stream::iter(agg_span) + .map(|bucket| async move { + let durable = self.durable(); + let params = (version, task_id, &bucket); + + let get_report_count = || { + durable + .request(bindings::AggregateStore::ReportCount, params) + .send::() + }; + + // TODO: remove this after the worker has this feature deployed. + let backwards_compat_get_report_count = || { + durable + .request(bindings::AggregateStore::Get, params) + .send::() + .map_ok(|r| r.report_count) + }; + + let count = get_report_count() + .or_else(|_| backwards_compat_get_report_count()) + .await + .map_err(|e| { + fatal_error!( + err = ?e, + params = ?params, + "failed fetching report count of an agg share" + ) + })?; + Ok(count > 0) + }) + .buffer_unordered(usize::MAX) + .collect::>() + .await + .into_iter() + .reduce(|a, b| Ok(a? || b?)) + .unwrap_or(Ok(false)) + } + + fn metrics(&self) -> &dyn DaphneMetrics { + self.metrics.daphne() + } + + fn audit_log(&self) -> &dyn AuditLog { + &*self.audit_log + } + + fn valid_report_time_range(&self) -> Range { + let now = self.get_current_time(); + + let start = now.saturating_sub(self.service_config.report_storage_epoch_duration); + let end = now.saturating_add(self.service_config.report_storage_max_future_time_skew); + + start..end + } +} + +pub struct HpkeDecrypter(Marc>); + +impl hpke::HpkeDecrypter for HpkeDecrypter { + fn hpke_decrypt( + &self, + info: impl info_and_aad::InfoAndAad, + ciphertext: &HpkeCiphertext, + ) -> Result, DapError> { + self.0.hpke_decrypt(info, ciphertext) + } +} + +#[async_trait::async_trait] +impl hpke::HpkeProvider for App { + type WrappedHpkeConfig<'s> = Marc; + type ReceiverConfigs<'s> = HpkeDecrypter; + + async fn get_hpke_config_for<'s>( + &'s self, + version: DapVersion, + _task_id: Option<&TaskId>, + ) -> Result, DapError> { + // TODO(draft-09) Remove `_task_id` parameter, as task id is no longer passed from draft-12. + self.kv() + .get_mapped::( + &version, + &KvGetOptions::default(), + |config_list| { + // Assume the first HPKE config in the receiver list has the highest preference. + // + // TODO draft02 cleanup: Return the entire list and not just a single HPKE config. + // Note that we previously returned one because this was required in draft02. + config_list.iter().next().map(|hpke| &hpke.config) + }, + ) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to get the hpke config"))? + .ok_or_else(|| fatal_error!(err = "there are no hpke configs in kv!!", %version)) + } + + async fn can_hpke_decrypt(&self, task_id: &TaskId, config_id: u8) -> Result { + let version = self + .get_task_config_for(task_id) + .await? + .ok_or(DapError::Abort(DapAbort::UnrecognizedTask { + task_id: *task_id, + }))? + .version; + + Ok(self + .kv() + .peek::( + &version, + &KvGetOptions::default(), + |config_list| config_list.iter().any(|r| r.config.id == config_id), + ) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to get at the hpke config"))? + .unwrap_or(false)) + } + + async fn get_hpke_receiver_configs<'s>( + &'s self, + version: DapVersion, + ) -> Result, DapError> { + Ok(HpkeDecrypter( + self.kv() + .get::(&version, &KvGetOptions::default()) + .await + .map_err(|e| fatal_error!(err= ?e,"failed to get the hpke config"))? + .ok_or_else(|| fatal_error!(err="there are no hpke configs in kv!!", %version))?, + )) + } +} diff --git a/crates/daphne-worker/src/aggregator/roles/helper.rs b/crates/daphne-worker/src/aggregator/roles/helper.rs new file mode 100644 index 000000000..c6aec453c --- /dev/null +++ b/crates/daphne-worker/src/aggregator/roles/helper.rs @@ -0,0 +1,7 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use crate::aggregator::App; +use daphne::roles::DapHelper; + +impl DapHelper for App {} diff --git a/crates/daphne-worker/src/aggregator/roles/leader.rs b/crates/daphne-worker/src/aggregator/roles/leader.rs new file mode 100644 index 000000000..0a6e48fa4 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/roles/leader.rs @@ -0,0 +1,274 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use std::borrow::Cow; + +use crate::{aggregator::App, elapsed}; +use axum::{async_trait, http::Method}; +use daphne::{ + constants::{DapMediaType, DapRole}, + error::DapAbort, + fatal_error, + messages::{BatchId, BatchSelector, Collection, CollectionJobId, Report, TaskId}, + roles::{leader::WorkItem, DapAggregator as _, DapLeader}, + DapAggregationParam, DapCollectionJob, DapError, DapRequestMeta, DapResponse, DapVersion, +}; +use daphne_service_utils::http_headers; +use http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; +use prio::codec::ParameterizedEncode; +use tracing::{error, info}; +use url::Url; + +#[async_trait] +impl DapLeader for App { + async fn put_report(&self, report: &Report, task_id: &TaskId) -> Result<(), DapError> { + let task_config = self + .get_task_config_for(task_id) + .await? + .ok_or(DapAbort::UnrecognizedTask { task_id: *task_id })?; + + self.test_leader_state + .lock() + .unwrap() + .put_report(task_id, &task_config, report.clone()) + } + + async fn current_batch(&self, task_id: &TaskId) -> Result { + let task_config = self + .get_task_config_for(task_id) + .await? + .ok_or(DapError::Abort(DapAbort::UnrecognizedTask { + task_id: *task_id, + }))?; + + self.test_leader_state + .lock() + .unwrap() + .current_batch(task_id, &task_config) + } + + async fn init_collect_job( + &self, + task_id: &TaskId, + coll_job_id: &CollectionJobId, + batch_sel: BatchSelector, + agg_param: DapAggregationParam, + ) -> Result<(), DapError> { + let task_config = self + .get_task_config_for(task_id) + .await? + .ok_or(DapAbort::UnrecognizedTask { task_id: *task_id })?; + + self.test_leader_state.lock().unwrap().init_collect_job( + task_id, + &task_config, + coll_job_id, + batch_sel, + agg_param, + ) + } + + async fn poll_collect_job( + &self, + task_id: &TaskId, + coll_job_id: &CollectionJobId, + ) -> Result { + self.test_leader_state + .lock() + .unwrap() + .poll_collect_job(task_id, coll_job_id) + } + + async fn finish_collect_job( + &self, + task_id: &TaskId, + coll_job_id: &CollectionJobId, + collection: &Collection, + ) -> Result<(), DapError> { + self.test_leader_state + .lock() + .unwrap() + .finish_collect_job(task_id, coll_job_id, collection) + } + + async fn dequeue_work(&self, num_items: usize) -> Result, DapError> { + self.test_leader_state + .lock() + .unwrap() + .dequeue_work(num_items) + } + + async fn enqueue_work(&self, items: Vec) -> Result<(), DapError> { + self.test_leader_state.lock().unwrap().enqueue_work(items) + } + + async fn send_http_post

( + &self, + meta: DapRequestMeta, + url: Url, + payload: P, + ) -> Result + where + P: Send + ParameterizedEncode, + { + self.send_http(meta, Method::POST, url, payload).await + } + + async fn send_http_put

( + &self, + meta: DapRequestMeta, + url: Url, + payload: P, + ) -> Result + where + P: Send + ParameterizedEncode, + { + self.send_http(meta, Method::PUT, url, payload).await + } +} + +impl App { + #[worker::send] + async fn send_http

( + &self, + meta: DapRequestMeta, + method: Method, + url: Url, + payload: P, + ) -> Result + where + P: Send + ParameterizedEncode, + { + let content_type = meta + .media_type + .and_then(|mt| mt.as_str_for_version(meta.version)) + .ok_or_else(|| { + fatal_error!( + err = "failed to construct content-type", + ?meta.media_type, + ?meta.version, + ) + })?; + + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_str(content_type) + .map_err(|e| fatal_error!(err = ?e, "failed to construct content-type header"))?, + ); + + let bearer_token = if meta.taskprov_advertisement.is_some() { + if let Some(bearer_token) = self + .service_config + .taskprov + .as_ref() + .and_then(|t| t.self_bearer_token.as_ref()) + { + Cow::Borrowed(bearer_token) + } else { + return Err(DapError::Abort(DapAbort::UnauthorizedRequest { + detail: format!( + "taskprov authentication not setup for authentication with peer at {url}", + ), + task_id: meta.task_id, + })); + } + } else if let Some(bearer_token) = self + .bearer_tokens() + .get(DapRole::Leader, meta.task_id) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to get leader bearer token"))? + { + Cow::Owned(bearer_token) + } else { + return Err(DapError::Abort(DapAbort::UnauthorizedRequest { + detail: format!( + "no suitable authentication method found for authenticating with peer at {url}", + ), + task_id: meta.task_id, + })); + }; + + headers.insert( + HeaderName::from_static(http_headers::DAP_AUTH_TOKEN), + HeaderValue::from_str(bearer_token.as_str()) + .map_err(|e| fatal_error!(err = ?e, "failed to construct authentication header"))?, + ); + + if let Some(taskprov_advertisement) = &meta.taskprov_advertisement { + headers.insert( + HeaderName::from_static(http_headers::DAP_TASKPROV), + HeaderValue::from_str( + &taskprov_advertisement.serialize_to_header_value(meta.version)?, + ) + .map_err(|e| fatal_error!(err = ?e, "failed to construct dap-taskprov header"))?, + ); + } + + let req_builder = self + .http + .request(method, url.clone()) + .body( + payload + .get_encoded_with_param(&meta.version) + .map_err(|e| DapAbort::from_codec_error(e, meta.task_id))?, + ) + .headers(headers); + + let start = worker::Date::now(); + let reqwest_resp = req_builder + .send() + .await + .map_err(|e| fatal_error!(err = ?e, "failed to send request to the helper"))?; + info!("request to {} completed in {:?}", url, elapsed(&start)); + let status = reqwest_resp.status(); + + if status.is_success() { + // Translate the reqwest response into a Worker response. + let media_type = reqwest_resp + .headers() + .get_all(header::CONTENT_TYPE) + .into_iter() + .filter_map(|h| h.to_str().ok()) + .find_map(|h| DapMediaType::from_str_for_version(meta.version, h)) + .ok_or_else(|| fatal_error!(err = "peer response is missing media type"))?; + + let payload = reqwest_resp + .bytes() + .await + .map_err(|e| fatal_error!(err = ?e, "failed to read body of helper response"))? + .to_vec(); + + Ok(DapResponse { + version: meta.version, + payload, + media_type, + }) + } else { + error!("{}: request failed: {:?}", url, reqwest_resp); + match status { + StatusCode::BAD_REQUEST => { + if let Some(content_type) = reqwest_resp.headers().get(header::CONTENT_TYPE) { + if content_type == "application/problem+json" { + error!( + "Problem details: {}", + reqwest_resp.text().await.map_err( + |e| fatal_error!(err = ?e, "failed to read body of helper error response") + )? + ); + } + } + } + StatusCode::UNAUTHORIZED => { + return Err(DapAbort::UnauthorizedRequest { + detail: format!("helper at {url} didn't authorize our request"), + task_id: meta.task_id, + } + .into()) + } + _ => {} + } + Err(fatal_error!(err = "request aborted by peer")) + } + } +} diff --git a/crates/daphne-worker/src/aggregator/roles/mod.rs b/crates/daphne-worker/src/aggregator/roles/mod.rs new file mode 100644 index 000000000..574d840d3 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/roles/mod.rs @@ -0,0 +1,349 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +mod aggregator; +mod helper; +mod leader; + +use crate::storage::{ + self, + kv::{self, KvGetOptions}, + Kv, +}; +use daphne::{constants::DapRole, messages::TaskId, ReplayProtection}; +use daphne_service_utils::bearer_token::BearerToken; + +pub async fn fetch_replay_protection_override(kv: Kv<'_>) -> ReplayProtection { + let skip_replay_protection = kv + .get_cloned::>( + &kv::prefix::GlobalOverrides::SkipReplayProtection, + &KvGetOptions { + cache_not_found: true, + }, + ) + .await + .inspect_err( + |e| tracing::error!(error = ?e, "failed to fetch skip_replay_protection from kv"), + ) + .ok() // treat error as false + .flatten() + .unwrap_or_default(); // treat missing as false + if skip_replay_protection { + tracing::debug!("replay protection is disabled"); + ReplayProtection::InsecureDisabled + } else { + ReplayProtection::Enabled + } +} + +/// Bearer token for for tasks configured manually or via the [ppm-dap-interop-test][interop] draft. +/// +/// [interop]: https://divergentdave.github.io/draft-dcook-ppm-dap-interop-test-design/draft-dcook-ppm-dap-interop-test-design.html +pub(crate) struct BearerTokens<'s> { + kv: kv::Kv<'s>, +} + +impl<'s> From> for BearerTokens<'s> { + fn from(kv: kv::Kv<'s>) -> Self { + Self { kv } + } +} + +impl BearerTokens<'_> { + #[cfg(feature = "test-utils")] + pub async fn put_if_not_exists( + &self, + sender: DapRole, + task_id: TaskId, + token: BearerToken, + ) -> Result, storage::Error> { + self.kv + .put_if_not_exists::(&(sender, task_id).into(), token) + .await + } + + /// Checks if a presented token matches the expected token of a task. + /// + /// # Returns + /// + /// - `Ok(true)` if the task exists and the token matches + /// - `Ok(false)` if the task doesn't exist or the token doesn't match + /// - `Err(error)` if any io error occurs while fetching + pub async fn matches( + &self, + sender: DapRole, + task_id: TaskId, + token: &BearerToken, + ) -> Result { + self.kv + .peek::( + &(sender, task_id).into(), + &kv::KvGetOptions { + cache_not_found: false, + }, + |stored_token| stored_token == token, + ) + .await + .map(|s| s.is_some_and(|matches| matches)) + } + + pub async fn get( + &self, + sender: DapRole, + task_id: TaskId, + ) -> Result, storage::Error> { + self.kv + .get_cloned::( + &(sender, task_id).into(), + &kv::KvGetOptions { + cache_not_found: false, + }, + ) + .await + } +} + +#[cfg(feature = "test-utils")] +mod test_utils { + use super::super::App; + use crate::{storage::kv, storage_proxy}; + use daphne::{ + constants::{DapAggregatorRole, DapRole}, + fatal_error, + hpke::{HpkeConfig, HpkeReceiverConfig}, + messages::decode_base64url_vec, + roles::DapAggregator as _, + vdaf::{Prio3Config, VdafConfig}, + DapBatchMode, DapError, DapTaskConfig, DapVersion, + }; + use daphne_service_utils::{ + bearer_token::BearerToken, + test_route_types::{InternalTestAddTask, InternalTestEndpointForTask}, + }; + use prio::codec::Decode; + use std::num::NonZeroUsize; + + impl App { + pub(crate) async fn internal_delete_all(&self) -> Result<(), DapError> { + tracing::info!("deleting leader state"); + self.test_leader_state.lock().unwrap().delete_all(); + + tracing::info!("deleting kv state"); + // use daphne_service_utils::durable_requests::PURGE_STORAGE; + self.kv_state.reset(); + + tracing::info!("purging storage"); + storage_proxy::storage_purge(&self.env.0) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to purge storage"))?; + + Ok(()) + } + + pub(crate) fn internal_endpoint_for_task( + &self, + version: DapVersion, + cmd: InternalTestEndpointForTask, + ) -> Result { + if self.service_config.role != cmd.role { + return Err(fatal_error!(err = "role mismatch")); + } + let path = self + .service_config + .base_url + .as_ref() + .ok_or_else(|| fatal_error!(err = "base_url not configured"))? + .path(); + Ok(format!("{path}{}/", version.as_ref())) + } + + pub(crate) async fn internal_add_task( + &self, + version: DapVersion, + cmd: InternalTestAddTask, + ) -> Result<(), DapError> { + // VDAF config. + let vdaf = match ( + cmd.vdaf.typ.as_ref(), + cmd.vdaf.bits, + cmd.vdaf.length, + cmd.vdaf.chunk_length, + cmd.vdaf.dimension, + ) { + ("Prio3Count", None, None, None, None) => VdafConfig::Prio3(Prio3Config::Count), + ("Prio3Sum", Some(max_measurement), None, None, None) => VdafConfig::Prio3(Prio3Config::Sum { + max_measurement: max_measurement.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse bits for Prio3Config::Sum"))?, + }), + ("Prio3SumVec", Some(bits), Some(length), Some(chunk_length), None) => { + VdafConfig::Prio3(Prio3Config::SumVec { + bits: bits.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse bits for Prio3Config::SumVec"))?, + length: length.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse length for Prio3Config::SumVec"))?, + chunk_length: chunk_length.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse chunk_length for Prio3Config::SumVec"))?, + }) + } + ("Prio3Histogram", None, Some(length), Some(chunk_length), None) => { + VdafConfig::Prio3(Prio3Config::Histogram { + length: length.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse length for Prio3Config::Histogram"))?, + chunk_length: chunk_length.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse chunk_length for Prio3Config::Histogram"))?, + }) + } + ("Prio3SumVecField64MultiproofHmacSha256Aes128", Some(bits), Some(length), Some(chunk_length), None) => { + VdafConfig::Prio3(Prio3Config::Draft09SumVecField64MultiproofHmacSha256Aes128 { + bits: bits.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse bits for Prio3Config::SumVecField64MultiproofHmacSha256Aes128"))?, + length: length.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse length for Prio3Config::SumVecField64MultiproofHmacSha256Aes128"))?, + chunk_length: chunk_length.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse chunk_length for Prio3Config::SumVecField64MultiproofHmacSha256Aes128"))?, + num_proofs: 2, + }) + } + ("Prio2", None, None, None, Some(dimension)) => VdafConfig::Prio2 { + dimension: dimension.parse().map_err(|e| fatal_error!(err = ?e, "failed to parse dimension for Prio2"))?, + }, + _ => return Err(fatal_error!(err = "command failed: unrecognized VDAF")), + }; + + // VDAF verification key. + let vdaf_verify_key_data = decode_base64url_vec(cmd.vdaf_verify_key.as_bytes()) + .ok_or_else(|| { + fatal_error!(err = "VDAF verify key is not valid URL-safe base64") + })?; + let vdaf_verify_key = vdaf + .get_decoded_verify_key(&vdaf_verify_key_data) + .map_err(|e| fatal_error!(err = ?e, "failed to decode verify key"))?; + + // Collector HPKE config. + let collector_hpke_config_data = + decode_base64url_vec(cmd.collector_hpke_config.as_bytes()).ok_or_else(|| { + fatal_error!(err = "HPKE collector config is not valid URL-safe base64") + })?; + let collector_hpke_config = HpkeConfig::get_decoded(&collector_hpke_config_data) + .map_err(|e| fatal_error!(err = ?e, "failed to decode hpke config"))?; + + // Leader authentication token. + let token = BearerToken::from(cmd.leader_authentication_token); + if self + .bearer_tokens() + .put_if_not_exists(DapRole::Leader, cmd.task_id, token) + .await + .is_err() + { + return Err(fatal_error!( + err = "command failed: token already exists for the given task and bearer role (leader)", + task_id = %cmd.task_id, + )); + }; + + // Collector authentication token. + match (cmd.role, cmd.collector_authentication_token) { + (DapAggregatorRole::Leader, Some(token_string)) => { + let token = BearerToken::from(token_string); + if self + .bearer_tokens() + .put_if_not_exists(DapRole::Collector, cmd.task_id, token) + .await + .is_err() + { + return Err(fatal_error!(err = format!( + "command failed: token already exists for the given task ({}) and bearer role (collector)", + cmd.task_id + ))); + } + } + (DapAggregatorRole::Leader, None) => { + return Err(fatal_error!( + err = "command failed: missing collector authentication token", + )) + } + (DapAggregatorRole::Helper, None) => (), + (DapAggregatorRole::Helper, Some(..)) => { + return Err(fatal_error!( + err = "command failed: unexpected collector authentication token", + )); + } + }; + + // Query configuraiton. + let query = match (cmd.batch_mode, cmd.max_batch_size) { + (1, None) => DapBatchMode::TimeInterval, + (1, Some(..)) => { + return Err(fatal_error!( + err = "command failed: unexpected max batch size" + )) + } + (2, max_batch_size) => DapBatchMode::LeaderSelected { max_batch_size }, + _ => { + return Err(fatal_error!( + err = "command failed: unrecognized batch mode" + )) + } + }; + + if self + .kv() + .put_if_not_exists_with_expiration::( + &cmd.task_id, + DapTaskConfig { + version, + leader_url: cmd.leader, + helper_url: cmd.helper, + time_precision: cmd.time_precision, + not_before: self.get_current_time(), + not_after: cmd.task_expiration, + min_batch_size: cmd.min_batch_size, + query, + vdaf, + vdaf_verify_key, + collector_hpke_config, + method: Default::default(), + num_agg_span_shards: NonZeroUsize::new(4).unwrap(), + }, + cmd.task_expiration, + ) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to put task config in kv"))? + .is_some() + { + Err(fatal_error!( + err = format!( + "command failed: config already exists for the given task ({})", + cmd.task_id + ) + )) + } else { + Ok(()) + } + } + + pub(crate) async fn internal_add_hpke_config( + &self, + version: DapVersion, + new_receiver: HpkeReceiverConfig, + ) -> Result<(), DapError> { + let mut config_list = self + .kv() + .get_cloned::(&version, &Default::default()) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to get hpke config"))? + .unwrap_or_default(); + + if config_list + .iter() + .any(|receiver| new_receiver.config.id == receiver.config.id) + { + return Err(fatal_error!( + err = format!( + "receiver config with id {} already exists", + new_receiver.config.id + ) + )); + } + + config_list.push(new_receiver); + + self.kv() + .put::(&version, config_list) + .await + .map_err(|e| fatal_error!(err = ?e, "failed to put hpke config"))?; + Ok(()) + } + } +} diff --git a/crates/daphne-worker/src/aggregator/router/aggregator.rs b/crates/daphne-worker/src/aggregator/router/aggregator.rs new file mode 100644 index 000000000..2916dd82a --- /dev/null +++ b/crates/daphne-worker/src/aggregator/router/aggregator.rs @@ -0,0 +1,178 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + response::{AppendHeaders, IntoResponse}, + routing::get, +}; +use daphne::{ + constants::DapMediaType, + fatal_error, + messages::{encode_base64url, TaskId}, + roles::{aggregator, DapAggregator}, + DapError, DapResponse, DapVersion, +}; +use daphne_service_utils::http_headers; +use p256::ecdsa::{signature::Signer, Signature, SigningKey}; +use serde::Deserialize; + +use super::{AxumDapResponse, DaphneService}; + +pub fn add_aggregator_routes(router: super::Router) -> super::Router +where + A: DapAggregator + DaphneService + Send + Sync + 'static, +{ + router.route("/:version/hpke_config", get(hpke_config)) +} + +#[derive(Deserialize)] +struct QueryTaskId { + task_id: Option, +} + +#[tracing::instrument(skip(app), fields(version, task_id))] +async fn hpke_config( + State(app): State>, + Query(QueryTaskId { task_id }): Query, + Path(version): Path, +) -> impl IntoResponse +where + A: DapAggregator + DaphneService, +{ + match aggregator::handle_hpke_config_req(&*app, version, task_id).await { + Ok(resp) => match app.signing_key().map(|k| sign_dap_response(k, &resp)) { + None => AxumDapResponse::new_success(resp, app.server_metrics()).into_response(), + Some(Ok(signed)) => ( + AppendHeaders([(http_headers::HPKE_SIGNATURE, &signed)]), + AxumDapResponse::new_success(resp, app.server_metrics()), + ) + .into_response(), + Some(Err(e)) => AxumDapResponse::new_error(e, app.server_metrics()).into_response(), + }, + Err(e) => AxumDapResponse::new_error(e, app.server_metrics()).into_response(), + } +} + +pub(crate) fn sign_dap_response( + signing_key: &SigningKey, + resp: &DapResponse, +) -> Result { + match resp.media_type { + DapMediaType::HpkeConfigList => { + let signature: Signature = signing_key.sign(&resp.payload); + Ok(encode_base64url(signature.to_der().as_bytes())) + } + _ => Err(fatal_error!( + err = "tried to sign invalid response", + ?resp.media_type + )), + } +} + +#[cfg(test)] +mod test { + use axum::{ + body::Body, + extract::Query, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use daphne::messages::{Base64Encode, TaskId}; + use daphne::{ + constants::DapMediaType, messages::decode_base64url_vec, DapResponse, DapVersion, + }; + use p256::pkcs8::EncodePrivateKey; + use rand::{thread_rng, Rng}; + use rcgen::CertificateParams; + use tower::ServiceExt; + use webpki::{EndEntityCert, ECDSA_P256_SHA256}; + + use super::{sign_dap_response, QueryTaskId}; + + #[tokio::test] + async fn can_parse_task_id() { + let task_id = TaskId(thread_rng().gen()); + let router: Router = Router::new().route( + "/", + get(move |Query(QueryTaskId { task_id: tid })| async move { + assert_eq!(tid, Some(task_id)); + }), + ); + + let status = router + .oneshot( + Request::builder() + .uri(format!("/?task_id={}", task_id.to_base64url())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + .status(); + + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn accepts_missing_task_id() { + let router: Router = Router::new().route( + "/", + get(move |Query(QueryTaskId { task_id: tid })| async move { + assert_eq!(tid, None); + }), + ); + + let status = router + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap() + .status(); + + assert_eq!(status, StatusCode::OK); + } + + // Check that a signature produced by Daphne-Worker will be verified properly by the Clients. + #[test] + fn rondtrip_sign_hpke_config() { + let signing_key = + p256::ecdsa::SigningKey::from(p256::SecretKey::random(&mut rand::rngs::OsRng)); + + // Create a self-signed certificate for the signing key. + let cert_der = { + let mut params = CertificateParams::new(["test-aggregator.example.com".to_string()]); + params.key_pair = Some( + rcgen::KeyPair::from_der(signing_key.to_pkcs8_der().unwrap().to_bytes().as_ref()) + .unwrap(), + ); + params + .distinguished_name + .push(rcgen::DnType::LocalityName, "Braga"); + params + .distinguished_name + .push(rcgen::DnType::OrganizationName, "Cloudflare Lda"); + + let cert = rcgen::Certificate::from_params(params).unwrap(); + cert.serialize_der().unwrap() + }; + + const PAYLOAD: &[u8] = b"dummy HPKE configuration"; + let resp = DapResponse { + version: DapVersion::default(), + media_type: DapMediaType::HpkeConfigList, + payload: PAYLOAD.to_vec(), + }; + + let signature = sign_dap_response(&signing_key, &resp).unwrap(); + + // Verify the signature. + let signature_bytes = decode_base64url_vec(signature.as_bytes()).unwrap(); + + let cert = EndEntityCert::try_from(cert_der.as_ref()).unwrap(); + cert.verify_signature(&ECDSA_P256_SHA256, PAYLOAD, signature_bytes.as_ref()) + .unwrap(); + } +} diff --git a/crates/daphne-worker/src/aggregator/router/extractor.rs b/crates/daphne-worker/src/aggregator/router/extractor.rs new file mode 100644 index 000000000..5b9762f62 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/router/extractor.rs @@ -0,0 +1,793 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use std::io::Cursor; + +use axum::{ + async_trait, + body::Bytes, + extract::{FromRequest, FromRequestParts, Path, Request}, +}; +use daphne::{ + constants::DapMediaType, + error::DapAbort, + fatal_error, + messages::{ + request::{CollectionPollReq, RequestBody}, + taskprov::TaskprovAdvertisement, + AggregateShareReq, AggregationJobInitReq, CollectionReq, Report, TaskId, + }, + DapError, DapRequest, DapRequestMeta, DapVersion, +}; +use daphne_service_utils::{bearer_token::BearerToken, http_headers}; +use http::{header::CONTENT_TYPE, HeaderMap}; +use prio::codec::ParameterizedDecode; +use serde::Deserialize; + +use super::super::metrics; + +use super::{AxumDapResponse, DaphneService}; + +/// Trait used to decode a DAP http body. +pub trait DecodeFromDapHttpBody: RequestBody + Sized { + fn decode_from_http_body(bytes: Bytes, meta: &DapRequestMeta) -> Result; +} + +macro_rules! impl_decode_from_dap_http_body { + ($($type:ident),*$(,)?) => { + $( + impl DecodeFromDapHttpBody for $type { + fn decode_from_http_body( + bytes: Bytes, + meta: &DapRequestMeta, + ) -> Result { + let mut cursor = Cursor::new(bytes.as_ref()); + // Check that media type matches. + meta.get_checked_media_type(DapMediaType::$type)?; + // Decode the body + $type::decode_with_param(&meta.version, &mut cursor) + .map_err(|e| DapAbort::from_codec_error(e, meta.task_id)) + } + } + )* + }; +} + +impl_decode_from_dap_http_body!( + AggregationJobInitReq, + AggregateShareReq, + Report, + CollectionReq, +); + +/// Using `()` ignores the body of a request. +impl DecodeFromDapHttpBody for CollectionPollReq { + fn decode_from_http_body(_bytes: Bytes, _meta: &DapRequestMeta) -> Result { + Ok(Self) + } +} +/// Using `()` ignores the body of a request. +impl DecodeFromDapHttpBody for () { + fn decode_from_http_body(_bytes: Bytes, _meta: &DapRequestMeta) -> Result { + Ok(()) + } +} + +/// Parsers that allow us to have to deduce the resource parser to use based on the resource passed +/// in through the `R` type parameter in [`DapRequestExtractor`] and +/// [`UnauthenticatedDapRequestExtractor`]. +mod resource_parsers { + use daphne::messages::{AggregationJobId, CollectionJobId}; + use serde::{de::DeserializeOwned, Deserialize}; + + #[derive(Deserialize)] + pub struct AggregationJobIdParser { + /// This field name defines the parameter name in the route matcher because we use + /// `#[serde(flatten)]` in the + /// `UnauthenticatedDapRequestExtractor::from_request::PathParams::resource_parser` field. + agg_job_id: AggregationJobId, + } + + #[derive(Deserialize)] + pub struct CollectionJobIdParser { + /// This field name defines the parameter name in the route matcher because we use + /// `#[serde(flatten)]` in the + /// `UnauthenticatedDapRequestExtractor::from_request::PathParams::resource_parser` field. + collect_job_id: CollectionJobId, + } + + /// This parser has no fields, so `#[serde(flatten)]` correctly omits this field from the + /// fields it requires. + #[derive(Deserialize)] + pub struct NoneParser; + + /// Trait that lets us go from resource to resource parser. + pub trait Resource: Sized + Send + Sync { + type Parser: Into + Send + Sync + DeserializeOwned; + } + + macro_rules! impl_parser { + ($from:ty => $to:ty |$source:pat_param| $conv:expr) => { + impl Resource for $to { + type Parser = $from; + } + + impl ::core::convert::From<$from> for $to { + fn from($source: $from) -> Self { + $conv + } + } + }; + } + + impl_parser!(AggregationJobIdParser => AggregationJobId |value| value.agg_job_id); + impl_parser!(CollectionJobIdParser => CollectionJobId |value| value.collect_job_id); + impl_parser!(NoneParser => () |_| ()); +} + +/// An axum extractor capable of parsing a [`DapRequest`]. See [`DapRequest`] for an explanation on +/// the type parameters. +pub(super) struct UnauthenticatedDapRequestExtractor(pub DapRequest); + +#[async_trait] +impl FromRequest for UnauthenticatedDapRequestExtractor +where + B: DecodeFromDapHttpBody, + B::ResourceId: resource_parsers::Resource, + S: DaphneService + Send + Sync, +{ + type Rejection = AxumDapResponse; + + async fn from_request(req: Request, state: &S) -> Result { + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct PathParams { + version: DapVersion, + task_id: TaskId, + #[serde(flatten)] + resource_parser: R::Parser, + } + + let (mut parts, body) = req.into_parts(); + let Path(PathParams:: { + version, + task_id, + resource_parser, + }) = Path::from_request_parts(&mut parts, state) + .await + .map_err(|_| { + AxumDapResponse::new_error( + DapAbort::BadRequest("invalid path".into()), + state.server_metrics(), + ) + })?; + + let media_type = if let Some(content_type) = parts.headers.get(CONTENT_TYPE) { + let content_type = content_type.to_str().map_err(|_| { + let msg = "header value contains non ascii or invisible characters".into(); + AxumDapResponse::new_error(DapAbort::BadRequest(msg), state.server_metrics()) + })?; + let media_type = + DapMediaType::from_str_for_version(version, content_type).ok_or_else(|| { + AxumDapResponse::new_error( + DapAbort::BadRequest("invalid media type".into()), + state.server_metrics(), + ) + })?; + Some(media_type) + } else { + None + }; + + // TODO(mendess): this allocates needlessly, if prio supported some kind of + // AsyncParameterizedDecode we could avoid this allocation + let payload = axum::body::to_bytes(body, usize::MAX).await; + + let Ok(payload) = payload else { + return Err(AxumDapResponse::new_error( + fatal_error!(err = "failed to get payload"), + state.server_metrics(), + )); + }; + + let taskprov_advertisement = + extract_header_as_str(&parts.headers, http_headers::DAP_TASKPROV) + .map(|h| TaskprovAdvertisement::parse_taskprov_advertisement(h, &task_id, version)) + .transpose() + .map_err(|e| AxumDapResponse::new_error(e, state.server_metrics()))?; + + let meta = DapRequestMeta { + version, + task_id, + taskprov_advertisement, + media_type, + }; + + let payload = B::decode_from_http_body(payload, &meta) + .map_err(|e| AxumDapResponse::new_error(e, state.server_metrics()))?; + + let request = DapRequest { + meta, + resource_id: resource_parser.into(), + payload, + }; + + Ok(UnauthenticatedDapRequestExtractor(request)) + } +} + +pub mod dap_sender { + pub type DapSender = u8; + + pub const FROM_CLIENT: DapSender = 0; + pub const FROM_COLLECTOR: DapSender = 1 << 1; + pub const FROM_HELPER: DapSender = 1 << 2; + pub const FROM_LEADER: DapSender = 1 << 3; + + pub const fn to_enum(id: DapSender) -> daphne::constants::DapRole { + match id { + FROM_CLIENT => daphne::constants::DapRole::Client, + FROM_COLLECTOR => daphne::constants::DapRole::Collector, + FROM_HELPER => daphne::constants::DapRole::Helper, + FROM_LEADER => daphne::constants::DapRole::Leader, + _ => panic!("invalid dap sender. Please specify a valid dap_sender from the crate::extractor::dap_sender module"), + } + } +} + +/// An axum extractor capable of parsing a [`DapRequest`]. +/// +/// This extractor asserts that the request is authenticated. +/// +/// # Type and const parameters +/// - `SENDER`: The role that is expected to send this request. See [`dap_sender`] for possible values. +/// - `P` and `R`: See [`DapRequest`] for an explanation these type parameters. +pub(super) struct DapRequestExtractor( + pub DapRequest, +); + +#[async_trait] +impl FromRequest for DapRequestExtractor +where + B: DecodeFromDapHttpBody + Send + Sync, + B::ResourceId: resource_parsers::Resource, + S: DaphneService + Send + Sync, +{ + type Rejection = AxumDapResponse; + + async fn from_request(req: Request, state: &S) -> Result { + let bearer_token = extract_header_as_str(req.headers(), http_headers::DAP_AUTH_TOKEN) + .map(BearerToken::from); + + let cf_tls_client_auth = mtls_auth_from_request(&req); + + let UnauthenticatedDapRequestExtractor(request) = + UnauthenticatedDapRequestExtractor::from_request(req, state).await?; + + let error_to_response = |error| AxumDapResponse::new_error(error, state.server_metrics()); + let auth_error = |detail| { + error_to_response(DapError::from(DapAbort::UnauthorizedRequest { + detail, + task_id: request.task_id, + })) + }; + + let is_taskprov = state + .is_using_taskprov(&request) + .await + .map_err( + |e| fatal_error!(err = ?e, "failed to determine if request was using taskprov"), + ) + .map_err(error_to_response)?; + + let bearer_authed = if let Some(token) = bearer_token { + state + .server_metrics() + .auth_method_inc(metrics::AuthMethod::BearerToken); + state + .check_bearer_token( + &token, + const { dap_sender::to_enum(SENDER) }, + request.task_id, + is_taskprov, + ) + .await + .map_err(|reason| reason.either(auth_error, error_to_response))?; + true + } else { + false + }; + + // attempt to auth with mtls + if matches!(cf_tls_client_auth, Err(_) | Ok(true)) { + state + .server_metrics() + .auth_method_inc(metrics::AuthMethod::TlsClientAuth); + } + + let mtls_authed = cf_tls_client_auth + // we always check if mtls succedded, even if ... + .map_err(auth_error)? + // ... we only allow mtls auth for taskprov tasks + && is_taskprov; + + if bearer_authed || mtls_authed { + Ok(Self(request)) + } else { + Err(auth_error( + "No suitable authorization method was found".into(), + )) + } + } +} + +/// Check if there was an mtls auth attempt. +/// +/// - Ok(false) means no certificate was presented +/// - Ok(true) means a valid certificate was presented +/// - Err(e) means an invalid certificate was presented +pub(crate) fn mtls_auth_from_request(req: &http::Request) -> Result { + // The runtime gives us a cf_tls_client_auth whether the communication was secured by + // it or not, so if a certificate wasn't presented, treat it as if it weren't there. + // We only check for the validity of the certificate if it was present in the daphne server + // Literal "1" indicates that a certificate was presented. + #[cfg(test)] + let not_present = &test::ClientTlsAuthMock::do_not_present(); + #[cfg(test)] + let cf = Some( + req.extensions() + .get::() + .unwrap_or(not_present), + ); + + #[cfg(not(test))] + let cf = req + .extensions() + .get::() + .expect("cf object should always be present") + .tls_client_auth(); + + cf.filter(|auth| auth.cert_presented() == "1") + .map(|cert| { + let verified = cert.cert_verified(); + match verified.as_str() { + "SUCCESS" => Ok(()), + _ => Err(format!("Invalid TLS certificate ({verified})")), + } + }) + .transpose() + .map(|present| present.is_some()) +} + +fn extract_header_as_str<'s>(headers: &'s HeaderMap, header: &'static str) -> Option<&'s str> { + headers.get(header)?.to_str().ok() +} + +#[cfg(test)] +mod test { + use std::{ + sync::{Arc, OnceLock}, + time::Duration, + }; + + use axum::{ + body::Body, + extract::{Request, State}, + response::IntoResponse, + routing::get, + Router, + }; + use daphne::{ + async_test_versions, + constants::{DapMediaType, DapRole}, + messages::{ + request::{CollectionPollReq, RequestBody}, + taskprov::TaskprovAdvertisement, + AggregationJobId, AggregationJobInitReq, Base64Encode, CollectionJobId, CollectionReq, + TaskId, + }, + DapError, DapRequest, DapRequestMeta, DapVersion, + }; + use daphne_service_utils::{bearer_token::BearerToken, http_headers}; + use either::Either::{self, Left}; + use futures::FutureExt; + use rand::{thread_rng, Rng}; + use tokio::{ + sync::mpsc::{self, Sender}, + time::timeout, + }; + use tower::ServiceExt; + + use super::{ + dap_sender::FROM_LEADER, metrics::DaphneServiceMetrics, resource_parsers, + DecodeFromDapHttpBody, UnauthenticatedDapRequestExtractor, + }; + use crate::aggregator::metrics::DaphnePromServiceMetrics; + use http::{header, StatusCode}; + use prio::codec::ParameterizedEncode; + + /// We can't mock [`worker::Cf`] but we can mock a type that behaves similarly enough for us to + /// test the mtls logic. + #[derive(Debug, Clone)] + pub struct ClientTlsAuthMock(Option<&'static str>); + + impl ClientTlsAuthMock { + pub fn present_success() -> Self { + Self(Some("SUCCESS")) + } + pub fn present_failure() -> Self { + Self(Some("FAILURE")) + } + pub fn do_not_present() -> Self { + Self(None) + } + + #[allow(clippy::unused_self)] + pub fn cert_presented(&self) -> &'static str { + self.0.map_or("0", |_| "1") + } + + pub fn cert_verified(&self) -> String { + self.0.map_or_else(|| "missing".into(), |v| v.to_string()) + } + } + + const BEARER_TOKEN: &str = "test-token"; + + type DapRequestExtractor = super::DapRequestExtractor; + + /// Return a function that will parse a request using the [`DapRequestExtractor`] or + /// [`UnauthenticatedDapRequestExtractor`] and return the parsed request. + /// + /// The possible request URIs that are supported by this parser are: + /// - `/:version/:task_id/auth` uses the [`DapRequestExtractor`] + /// - `/:version/:task_id/parse-mandatory-fields` uses the [`UnauthenticatedDapRequestExtractor`] + /// - `/:version/:agg_job_id/parse-agg-job-id` uses the [`UnauthenticatedDapRequestExtractor`] + /// - `/:version/:collect_job_id/parse-collect-job-id` uses the [`UnauthenticatedDapRequestExtractor`] + async fn test(req: Request) -> Result, StatusCode> + where + B: DecodeFromDapHttpBody + Send + Sync + 'static, + B::ResourceId: resource_parsers::Resource + Send, + { + type Channel = Sender>; + + #[axum::async_trait] + impl> super::DaphneService for Channel { + fn server_metrics(&self) -> &dyn DaphneServiceMetrics { + // These tests don't care about metrics so we just store a static instance here so I + // can implement the DaphneService trait for Channel. + static METRICS: OnceLock = OnceLock::new(); + METRICS.get_or_init(|| { + DaphnePromServiceMetrics::register(prometheus::default_registry()).unwrap() + }) + } + + fn signing_key(&self) -> Option<&p256::ecdsa::SigningKey> { + None + } + + async fn check_bearer_token( + &self, + token: &BearerToken, + _sender: DapRole, + _task_id: TaskId, + _is_taskprov: bool, + ) -> Result<(), Either> { + (token.as_str() == BEARER_TOKEN) + .then_some(()) + .ok_or_else(|| Left("invalid token".into())) + } + + async fn is_using_taskprov(&self, req: &DapRequestMeta) -> Result { + Ok(req.taskprov_advertisement.is_some()) + } + } + + // setup a channel to "smuggle" the parsed request out of a handler + let (tx, mut rx) = mpsc::channel(1); + + // create a router that takes the send end of the channel as state + let router = Router::new() + .route("/:version/:task_id/auth", get(auth_handler::)) + .route( + "/:version/:task_id/parse-mandatory-fields", + get(handler::), + ) + .route( + "/:version/:task_id/:agg_job_id/parse-agg-job-id", + get(handler::), + ) + .route( + "/:version/:task_id/:collect_job_id/parse-collect-job-id", + get(handler::), + ) + .with_state(Arc::new(tx)); + + // unauthenticated handler that simply sends the received request through the channel + async fn handler( + State(ch): State>>, + req: UnauthenticatedDapRequestExtractor, + ) -> impl IntoResponse { + ch.send(req.0).await.unwrap(); + } + + // unauthenticated handler that simply sends the received request through the channel + async fn auth_handler( + State(ch): State>>, + req: DapRequestExtractor, + ) -> impl IntoResponse { + ch.send(req.0).await.unwrap(); + } + + let resp = match timeout(Duration::from_secs(1), router.oneshot(req)) + .await + .unwrap() + { + Ok(resp) => resp, + Err(i) => match i {}, + }; + + match resp.status() { + StatusCode::NOT_FOUND => panic!("unsuported uri"), + // get the request sent through the channel in the handler + StatusCode::OK => Ok(rx.recv().now_or_never().unwrap().unwrap()), + code => { + let payload = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + eprintln!( + "body was: {}", + String::from_utf8_lossy(&payload).into_owned() + ); + Err(code) + } + } + } + + fn mk_task_id() -> TaskId { + TaskId(thread_rng().gen()) + } + + async fn parse_mandatory_fields(version: DapVersion) { + let task_id = mk_task_id(); + let req = test::<()>( + Request::builder() + .uri(format!( + "/{version}/{}/parse-mandatory-fields", + task_id.to_base64url() + )) + .header( + header::CONTENT_TYPE, + DapMediaType::AggregateShareReq + .as_str_for_version(version) + .unwrap(), + ) + .header(http_headers::DAP_AUTH_TOKEN, BEARER_TOKEN) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(req.version, version); + assert_eq!(req.task_id, task_id); + } + + async_test_versions! { parse_mandatory_fields } + + async fn parse_agg_job_id(version: DapVersion) { + let task_id = mk_task_id(); + let agg_job_id = AggregationJobId(thread_rng().gen()); + + let req = test::( + Request::builder() + .uri(format!( + "/{version}/{}/{}/parse-agg-job-id", + task_id.to_base64url(), + agg_job_id.to_base64url(), + )) + .header(http_headers::DAP_AUTH_TOKEN, BEARER_TOKEN) + .header( + header::CONTENT_TYPE, + DapMediaType::AggregationJobInitReq + .as_str_for_version(version) + .unwrap(), + ) + .body(Body::from( + AggregationJobInitReq { + agg_param: vec![], + part_batch_sel: daphne::messages::PartialBatchSelector::TimeInterval, + prep_inits: vec![], + } + .get_encoded_with_param(&version) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(req.resource_id, agg_job_id); + assert_eq!(req.task_id, task_id); + } + + async_test_versions! { parse_agg_job_id } + + async fn parse_collect_job_id(version: DapVersion) { + let task_id = mk_task_id(); + let collect_job_id = CollectionJobId(thread_rng().gen()); + + let req = test::( + Request::builder() + .uri(format!( + "/{version}/{}/{}/parse-collect-job-id", + task_id.to_base64url(), + collect_job_id.to_base64url(), + )) + .header(http_headers::DAP_AUTH_TOKEN, BEARER_TOKEN) + .header( + header::CONTENT_TYPE, + DapMediaType::CollectionReq + .as_str_for_version(version) + .unwrap(), + ) + .body(Body::from( + CollectionReq { + query: daphne::messages::Query::TimeInterval { + batch_interval: daphne::messages::Interval { + start: 1, + duration: 1, + }, + }, + agg_param: vec![], + } + .get_encoded_with_param(&version) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(req.resource_id, collect_job_id); + assert_eq!(req.task_id, task_id); + } + + async_test_versions! { parse_collect_job_id } + + async fn parse_collect_job_id_from_poll_request(version: DapVersion) { + let task_id = mk_task_id(); + let collect_job_id = CollectionJobId(thread_rng().gen()); + + let req = test::( + Request::builder() + .uri(format!( + "/{version}/{}/{}/parse-collect-job-id", + task_id.to_base64url(), + collect_job_id.to_base64url(), + )) + .header(http_headers::DAP_AUTH_TOKEN, BEARER_TOKEN) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(req.resource_id, collect_job_id); + assert_eq!(req.task_id, task_id); + } + + async_test_versions! { parse_collect_job_id_from_poll_request } + + async fn incorrect_bearer_tokens_are_rejected(version: DapVersion) { + let status_code = test::<()>( + Request::builder() + .uri(format!("/{version}/{}/auth", mk_task_id().to_base64url())) + .header(http_headers::DAP_AUTH_TOKEN, "something incorrect") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap_err(); + + assert_eq!(status_code, StatusCode::UNAUTHORIZED); + } + + async_test_versions! { incorrect_bearer_tokens_are_rejected } + + async fn missing_auth_is_rejected(version: DapVersion) { + let status_code = test::<()>( + Request::builder() + .uri(format!("/{version}/{}/auth", mk_task_id().to_base64url())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap_err(); + + assert_eq!(status_code, StatusCode::UNAUTHORIZED); + } + + async_test_versions! { missing_auth_is_rejected } + + async fn mtls_auth_is_enough(version: DapVersion) { + let taskprov_advertisement = TaskprovAdvertisement { + task_info: b"cool task".into(), + leader_url: daphne::messages::taskprov::UrlBytes { + bytes: b"http://leader".into(), + }, + helper_url: daphne::messages::taskprov::UrlBytes { + bytes: b"http://helper".into(), + }, + query_config: daphne::messages::taskprov::QueryConfig { + time_precision: 1, + max_batch_query_count: 1, + min_batch_size: 1, + batch_mode: daphne::messages::taskprov::BatchMode::TimeInterval, + }, + task_expiration: 1, + vdaf_config: daphne::messages::taskprov::VdafConfig { + dp_config: daphne::messages::taskprov::DpConfig::None, + var: daphne::messages::taskprov::VdafTypeVar::Prio2 { dimension: 1 }, + }, + }; + + let req = test::<()>( + Request::builder() + .uri(format!( + "/{version}/{}/auth", + taskprov_advertisement + .compute_task_id(version) + .to_base64url() + )) + .extension(ClientTlsAuthMock::present_success()) + .header( + http_headers::DAP_TASKPROV, + taskprov_advertisement + .serialize_to_header_value(version) + .unwrap(), + ) + .body(Body::empty()) + .unwrap(), + ) + .await; + + req.unwrap(); + } + + async_test_versions! { mtls_auth_is_enough } + + async fn incorrect_bearer_tokens_are_rejected_even_with_mtls_auth(version: DapVersion) { + let code = test::<()>( + Request::builder() + .uri(format!("/{version}/{}/auth", mk_task_id().to_base64url())) + .header(http_headers::DAP_AUTH_TOKEN, "something incorrect") + .extension(ClientTlsAuthMock::present_success()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap_err(); + + assert_eq!(code, StatusCode::UNAUTHORIZED); + } + + async_test_versions! { incorrect_bearer_tokens_are_rejected_even_with_mtls_auth } + + async fn invalid_mtls_auth_is_rejected_despite_correct_bearer_token(version: DapVersion) { + let code = test::<()>( + Request::builder() + .uri(format!("/{version}/{}/auth", mk_task_id().to_base64url())) + .header(http_headers::DAP_AUTH_TOKEN, BEARER_TOKEN) + .extension(ClientTlsAuthMock::present_failure()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap_err(); + + assert_eq!(code, StatusCode::UNAUTHORIZED); + } + + async_test_versions! { invalid_mtls_auth_is_rejected_despite_correct_bearer_token } +} diff --git a/crates/daphne-worker/src/aggregator/router/helper.rs b/crates/daphne-worker/src/aggregator/router/helper.rs new file mode 100644 index 000000000..6d4fd7512 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/router/helper.rs @@ -0,0 +1,79 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use std::sync::Arc; + +use axum::{ + extract::State, + routing::{post, put}, +}; +use daphne::{ + messages::{AggregateShareReq, AggregationJobInitReq}, + roles::{helper, DapHelper}, +}; +use http::StatusCode; + +use super::{ + super::roles::fetch_replay_protection_override, extractor::dap_sender::FROM_LEADER, App, + AxumDapResponse, DapRequestExtractor, DaphneService, +}; +use crate::elapsed; + +pub(super) fn add_helper_routes(router: super::Router) -> super::Router { + router + .route( + "/:version/tasks/:task_id/aggregation_jobs/:agg_job_id", + put(agg_job), + ) + .route("/:version/tasks/:task_id/aggregate_shares", post(agg_share)) +} + +#[tracing::instrument( + skip_all, + fields( + media_type = ?req.media_type, + task_id = ?req.task_id, + version = ?req.version, + ) +)] +#[worker::send] +async fn agg_job( + State(app): State>, + DapRequestExtractor(req): DapRequestExtractor, +) -> AxumDapResponse { + let now = worker::Date::now(); + + let resp = helper::handle_agg_job_init_req( + &*app, + req, + fetch_replay_protection_override(app.kv()).await, + ) + .await; + + let elapsed = elapsed(&now); + + app.server_metrics().aggregate_job_latency(elapsed); + + AxumDapResponse::from_result_with_success_code(resp, app.server_metrics(), StatusCode::CREATED) +} + +#[tracing::instrument( + skip_all, + fields( + media_type = ?req.media_type, + task_id = ?req.task_id, + version = ?req.version, + ) +)] +async fn agg_share( + State(app): State>, + DapRequestExtractor(req): DapRequestExtractor, +) -> AxumDapResponse +where + A: DapHelper + DaphneService + Send + Sync, +{ + AxumDapResponse::from_result( + helper::handle_agg_share_req(&*app, req).await, + app.server_metrics(), + ) +} diff --git a/crates/daphne-worker/src/aggregator/router/leader.rs b/crates/daphne-worker/src/aggregator/router/leader.rs new file mode 100644 index 000000000..5509d3d61 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/router/leader.rs @@ -0,0 +1,157 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use std::sync::Arc; + +use axum::{ + extract::{Path, Request, State}, + http::StatusCode, + middleware::{from_fn, Next}, + response::{IntoResponse, Response}, + routing::{get, post, put}, +}; +use daphne::{ + constants::DapMediaType, + error::DapAbort, + messages::{self, request::CollectionPollReq}, + roles::leader::{self, DapLeader}, + DapError, DapVersion, +}; +use prio::codec::ParameterizedEncode; + +use super::{ + extractor::dap_sender::FROM_COLLECTOR, AxumDapResponse, DapRequestExtractor, DaphneService, + UnauthenticatedDapRequestExtractor, +}; +use futures::{future::BoxFuture, FutureExt}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct PathVersion { + #[serde(rename = "version")] + presented_version: DapVersion, +} + +fn require_version( + expected_version: DapVersion, +) -> impl Copy + Fn(Path, Request, Next) -> BoxFuture<'static, Response> { + move |Path(PathVersion { presented_version }), req, next| { + async move { + if presented_version != expected_version { + return StatusCode::METHOD_NOT_ALLOWED.into_response(); + } + next.run(req).await + } + .boxed() + } +} + +pub(super) fn add_leader_routes(router: super::Router) -> super::Router +where + A: DapLeader + DaphneService + Send + Sync + 'static, +{ + router + .route( + "/:version/tasks/:task_id/reports", + put(upload).layer(from_fn(require_version(DapVersion::Draft09))), + ) + .route( + "/:version/tasks/:task_id/reports", + post(upload).layer(from_fn(require_version(DapVersion::Latest))), + ) + .route( + "/:version/tasks/:task_id/collection_jobs/:collect_job_id", + put(start_collection_job), + ) + .route( + "/:version/tasks/:task_id/collection_jobs/:collect_job_id", + post(poll_collect).layer(from_fn(require_version(DapVersion::Draft09))), + ) + .route( + "/:version/tasks/:task_id/collection_jobs/:collect_job_id", + get(poll_collect).layer(from_fn(require_version(DapVersion::Latest))), + ) +} + +#[tracing::instrument( + skip_all, + fields( + task_id = ?req.task_id, + version = ?req.version, + ) +)] +async fn upload( + State(app): State>, + UnauthenticatedDapRequestExtractor(req): UnauthenticatedDapRequestExtractor, +) -> Response +where + A: DapLeader + DaphneService + Send + Sync, +{ + match leader::handle_upload_req(&*app, req).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => AxumDapResponse::new_error(e, app.server_metrics()).into_response(), + } +} + +#[tracing::instrument( + skip_all, + fields( + task_id = ?req.task_id, + version = ?req.version, + ) +)] +async fn start_collection_job( + State(app): State>, + DapRequestExtractor(req): DapRequestExtractor, +) -> Response +where + A: DapLeader + DaphneService + Send + Sync, +{ + match leader::handle_coll_job_req(&*app, &req).await { + Ok(()) => StatusCode::CREATED.into_response(), + Err(e) => AxumDapResponse::new_error(e, app.server_metrics()).into_response(), + } +} + +#[tracing::instrument( + skip_all, + fields( + task_id = ?req.task_id, + version = ?req.version, + ) +)] +async fn poll_collect( + State(app): State>, + DapRequestExtractor(req): DapRequestExtractor, +) -> Response +where + A: DapLeader + DaphneService + Send + Sync, +{ + match app.poll_collect_job(&req.task_id, &req.resource_id).await { + Ok(daphne::DapCollectionJob::Done(collect_resp)) => AxumDapResponse::new_success( + daphne::DapResponse { + version: req.version, + media_type: DapMediaType::Collection, + payload: match collect_resp.get_encoded_with_param(&req.version) { + Ok(payload) => payload, + Err(e) => { + return AxumDapResponse::new_error( + DapError::encoding(e), + app.server_metrics(), + ) + .into_response() + } + }, + }, + app.server_metrics(), + ) + .into_response(), + Ok(daphne::DapCollectionJob::Pending) => StatusCode::ACCEPTED.into_response(), + Ok(daphne::DapCollectionJob::Unknown) => AxumDapResponse::new_error( + DapAbort::BadRequest("unknown collection job id".into()), + app.server_metrics(), + ) + .into_response(), + Err(e) => AxumDapResponse::new_error(e, app.server_metrics()).into_response(), + } +} diff --git a/crates/daphne-worker/src/aggregator/router/mod.rs b/crates/daphne-worker/src/aggregator/router/mod.rs new file mode 100644 index 000000000..697c7dca7 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/router/mod.rs @@ -0,0 +1,230 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +mod aggregator; +mod extractor; +mod helper; +mod leader; +#[cfg(feature = "test-utils")] +pub mod test_routes; + +use std::sync::Arc; + +use axum::{ + extract::{Request, State}, + http::{header::CONTENT_TYPE, HeaderValue, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use daphne::{ + constants::{DapAggregatorRole, DapRole}, + error::DapAbort, + fatal_error, + messages::TaskId, + DapError, DapRequestMeta, DapResponse, +}; +use daphne_service_utils::bearer_token::BearerToken; +use either::Either; + +use super::{metrics::DaphneServiceMetrics, App}; +use extractor::{DapRequestExtractor, UnauthenticatedDapRequestExtractor}; +use tower::ServiceExt as _; +use worker::HttpRequest; + +type Router = axum::Router>; + +/// Capabilities necessary when running a native daphne service. +#[axum::async_trait] +pub trait DaphneService { + /// The service metrics + fn server_metrics(&self) -> &dyn DaphneServiceMetrics; + + fn signing_key(&self) -> Option<&p256::ecdsa::SigningKey> { + None + } + + /// Checks if a bearer token is accepted. + /// + /// # Errors + /// + /// Returns an either: + /// - left: error message with the reason why the token wasn't accepted. + /// - right: an internal error that made checking the token impossible. + async fn check_bearer_token( + &self, + presented_token: &BearerToken, + sender: DapRole, + task_id: TaskId, + is_taskprov: bool, + ) -> Result<(), Either>; + + /// Checks if this request intends to use taskprov. + async fn is_using_taskprov(&self, req: &DapRequestMeta) -> Result; +} + +#[axum::async_trait] +impl DaphneService for Arc +where + S: DaphneService + Send + Sync, +{ + fn server_metrics(&self) -> &dyn DaphneServiceMetrics { + S::server_metrics(&**self) + } + + fn signing_key(&self) -> Option<&p256::ecdsa::SigningKey> { + S::signing_key(&**self) + } + + async fn check_bearer_token( + &self, + presented_token: &BearerToken, + sender: DapRole, + task_id: TaskId, + is_taskprov: bool, + ) -> Result<(), Either> { + S::check_bearer_token(&**self, presented_token, sender, task_id, is_taskprov).await + } + + async fn is_using_taskprov(&self, req: &DapRequestMeta) -> Result { + S::is_using_taskprov(&**self, req).await + } +} + +pub async fn handle_dap_request(app: App, req: HttpRequest) -> Response { + let router = axum::Router::new(); + + let router = aggregator::add_aggregator_routes(router); + + let router = match app.service_config.role { + DapAggregatorRole::Leader => leader::add_leader_routes(router), + DapAggregatorRole::Helper => helper::add_helper_routes(router), + }; + + #[cfg(feature = "test-utils")] + let router = test_routes::add_test_routes(router, app.service_config.role); + + async fn request_metrics( + State(app): State>, + req: Request, + next: Next, + ) -> impl IntoResponse { + tracing::info!(method = %req.method(), uri = %req.uri(), "received request"); + let resp = next.run(req).await; + app.server_metrics() + .count_http_status_code(resp.status().as_u16()); + tracing::info!(status_code = %resp.status(), "request finished"); + resp + } + + let aggregator = Arc::new(app); + let Ok(response) = router + .with_state(aggregator.clone()) + .layer( + tower::ServiceBuilder::new().layer(axum::middleware::from_fn_with_state( + aggregator, + request_metrics, + )), + ) + .oneshot(req) + .await; + + response +} + +struct AxumDapResponse(axum::response::Response); + +impl AxumDapResponse { + pub fn new_success(response: DapResponse, metrics: &dyn DaphneServiceMetrics) -> Self { + Self::new_success_with_code(response, metrics, StatusCode::OK) + } + + pub fn new_success_with_code( + response: DapResponse, + metrics: &dyn DaphneServiceMetrics, + status_code: StatusCode, + ) -> Self { + let Some(media_type) = response.media_type.as_str_for_version(response.version) else { + return AxumDapResponse::new_error( + fatal_error!(err = "invalid content-type for DAP version"), + metrics, + ); + }; + let media_type = match HeaderValue::from_str(media_type) { + Ok(media_type) => media_type, + Err(e) => { + return AxumDapResponse::new_error( + fatal_error!(err = ?e, "content-type contained invalid bytes {media_type:?}"), + metrics, + ) + } + }; + + let headers = [(CONTENT_TYPE, media_type)]; + + Self((status_code, headers, response.payload).into_response()) + } + + pub fn new_error>(error: E, metrics: &dyn DaphneServiceMetrics) -> Self { + // Trigger abort if any report errors reach this point. + let error = match error.into() { + DapError::ReportError(err) => DapAbort::report_rejected(err), + DapError::Fatal(e) => Err(e), + DapError::Abort(abort) => Ok(abort), + }; + let (status, problem_details) = match error { + Ok(abort) => { + tracing::error!(error = ?abort, "request aborted due to protocol abort"); + let status = if let DapAbort::UnauthorizedRequest { .. } = abort { + StatusCode::UNAUTHORIZED + } else { + StatusCode::BAD_REQUEST + }; + (status, abort.into_problem_details()) + } + Err(fatal_error) => { + tracing::error!(error = ?fatal_error, "request aborted due to fatal error"); + // TODO(mendess) uncomment the line below + // self.error_reporter.report_abort(&e); + let problem_details = fatal_error.into_problem_details(); + (StatusCode::INTERNAL_SERVER_ERROR, problem_details) + } + }; + // this to string is bounded by the + // number of variants in the enum + metrics.abort_count_inc(&problem_details.title); + let headers = [(CONTENT_TYPE, "application/problem+json")]; + + Self((status, headers, Json(problem_details)).into_response()) + } + + pub fn from_result( + result: Result, + metrics: &dyn DaphneServiceMetrics, + ) -> Self + where + E: Into, + { + Self::from_result_with_success_code(result, metrics, StatusCode::OK) + } + + pub fn from_result_with_success_code( + result: Result, + metrics: &dyn DaphneServiceMetrics, + status_code: StatusCode, + ) -> Self + where + E: Into, + { + match result { + Ok(o) => Self::new_success_with_code(o, metrics, status_code), + Err(e) => Self::new_error(e, metrics), + } + } +} + +impl IntoResponse for AxumDapResponse { + fn into_response(self) -> axum::response::Response { + self.0 + } +} diff --git a/crates/daphne-worker/src/aggregator/router/test_routes.rs b/crates/daphne-worker/src/aggregator/router/test_routes.rs new file mode 100644 index 000000000..582605b87 --- /dev/null +++ b/crates/daphne-worker/src/aggregator/router/test_routes.rs @@ -0,0 +1,169 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, +}; +use daphne::{ + constants::DapAggregatorRole, + hpke::HpkeReceiverConfig, + messages::{Base64Encode, TaskId}, + roles::{leader, DapLeader}, + DapVersion, +}; +use daphne_service_utils::test_route_types::{InternalTestAddTask, InternalTestEndpointForTask}; +use serde::Deserialize; + +use super::App; + +use super::{AxumDapResponse, DaphneService}; + +pub fn add_test_routes(router: super::Router, role: DapAggregatorRole) -> super::Router { + let router = if role == DapAggregatorRole::Leader { + router + .route("/internal/process", post(leader_process)) + .route( + "/internal/current_batch/task/:task_id", + get(leader_current_batch), + ) + } else { + router + }; + + router + .route("/internal/delete_all", post(delete_all)) + .route("/internal/test/ready", post(StatusCode::OK)) + .route( + "/internal/test/endpoint_for_task", + post(endpoint_for_task_default), + ) + .route( + "/:version/internal/test/endpoint_for_task", + post(endpoint_for_task), + ) + .route("/internal/test/add_task", post(add_task_default)) + .route("/:version/internal/test/add_task", post(add_task)) + .route( + "/internal/test/add_hpke_config", + post(add_hpke_config_default), + ) + .route( + "/:version/internal/test/add_hpke_config", + post(add_hpke_config), + ) +} + +#[tracing::instrument(skip(app))] +async fn leader_process(State(app): State>) -> Response { + match leader::process(&*app, "unspecified-daphne-worker-host", 100).await { + Ok(telem) => (StatusCode::OK, Json(telem)).into_response(), + Err(e) => AxumDapResponse::new_error(e, app.server_metrics()).into_response(), + } +} + +#[derive(Deserialize)] +struct PathTaskId { + task_id: TaskId, +} + +#[tracing::instrument(skip(app))] +async fn leader_current_batch( + State(app): State>, + Path(PathTaskId { task_id }): Path, +) -> impl IntoResponse { + match app.current_batch(&task_id).await { + Ok(batch_id) => (StatusCode::OK, batch_id.to_base64url().into_bytes()).into_response(), + Err(e) => AxumDapResponse::new_error(e, &*app.metrics).into_response(), + } +} + +#[tracing::instrument(skip(app))] +#[worker::send] +async fn delete_all(State(app): State>) -> impl IntoResponse { + match app.internal_delete_all().await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => AxumDapResponse::new_error(e, &*app.metrics).into_response(), + } +} + +async fn endpoint_for_task_default( + state: State>, + cmd: Json, +) -> impl IntoResponse { + let version = state.0.service_config.default_version; + endpoint_for_task(state, Path(version), cmd).await +} + +#[tracing::instrument(skip(app, cmd))] +async fn endpoint_for_task( + State(app): State>, + Path(version): Path, + Json(cmd): Json, +) -> impl IntoResponse { + match app.internal_endpoint_for_task(version, cmd) { + Ok(path) => ( + StatusCode::OK, + Json(serde_json::json!({ "status": "success", "endpoint": path })), + ) + .into_response(), + Err(e) => AxumDapResponse::new_error(e, &*app.metrics).into_response(), + } +} + +#[tracing::instrument(skip(app, cmd))] +async fn add_task( + State(app): State>, + Path(version): Path, + Json(cmd): Json, +) -> impl IntoResponse { + match app.internal_add_task(version, cmd).await { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ "status": "success" })), + ) + .into_response(), + Err(e) => AxumDapResponse::new_error(e, &*app.metrics).into_response(), + } +} + +#[tracing::instrument(skip(app, json))] +async fn add_task_default( + State(app): State>, + json: Json, +) -> impl IntoResponse { + let version = app.service_config.default_version; + add_task(State(app), Path(version), json).await +} + +#[tracing::instrument(skip(app, hpke))] +#[worker::send] +async fn add_hpke_config( + State(app): State>, + Path(version): Path, + Json(hpke): Json, +) -> impl IntoResponse { + match app.internal_add_hpke_config(version, hpke).await { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ "status": "success" })), + ) + .into_response(), + Err(e) => AxumDapResponse::new_error(e, &*app.metrics).into_response(), + } +} + +#[tracing::instrument(skip(app, json))] +#[worker::send] +async fn add_hpke_config_default( + State(app): State>, + json: Json, +) -> impl IntoResponse { + let version = app.service_config.default_version; + add_hpke_config(State(app), Path(version), json).await +} diff --git a/crates/daphne-worker/src/lib.rs b/crates/daphne-worker/src/lib.rs index 28e2a9682..6b8fb25ea 100644 --- a/crates/daphne-worker/src/lib.rs +++ b/crates/daphne-worker/src/lib.rs @@ -1,11 +1,13 @@ -// Copyright (c) 2022 Cloudflare, Inc. All rights reserved. +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause #![allow(clippy::semicolon_if_nothing_returned)] //! Workers backend for `daphne-server`. +pub mod aggregator; pub mod durable; +pub mod storage; pub mod storage_proxy; pub mod tracing_utils; @@ -18,8 +20,13 @@ pub use axum::{ response::{IntoResponse, Response}, }; pub use daphne::DapRequest; +use std::time::Duration; pub(crate) fn int_err(s: S) -> Error { error!("internal error: {}", s.to_string()); Error::RustError("internalError".to_string()) } + +pub(crate) fn elapsed(date: &worker::Date) -> Duration { + Duration::from_millis(worker::Date::now().as_millis() - date.as_millis()) +} diff --git a/crates/daphne-worker/src/storage/kv/cache.rs b/crates/daphne-worker/src/storage/kv/cache.rs new file mode 100644 index 000000000..3c1e36007 --- /dev/null +++ b/crates/daphne-worker/src/storage/kv/cache.rs @@ -0,0 +1,97 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +use std::{any::Any, collections::HashMap, time::Duration}; + +use mappable_rc::Marc; + +use super::KvPrefix; +use crate::elapsed; +use worker::send::SendWrapper; + +const CACHE_VALUE_LIFETIME: Duration = Duration::from_secs(60 * 5); + +struct CacheLine { + /// Time at which the cache item was set. + ts: SendWrapper, + + /// Either the value or an indication that no value was found. + entry: Option>, +} + +#[derive(Default)] +pub struct Cache { + /// This map follows the same structure of KV queries. + /// The first key (&'static str) is a [`KvPrefix::PREFIX`] + /// The second key (String) is the key that is associated with this value + kv: HashMap<&'static str, HashMap>, +} + +pub enum CacheResult { + /// Cache hit. + /// + /// `None` indicates that the value is known to not exist. + Hit(Option>), + /// Cache Miss. It was never cached or it has expired. + Miss, + /// There is a value associated with this key, but it's type is not [`T`]. + MismatchedType, +} + +impl Cache { + pub fn get

(&self, key: &str) -> CacheResult + where + P: KvPrefix, + { + match self.kv.get(P::PREFIX) { + Some(cache) => match cache.get(key) { + // Cache hit + Some(CacheLine { ts, entry }) if elapsed(ts) < CACHE_VALUE_LIFETIME => entry + .as_ref() + .map(|entry| Marc::try_map(entry.clone(), |v| v.downcast_ref::())) + .transpose() // bring out the try_map error + .map_or(CacheResult::MismatchedType, CacheResult::Hit), + + // Cache miss or the cached value is stale. + Some(_) | None => CacheResult::Miss, + }, + + // Cache miss + None => CacheResult::Miss, + } + } + + pub(super) fn put

(&mut self, key: String, entry: Option>) + where + P: KvPrefix, + { + self.kv.entry(P::PREFIX).or_default().insert( + key, + CacheLine { + ts: SendWrapper(worker::Date::now()), + entry: entry.map(|value| Marc::map(value, |v| v as &(dyn Any + Send + Sync))), + }, + ); + } + + #[expect(dead_code)] + pub fn delete

(&mut self, key: &str) -> CacheResult + where + P: KvPrefix, + { + match self.kv.get_mut(P::PREFIX) { + Some(cache) => match cache.remove(key) { + // Cache hit + Some(CacheLine { ts: _, entry }) => entry + .map(|entry| Marc::try_map(entry, |v| v.downcast_ref::())) + .transpose() // bring out the try_map error + .map_or(CacheResult::MismatchedType, CacheResult::Hit), + + None => CacheResult::Miss, + }, + + // Cache miss + None => CacheResult::Miss, + } + } +} diff --git a/crates/daphne-worker/src/storage/kv/mod.rs b/crates/daphne-worker/src/storage/kv/mod.rs new file mode 100644 index 000000000..0aaf036d9 --- /dev/null +++ b/crates/daphne-worker/src/storage/kv/mod.rs @@ -0,0 +1,445 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +mod cache; +// mod request_coalescer; + +use std::{any::Any, fmt::Display, future::Future, sync::RwLock}; + +use daphne_service_utils::durable_requests::KV_PATH_PREFIX; +use mappable_rc::Marc; +use serde::{de::DeserializeOwned, Serialize}; +use tracing::{info_span, Instrument}; + +use super::Error; +use crate::storage_proxy; +use cache::Cache; +use daphne::messages::Time; +use worker::send::SendWrapper; + +#[derive(Default)] +pub struct State { + cache: RwLock, +} + +impl State { + #[cfg(feature = "test-utils")] + pub fn reset(&self) { + let Self { cache } = self; + + *cache.write().unwrap() = Default::default(); + } +} + +pub(crate) struct Kv<'h> { + env: &'h SendWrapper, + state: &'h State, +} + +pub trait KvPrefix { + const PREFIX: &'static str; + + type Key: Display; + type Value: Any + Send + Sync + Serialize + DeserializeOwned; +} + +pub mod prefix { + use std::{ + fmt::{self, Display}, + marker::PhantomData, + }; + + use daphne::{ + constants::DapRole, + hpke::HpkeReceiverConfig, + messages::{Base64Encode, TaskId}, + taskprov, DapTaskConfig, DapVersion, + }; + use daphne_service_utils::bearer_token::BearerToken; + use serde::{de::DeserializeOwned, Serialize}; + + use super::KvPrefix; + + pub type HpkeRecieverConfigList = Vec; + + #[derive(Debug)] + pub struct GlobalConfigOverride(PhantomData); + + /// List of global overrides stored in kv. + #[derive(Debug)] + #[cfg_attr(feature = "test-utils", derive(serde::Serialize, serde::Deserialize))] + pub enum GlobalOverrides { + /// A `bool` describing whether to skip replay protection. + SkipReplayProtection, + /// The default number of aggregate span shards to use in new tasks. + DefaultNumAggSpanShards, + } + + impl Display for GlobalOverrides { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let key = match self { + Self::SkipReplayProtection => "skip_replay_protection", + Self::DefaultNumAggSpanShards => "default_num_agg_span_shards", + }; + f.write_str(key) + } + } + + impl KvPrefix for GlobalConfigOverride + where + V: Send + Sync + Serialize + DeserializeOwned + 'static, + { + const PREFIX: &'static str = "global_config/override"; + + type Key = GlobalOverrides; + type Value = V; + } + + pub struct TaskConfig(); + impl KvPrefix for TaskConfig { + const PREFIX: &'static str = "config/task"; + + type Key = TaskId; + type Value = DapTaskConfig; + } + + pub struct TaskprovOptInParam(); + impl KvPrefix for TaskprovOptInParam { + const PREFIX: &'static str = "taskprov/opt_in_param"; + + type Key = TaskId; + type Value = taskprov::OptInParam; + } + + pub struct HpkeReceiverConfigSet(); + impl KvPrefix for HpkeReceiverConfigSet { + const PREFIX: &'static str = "hpke_receiver_config_set"; + + type Key = DapVersion; + type Value = HpkeRecieverConfigList; + } + + pub struct KvBearerToken(); + impl KvPrefix for KvBearerToken { + const PREFIX: &'static str = "bearer_token"; + + type Key = KvBearerTokenKey; + type Value = BearerToken; + } + + #[derive(Debug)] + pub struct KvBearerTokenKey(DapRole, TaskId); + impl From<(DapRole, TaskId)> for KvBearerTokenKey { + fn from((s, t): (DapRole, TaskId)) -> Self { + Self(s, t) + } + } + impl fmt::Display for KvBearerTokenKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(sender, task_id) = self; + let task_id = task_id.to_base64url(); + match sender { + DapRole::Client => write!(f, "client/task/{task_id}"), + DapRole::Collector => write!(f, "collector/task/{task_id}"), + DapRole::Helper => write!(f, "helper/task/{task_id}"), + DapRole::Leader => write!(f, "leader/task/{task_id}"), + } + } + } +} + +/// Options for getting items from KV. +#[derive(Default, Debug)] +pub(crate) struct KvGetOptions { + /// Cache the response from KV regardless of whether a value was found. If the value was not + /// found, then [`Kv::get`] and its cousins will return `None` until the cache line expires. + /// + /// In most cases we want this option to be disabled. This option is useful in situations where + /// we don't expect the value to be in KV and the user is not latency-sensitive. For example, + /// we store overrides for [`DapGlobalConfig`] in KV, but we can wait a few minutes for these + /// overrides to take effect. Setting this option prevents us from hitting KV harder than we + /// need to. + pub(crate) cache_not_found: bool, +} + +pub(crate) enum GetOrInsertError { + StorageProxy(Error), + Other(E), +} + +impl From for GetOrInsertError { + fn from(error: Error) -> Self { + Self::StorageProxy(error) + } +} + +impl<'h> Kv<'h> { + pub fn new(env: &'h SendWrapper, state: &'h State) -> Self { + Self { env, state } + } + + pub async fn get

( + &self, + key: &P::Key, + opt: &KvGetOptions, + ) -> Result>, Error> + where + P: KvPrefix, + P::Key: std::fmt::Debug, + { + self.get_mapped::(key, opt, |t| Some(t)).await + } + + pub async fn get_cloned

( + &self, + key: &P::Key, + opt: &KvGetOptions, + ) -> Result, Error> + where + P: KvPrefix, + P::Key: std::fmt::Debug, + P::Value: Clone, + { + Ok(self.get::

(key, opt).await?.map(|t| t.as_ref().clone())) + } + + pub async fn get_mapped( + &self, + key: &P::Key, + opt: &KvGetOptions, + mapper: F, + ) -> Result>, Error> + where + P: KvPrefix, + P::Key: std::fmt::Debug, + F: for<'s> FnOnce(&'s P::Value) -> Option<&'s R>, + R: Send + Sync + 'static, + { + self.get_internal::(key, opt, Some) + .await + .map(|opt| opt.flatten().map(|marc| Marc::try_map(marc, mapper).ok())) + .map(Option::flatten) + } + + pub async fn get_or_insert_with( + &self, + key: &P::Key, + opt: &KvGetOptions, + default: impl FnOnce() -> Fut, + expiration: Option

(key, default, expiration).await?; + Ok(cached) + } + + pub async fn peek( + &self, + key: &P::Key, + opt: &KvGetOptions, + peeker: F, + ) -> Result, Error> + where + P: KvPrefix, + P::Key: std::fmt::Debug, + F: FnOnce(&P::Value) -> R, + { + self.get_internal::(key, opt, Some) + .await + .map(|opt| opt.flatten().map(|marc| peeker(&marc))) + } + + async fn get_internal( + &self, + key: &P::Key, + opt: &KvGetOptions, + mapper: F, + ) -> Result, Error> + where + P: KvPrefix, + P::Key: std::fmt::Debug, + F: FnOnce(Marc) -> R, + { + let key = Self::to_key::

(key); + tracing::debug!(key, "GET"); + match self.state.cache.read().unwrap().get::

(&key) { + cache::CacheResult::Miss => {} + cache::CacheResult::Hit(t) => return Ok(t.map(mapper)), + cache::CacheResult::MismatchedType => { + tracing::warn!( + "cache mismatched type, wanted {}", + std::any::type_name::() + ); + } + } + let span = info_span!( + "uncached kv_get", + ?key, + ?opt, + prefix = std::any::type_name::

() + ); + async { + if let Some(v) = storage_proxy::kv_get(self.env, &key).await? { + let t = Marc::new(serde_json::from_slice::(&v)?); + let r = mapper(t.clone()); + self.state.cache.write().unwrap().put::

(key, Some(t)); + Ok(Some(r)) + } else { + if opt.cache_not_found { + self.state.cache.write().unwrap().put::

(key, None); + } + Ok(None) + } + } + .instrument(span) + .await + } + + pub async fn put_internal

( + &self, + key: &P::Key, + value: P::Value, + expiration: Option

(key); + tracing::debug!(key, "PUT"); + + storage_proxy::kv_put( + self.env, + expiration, + &key, + &serde_json::to_vec(&value).unwrap(), + ) + .await?; + + let value = Marc::new(value); + self.state + .cache + .write() + .unwrap() + .put::

(key, Some(value.clone())); + Ok(value) + } + + pub async fn put_with_expiration

( + &self, + key: &P::Key, + value: P::Value, + expiration: Time, + ) -> Result, Error> + where + P: KvPrefix, + P::Key: std::fmt::Debug, + P::Value: Serialize, + { + self.put_internal::

(key, value, Some(expiration)).await + } + + #[cfg_attr(not(feature = "test-utils"), expect(dead_code))] + pub async fn put

(&self, key: &P::Key, value: P::Value) -> Result, Error> + where + P: KvPrefix, + P::Key: std::fmt::Debug, + P::Value: Serialize, + { + self.put_internal::

(key, value, None).await + } + + /// Stores a value in kv if it doesn't already exist. + /// + /// If the value already exists, returns the passed in value inside the Ok variant. + pub async fn put_if_not_exists_internal

( + &self, + key: &P::Key, + value: P::Value, + expiration: Option

(key); + + tracing::debug!(key, "PUT if not exists"); + + let inserted = storage_proxy::kv_put_if_not_exists( + self.env, + expiration, + &key, + &serde_json::to_vec(&value).unwrap(), + ) + .await?; + if inserted { + self.state + .cache + .write() + .unwrap() + .put::

(key, Some(value.into())); + Ok(None) + } else { + Ok(Some(value)) + } + } + + #[cfg_attr(not(feature = "test-utils"), expect(dead_code))] + pub async fn put_if_not_exists_with_expiration

( + &self, + key: &P::Key, + value: P::Value, + expiration: Time, + ) -> Result, Error> + where + P: KvPrefix, + P::Key: std::fmt::Debug, + P::Value: Serialize, + { + self.put_if_not_exists_internal::

(key, value, Some(expiration)) + .await + } + + #[cfg_attr(not(feature = "test-utils"), expect(dead_code))] + pub async fn put_if_not_exists

( + &self, + key: &P::Key, + value: P::Value, + ) -> Result, Error> + where + P: KvPrefix, + P::Key: std::fmt::Debug, + P::Value: Serialize, + { + self.put_if_not_exists_internal::

(key, value, None).await + } + + #[tracing::instrument(skip_all, fields(key, prefix = std::any::type_name::

()))] + pub async fn only_cache_put

(&self, key: &P::Key, value: P::Value) + where + P: KvPrefix, + P::Key: std::fmt::Debug, + { + let key = Self::to_key::

(key); + self.state + .cache + .write() + .unwrap() + .put::

(key, Some(value.into())); + } + + fn to_key(key: &P::Key) -> String { + format!("{KV_PATH_PREFIX}/{}/{key}", P::PREFIX) + } +} diff --git a/crates/daphne-worker/src/storage/mod.rs b/crates/daphne-worker/src/storage/mod.rs new file mode 100644 index 000000000..9065ddb47 --- /dev/null +++ b/crates/daphne-worker/src/storage/mod.rs @@ -0,0 +1,136 @@ +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +pub(crate) mod kv; + +use crate::storage_proxy; +use axum::http::StatusCode; +use daphne_service_utils::{ + capnproto_payload::{CapnprotoPayloadEncode, CapnprotoPayloadEncodeExt}, + durable_requests::{bindings::DurableMethod, DurableRequest, ObjectIdFrom}, +}; +pub(crate) use kv::Kv; +use serde::de::DeserializeOwned; +use std::fmt::Debug; +use worker::Env; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + #[error("serialization error: {0}")] + Serde(#[from] serde_json::Error), + #[error("worker error: {0}")] + Worker(#[from] worker::Error), + #[error("http error. request returned status code {status} with the body {body}")] + Http { status: StatusCode, body: String }, +} + +#[derive(Clone, Copy)] +pub(crate) struct Do<'h> { + env: &'h Env, + retry: bool, +} + +impl<'h> Do<'h> { + pub fn new(env: &'h Env) -> Self { + Self { env, retry: false } + } + + #[expect(dead_code)] + pub fn with_retry(self) -> Self { + Self { + retry: true, + ..self + } + } +} + +pub struct RequestBuilder<'d, B: DurableMethod, P: AsRef<[u8]>> { + durable: &'d Do<'d>, + path: B, + request: DurableRequest

, +} + +impl<'d, B: DurableMethod + Debug, P: AsRef<[u8]>> RequestBuilder<'d, B, P> { + #[tracing::instrument(skip_all, fields(path = ?self.path))] + pub async fn send(self) -> Result + where + R: DeserializeOwned, + { + tracing::debug!( + obj = std::any::type_name::().split("::").last().unwrap(), + path = ?self.path, + "requesting DO", + ); + let resp = storage_proxy::handle_do_request( + self.durable.env, + Default::default(), + self.path.to_uri(), + self.request, + |_, _, _| {}, + ) + .await?; + + use http_body_util::BodyExt; + let (resp, body) = resp.into_parts(); + let body = body.collect().await?.to_bytes(); + if resp.status.is_success() { + Ok(serde_json::from_slice(&body)?) + } else { + Err(Error::Http { + status: resp.status, + body: String::from_utf8_lossy(&body).into_owned(), + }) + } + } +} + +impl<'d, B: DurableMethod> RequestBuilder<'d, B, [u8; 0]> { + pub fn encode(self, payload: &T) -> RequestBuilder<'d, B, Vec> { + self.with_body(payload.encode_to_bytes()) + } + + pub fn with_body>(self, payload: T) -> RequestBuilder<'d, B, T> { + RequestBuilder { + durable: self.durable, + path: self.path, + request: self.request.with_body(payload), + } + } +} + +impl Do<'_> { + pub fn request( + &self, + path: B, + params: B::NameParameters<'_>, + ) -> RequestBuilder<'_, B, [u8; 0]> { + let (request, _) = DurableRequest::new(path, params); + RequestBuilder { + durable: self, + path, + request: if self.retry { + request.with_retry() + } else { + request + }, + } + } + + #[expect(dead_code)] + pub fn request_with_id( + &self, + path: B, + object_id: ObjectIdFrom, + ) -> RequestBuilder<'_, B, [u8; 0]> { + let (request, _) = DurableRequest::new_with_id(path, object_id); + RequestBuilder { + durable: self, + path, + request: if self.retry { + request.with_retry() + } else { + request + }, + } + } +} diff --git a/crates/daphne-worker/src/storage_proxy/middleware.rs b/crates/daphne-worker/src/storage_proxy/middleware.rs index d4baaf721..14d1a6f9d 100644 --- a/crates/daphne-worker/src/storage_proxy/middleware.rs +++ b/crates/daphne-worker/src/storage_proxy/middleware.rs @@ -1,10 +1,7 @@ -// Copyright (c) 2024 Cloudflare, Inc. All rights reserved. +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause -use std::{ - sync::{Arc, OnceLock}, - time::Duration, -}; +use std::sync::{Arc, OnceLock}; use axum::{ extract::{Path, State}, @@ -20,6 +17,7 @@ use http::{Method, StatusCode}; use tower_service::Service; use super::RequestContext; +use crate::elapsed; /// Performs bearer token auth of a request. pub async fn bearer_auth( @@ -105,7 +103,3 @@ pub async fn time_do_requests( ); response } - -fn elapsed(date: &worker::Date) -> Duration { - Duration::from_millis(worker::Date::now().as_millis() - date.as_millis()) -} diff --git a/crates/daphne-worker/src/storage_proxy/mod.rs b/crates/daphne-worker/src/storage_proxy/mod.rs index c8f68a851..661c9cf68 100644 --- a/crates/daphne-worker/src/storage_proxy/mod.rs +++ b/crates/daphne-worker/src/storage_proxy/mod.rs @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Cloudflare, Inc. All rights reserved. +// Copyright (c) 2025 Cloudflare, Inc. All rights reserved. // SPDX-License-Identifier: BSD-3-Clause //! This is a Worker that proxies requests to the storage that Workers has access to, i.e., KV and @@ -84,7 +84,7 @@ use axum::{ }; use axum_extra::TypedHeader; use bytes::Bytes; -use daphne::messages::Time; +use daphne::messages::{self, Time}; use daphne_service_utils::durable_requests::{ DurableRequest, ObjectIdFrom, DO_PATH_PREFIX, KV_PATH_PREFIX, }; @@ -136,10 +136,10 @@ pub async fn handle_request(req: HttpRequest, env: Env, registry: &Registry) -> let router = axum::Router::new() .route( constcat::concat!(KV_PATH_PREFIX, "/*path"), - routing::get(kv_get) - .post(kv_put) - .put(kv_put_if_not_exists) - .delete(kv_delete) + routing::get(kv_get_handler) + .post(kv_put_handler) + .put(kv_put_if_not_exists_handler) + .delete(kv_delete_handler) .route_layer(from_fn_with_state( ctx.clone(), middleware::time_kv_requests, @@ -147,7 +147,7 @@ pub async fn handle_request(req: HttpRequest, env: Env, registry: &Registry) -> ) .route( constcat::concat!(DO_PATH_PREFIX, "/*path"), - routing::any(handle_do_request).layer(from_fn_with_state( + routing::any(handle_do_request_handler).layer(from_fn_with_state( ctx.clone(), middleware::time_do_requests, )), @@ -157,7 +157,7 @@ pub async fn handle_request(req: HttpRequest, env: Env, registry: &Registry) -> let router = router .route( daphne_service_utils::durable_requests::PURGE_STORAGE, - routing::any(storage_purge), + routing::any(storage_purge_handler), ) .route( daphne_service_utils::durable_requests::STORAGE_READY, @@ -172,13 +172,21 @@ pub async fn handle_request(req: HttpRequest, env: Env, registry: &Registry) -> /// Clear all storage. Only available to tests #[cfg(feature = "test-utils")] -#[tracing::instrument(skip(ctx))] #[worker::send] -async fn storage_purge(ctx: State>) -> impl IntoResponse + 'static { +async fn storage_purge_handler(ctx: State>) -> impl IntoResponse + 'static { + storage_purge(&ctx.env) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) +} + +#[cfg(feature = "test-utils")] +#[tracing::instrument(skip_all)] +#[worker::send] +pub async fn storage_purge(env: &Env) -> Result<(), worker::Error> { use daphne_service_utils::durable_requests::bindings::{DurableMethod, TestStateCleaner}; let kv_delete = async { - let kv = ctx.env.kv(KV_BINDING_DAP_CONFIG)?; + let kv = env.kv(KV_BINDING_DAP_CONFIG)?; for key in kv.list().execute().await?.keys { kv.delete(&key.name).await?; tracing::trace!("deleted KV item {}", key.name); @@ -192,17 +200,14 @@ async fn storage_purge(ctx: State>) -> impl IntoResponse + ' RequestInit::new().with_method(worker::Method::Post), )?; - ctx.env - .durable_object(TestStateCleaner::BINDING)? + env.durable_object(TestStateCleaner::BINDING)? .id_from_name(TestStateCleaner::NAME_STR)? .get_stub()? .fetch_with_request(req) .await }; - futures::try_join!(kv_delete, do_delete) - .map(|_| ()) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()) + futures::try_join!(kv_delete, do_delete).map(|_| ()) } #[derive(Debug)] @@ -234,25 +239,23 @@ impl Header for ExpirationHeader { } } -impl ExpirationHeader { - fn at_least_60s_from_now(self) -> Self { - // KV wont let you request an expiration that isn't at least 60 seconds into the - // future. If you try to do so, it will return a 400. The problem is, the only error - // the worker API returns is a JsValue that might contain a string that might - // explain that. - // - // In order to avoid parsing the error message we opt to just "hardcode" the - // expiration to be, at least 65 seconds from now. Effectively expiring the value as soon - // as possible. The extra 5 seconds are just in case we take a really long time from here to - // the request. - let now_plus_65_seconds = (Date::now().as_millis() / 1000) + 65; - Self(u64::max(self.0, now_plus_65_seconds)) - } +fn at_least_60s_from_now(time: messages::Time) -> messages::Time { + // KV wont let you request an expiration that isn't at least 60 seconds into the + // future. If you try to do so, it will return a 400. The problem is, the only error + // the worker API returns is a JsValue that might contain a string that might + // explain that. + // + // In order to avoid parsing the error message we opt to just "hardcode" the + // expiration to be, at least 65 seconds from now. Effectively expiring the value as soon + // as possible. The extra 5 seconds are just in case we take a really long time from here to + // the request. + let now_plus_65_seconds = (Date::now().as_millis() / 1000) + 65; + messages::Time::max(time, now_plus_65_seconds) } async fn retry(mut f: F) -> Result where - F: FnMut(usize) -> Fut, + F: FnMut(u8) -> Fut, Fut: Future>, { const RETRY_DELAYS: &[Duration] = &[ @@ -261,14 +264,14 @@ where Duration::from_millis(4_000), Duration::from_millis(8_000), ]; - let attempts = RETRY_DELAYS.len() + 1; + let attempts = u8::try_from(RETRY_DELAYS.len() + 1).unwrap(); let mut attempt = 1; loop { match f(attempt).await { Ok(ok) => return Ok(ok), Err(error) => { if attempt < attempts { - Delay::from(RETRY_DELAYS[attempt - 1]).await; + Delay::from(RETRY_DELAYS[usize::from(attempt - 1)]).await; attempt += 1; } else { return Err(error); @@ -278,35 +281,50 @@ where } } -#[tracing::instrument(skip(ctx))] #[worker::send] -async fn kv_get( +async fn kv_get_handler( ctx: State>, Path(key): Path, ) -> Result { - let get = ctx.env.kv(KV_BINDING_DAP_CONFIG)?.get(&key); - - if let Some(bytes) = retry(|_| get.clone().bytes()).await? { + if let Some(bytes) = kv_get(&ctx.env, &key).await? { Ok((StatusCode::OK, bytes).into_response()) } else { Ok((StatusCode::NOT_FOUND, "value not found").into_response()) } } -#[tracing::instrument(skip(ctx, body))] +#[tracing::instrument(skip(env))] #[worker::send] -async fn kv_put( +pub async fn kv_get(env: &Env, key: &str) -> Result>, worker::Error> { + let get = env.kv(KV_BINDING_DAP_CONFIG)?.get(key); + Ok(retry(|_| get.clone().bytes()).await?) +} + +#[worker::send] +async fn kv_put_handler( ctx: State>, expiration: Option>, Path(key): Path, body: Bytes, ) -> Result { - let expiration = expiration.map(|TypedHeader(header)| header); + let expiration = expiration.map(|TypedHeader(header)| header.0); + + kv_put(&ctx.env, expiration, &key, &body).await?; + + Ok(StatusCode::OK.into_response()) +} - match ctx.env.kv(KV_BINDING_DAP_CONFIG)?.put_bytes(&key, &body) { +#[tracing::instrument(skip(env, body))] +pub async fn kv_put( + env: &Env, + expiration: Option, + key: &str, + body: &[u8], +) -> Result<(), worker::Error> { + match env.kv(KV_BINDING_DAP_CONFIG)?.put_bytes(key, body) { Ok(mut put) => { if let Some(expiration_unix_timestamp) = expiration { - put = put.expiration(expiration_unix_timestamp.at_least_60s_from_now().0); + put = put.expiration(at_least_60s_from_now(expiration_unix_timestamp)); }; if let Err(error) = retry(|_| put.clone().execute()).await { tracing::warn!( @@ -322,34 +340,47 @@ async fn kv_put( ); } } - - Ok(StatusCode::OK.into_response()) + Ok(()) } -#[tracing::instrument(skip(ctx, body))] #[worker::send] -async fn kv_put_if_not_exists( +async fn kv_put_if_not_exists_handler( ctx: State>, expiration: Option>, Path(key): Path, body: Bytes, ) -> Result { - let expiration = expiration.map(|TypedHeader(header)| header); + let expiration = expiration.map(|TypedHeader(header)| header.0); + + if kv_put_if_not_exists(&ctx.env, expiration, &key, &body).await? { + Ok(StatusCode::OK) + } else { + Ok(StatusCode::CONFLICT) + } +} - let kv = ctx.env.kv(KV_BINDING_DAP_CONFIG)?; - let listing = kv.list().prefix(key.clone()); +#[tracing::instrument(skip(env, body))] +#[worker::send] +pub async fn kv_put_if_not_exists( + env: &Env, + expiration: Option, + key: &str, + body: &[u8], +) -> Result { + let kv = env.kv(KV_BINDING_DAP_CONFIG)?; + let listing = kv.list().prefix(key.to_string()); if retry(|_| listing.clone().execute()) .await? .keys .into_iter() .any(|k| k.name == key) { - Ok(StatusCode::CONFLICT.into_response()) + Ok(false) } else { - match kv.put_bytes(&key, &body) { + match kv.put_bytes(key, body) { Ok(mut put) => { if let Some(expiration_unix_timestamp) = expiration { - put = put.expiration(expiration_unix_timestamp.at_least_60s_from_now().0); + put = put.expiration(at_least_60s_from_now(expiration_unix_timestamp)); } if let Err(error) = retry(|_| put.clone().execute()).await { tracing::warn!( @@ -366,26 +397,30 @@ async fn kv_put_if_not_exists( } } - Ok(StatusCode::OK.into_response()) + Ok(true) } } -#[tracing::instrument(skip(ctx))] #[worker::send] -async fn kv_delete( +async fn kv_delete_handler( ctx: State>, Path(key): Path, ) -> Result { - let kv = ctx.env.kv(KV_BINDING_DAP_CONFIG)?; - retry(|_| kv.delete(&key)).await?; + kv_delete(&ctx.env, &key).await?; Ok(StatusCode::OK.into_response()) } +#[tracing::instrument(skip(env))] +pub async fn kv_delete(env: &Env, key: &str) -> Result<(), worker::Error> { + let kv = env.kv(KV_BINDING_DAP_CONFIG)?; + retry(|_| kv.delete(key)).await?; + Ok(()) +} + /// Handle a durable object request -#[tracing::instrument(skip(ctx, headers, body))] #[worker::send] -async fn handle_do_request( +async fn handle_do_request_handler( ctx: State>, headers: HeaderMap, Path(uri): Path, @@ -394,11 +429,42 @@ async fn handle_do_request( let durable_request = DurableRequest::try_from(body.as_ref()) .map_err(|e| worker::Error::RustError(format!("invalid format: {e:?}")))?; + Ok(handle_do_request( + &ctx.env, + headers, + &uri, + durable_request, + |attempt, binding, uri| match attempt { + Some(attempt) => ctx.metrics.durable_request_retry_count_inc( + attempt.try_into().unwrap(), + binding, + uri, + ), + None => ctx + .metrics + .durable_request_retry_count_inc(-1, binding, uri), + }, + ) + .await?) +} + +#[tracing::instrument(skip(env, headers, durable_request, retry_metric))] +#[worker::send] +pub async fn handle_do_request>( + env: &Env, + headers: HeaderMap, + uri: &str, + durable_request: DurableRequest

, + retry_metric: impl Fn(Option, &str, &str), +) -> Result { let http_request = { let mut do_req = RequestInit::new(); do_req.with_method(worker::Method::Post); do_req.with_headers(headers.into()); - tracing::debug!(len = body.len(), "deserializing do request"); + tracing::debug!( + len = durable_request.body().len(), + "deserializing do request" + ); { let body = durable_request.body(); @@ -409,24 +475,21 @@ async fn handle_do_request( buffer.copy_from(body); do_req.with_body(Some(buffer.into())); } - let url = Url::parse("https://fake-host/") - .unwrap() - .join(&uri) - .unwrap(); + let url = Url::parse("https://fake-host/").unwrap().join(uri).unwrap(); Request::new_with_init(url.as_str(), &do_req)? }; - let binding = ctx.env.durable_object(&durable_request.binding)?; + let binding = env.durable_object(&durable_request.binding)?; let obj = match &durable_request.id { ObjectIdFrom::Name(name) => binding.id_from_name(name.as_str())?, ObjectIdFrom::Hex(hex) => binding.id_from_string(hex.as_str())?, }; retry(|attempt| { - let ctx = &ctx; let obj = &obj; let binding = &durable_request.binding; let uri = &uri; let http_request = http_request.clone(); + let retry_metric = &retry_metric; async move { tracing::debug!(id = obj.to_string(), "Getting DO stub"); @@ -438,11 +501,7 @@ async fn handle_do_request( match stub.fetch_with_request(http_request?).await { Ok(ok) => { - ctx.metrics.durable_request_retry_count_inc( - (attempt - 1).try_into().unwrap(), - binding, - uri, - ); + retry_metric(Some(attempt - 1), binding, uri); Ok(HttpResponse::try_from(ok).unwrap()) } Err(error) => { @@ -454,9 +513,8 @@ async fn handle_do_request( error = ?error, "DO request failed" ); - ctx.metrics - .durable_request_retry_count_inc(-1, binding, uri); - Err(error.into()) + retry_metric(None, binding, uri); + Err(error) } } }