diff --git a/Cargo.lock b/Cargo.lock index 3a090475a..e2e630929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,15 +21,13 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.5" +version = "3.18.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d62d1a48894ec9450bcde7ef1e3681205771ff6ea9c61cc5ae031145192787" +checksum = "a3f290edc569ad10c07287eebb96629686b37f1e55283a093569151d3adde0c7" dependencies = [ "actix-codec", "actix-service", - "actix-tls", "actix-utils", - "base64", "bitflags 2.13.2", "brotli", "bytes", @@ -39,18 +37,15 @@ dependencies = [ "flate2", "foldhash 0.2.0", "futures-core", - "h2", + "h2 0.3.27", "http 0.2.12", "httparse", "httpdate", "itoa", "language-tags", - "local-channel", "mime", "percent-encoding", "pin-project-lite", - "rand 0.10.2", - "sha1", "smallvec", "tokio", "tokio-util", @@ -119,27 +114,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "actix-tls" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21d0a5de50893252dfa9d7c4bf83761bcc1920651916cd9da24c974cf8ee1b92" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "http 0.2.12", - "http 1.5.0", - "impl-more", - "pin-project-lite", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tokio-util", - "tracing", -] - [[package]] name = "actix-utils" version = "3.0.1" @@ -163,7 +137,6 @@ dependencies = [ "actix-rt", "actix-server", "actix-service", - "actix-tls", "actix-utils", "actix-web-codegen", "bytes", @@ -307,45 +280,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -369,72 +303,28 @@ dependencies = [ ] [[package]] -name = "autocfg" -version = "1.5.1" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "awc" -version = "3.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7dc0207013c5059ddce268fe12045bd12b2e919318ee660c891bfe297a54f1f" -dependencies = [ - "actix-codec", - "actix-http", - "actix-rt", - "actix-service", - "actix-tls", - "actix-utils", - "base64", - "bytes", - "cfg-if", - "cookie", - "derive_more", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "itoa", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "rand 0.9.5", - "serde", - "serde_json", - "serde_urlencoded", - "tokio", -] - -[[package]] -name = "aws-lc-rs" -version = "1.18.1" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "aws-lc-sys" -version = "0.45.0" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bincode" @@ -445,15 +335,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -617,17 +498,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core 0.10.1", -] - [[package]] name = "ciborium" version = "0.2.2" @@ -695,15 +565,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" @@ -928,12 +789,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - [[package]] name = "defmt" version = "1.1.1" @@ -971,34 +826,21 @@ version = "0.1.0" dependencies = [ "actix-web", "anyhow", - "awc", "clap", "env_logger", + "futures-util", "log", "microsoft-webui", "microsoft-webui-handler", "microsoft-webui-protocol", "mime_guess", + "reqwest", "serde", "serde_json", "tokio", "toml 1.1.6+spec-1.1.0", ] -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1063,12 +905,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "either" version = "1.18.0" @@ -1223,12 +1059,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1383,29 +1213,6 @@ dependencies = [ "system-deps 7.0.8", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -1414,8 +1221,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", + "r-efi", ] [[package]] @@ -1625,6 +1431,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1702,6 +1527,29 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -1723,6 +1571,49 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1881,6 +1772,12 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1983,7 +1880,7 @@ version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.4.3", + "getrandom", "libc", ] @@ -2024,12 +1921,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.189" @@ -2064,17 +1955,6 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" -[[package]] -name = "local-channel" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" -dependencies = [ - "futures-core", - "futures-sink", - "local-waker", -] - [[package]] name = "local-waker" version = "0.1.4" @@ -2104,13 +1984,13 @@ dependencies = [ "anyhow", "bytes", "clap", + "h2 0.4.20", "hmac", + "http 1.5.0", "log", "microsoft-webui", "microsoft-webui-handler", "mime_guess", - "rcgen", - "rustls", "serde", "serde_json", "sha2", @@ -2139,7 +2019,6 @@ name = "microsoft-webui" version = "3.0.4" dependencies = [ "actix-web", - "awc", "bytes", "crc32fast", "criterion", @@ -2154,6 +2033,7 @@ dependencies = [ "microsoft-webui-protocol", "microsoft-webui-tokens", "rayon", + "reqwest", "serde", "serde_json", "sha2", @@ -2170,7 +2050,6 @@ version = "3.0.4" dependencies = [ "actix-web", "anyhow", - "awc", "bytes", "clap", "console", @@ -2185,6 +2064,7 @@ dependencies = [ "microsoft-webui-protocol", "microsoft-webui-tokens", "mime_guess", + "reqwest", "serde", "serde_json", "tempfile", @@ -2199,7 +2079,7 @@ name = "microsoft-webui-desktop" version = "3.0.4" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "block2", "clap", "console", @@ -2208,7 +2088,7 @@ dependencies = [ "futures-channel", "futures-executor", "futures-util", - "getrandom 0.4.3", + "getrandom", "gtk4", "libc", "microsoft-webui", @@ -2462,12 +2342,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.9.1" @@ -2588,7 +2462,7 @@ version = "0.1.0" dependencies = [ "anyhow", "futures-executor", - "getrandom 0.4.3", + "getrandom", "microsoft-webui-desktop", "microsoft-webui-handler", "microsoft-webui-tokens", @@ -2613,16 +2487,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "notify" version = "8.2.0" @@ -2662,31 +2526,12 @@ dependencies = [ "bitflags 2.13.2", ] -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2780,15 +2625,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2885,16 +2721,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3021,15 +2847,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -3185,64 +3002,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "rayon" version = "1.12.0" @@ -3263,20 +3028,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rcgen" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3322,17 +3073,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "ring" -version = "0.17.14" +name = "reqwest" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", ] [[package]] @@ -3350,15 +3119,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - [[package]] name = "rustix" version = "1.1.4" @@ -3372,42 +3132,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -3516,17 +3240,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sha2" version = "0.11.0" @@ -3648,12 +3361,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.119" @@ -3676,6 +3383,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3750,7 +3466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3862,16 +3578,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tokio-rustls" -version = "0.26.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.19" @@ -3972,6 +3678,51 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http 1.5.0", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -4004,6 +3755,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typed-arena" version = "2.0.2" @@ -4055,12 +3812,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -4108,19 +3859,19 @@ dependencies = [ ] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "want" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] [[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" @@ -4135,6 +3886,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.127" @@ -4167,6 +3928,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -4406,22 +4180,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4433,22 +4198,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - [[package]] name = "windows-targets" version = "0.53.5" @@ -4456,14 +4205,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4475,96 +4224,48 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -4595,36 +4296,12 @@ dependencies = [ "version_check", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "ring", - "rusticata-macros", - "thiserror", - "time", -] - [[package]] name = "xtask" version = "0.1.0" @@ -4652,16 +4329,6 @@ dependencies = [ "serde", ] -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec", - "time", -] - [[package]] name = "yoke" version = "0.8.3" @@ -4726,12 +4393,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index aa7814417..caef2bde6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,10 +40,19 @@ pyo3 = { version = "0.29.2", features = ["abi3-py311", "generate-import-lib"] } wasm-bindgen = "=0.2.127" js-sys = "0.3.104" serde-wasm-bindgen = "0.6.5" -actix-web = "4.15.0" -rustls = "0.23.46" -rcgen = "0.14.9" -awc = "3.8.2" +actix-web = { version = "4.15.0", default-features = false, features = [ + "macros", + "compress-brotli", + "compress-gzip", + "compress-zstd", + "cookies", + "http2", + "unicode", + "compat", +] } +h2 = "0.4.20" +http = "1.4.0" +reqwest = { version = "0.13.5", default-features = false, features = ["stream"] } hmac = "0.13.0" sha2 = "0.11.0" expand-tilde = "0.6.1" diff --git a/crates/webui-cli/Cargo.toml b/crates/webui-cli/Cargo.toml index b5821108e..db5e31049 100644 --- a/crates/webui-cli/Cargo.toml +++ b/crates/webui-cli/Cargo.toml @@ -30,7 +30,7 @@ console = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } actix-web = { workspace = true } -awc = { workspace = true } +reqwest = { workspace = true } expand-tilde = { workspace = true } mime_guess = { workspace = true } bytes = { workspace = true } diff --git a/crates/webui-cli/src/commands/serve.rs b/crates/webui-cli/src/commands/serve.rs index dc8d6c341..3616bf768 100644 --- a/crates/webui-cli/src/commands/serve.rs +++ b/crates/webui-cli/src/commands/serve.rs @@ -287,6 +287,12 @@ const HMR_ENDPOINT: &str = "/__webui/livereload"; /// where spurious rebuilds reload the page mid-test. const WATCH_DISABLE_ENV: &str = "WEBUI_NO_WATCH"; +/// Matches AWC's default buffered response-body limit. +const MAX_BUFFERED_API_BODY_BYTES: usize = 2 * 1024 * 1024; + +/// Matches AWC's default request timeout for bounded API responses. +const API_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); + fn watch_disabled_by_env() -> bool { match std::env::var(WATCH_DISABLE_ENV) { Ok(v) => !v.is_empty() && v != "0", @@ -316,6 +322,7 @@ fn execute_mode(args: &ServeArgs) -> Result { fn run(args: &ServeArgs, control: Option) -> Result<()> { let host_policy = host::HostPolicy::new(args.port, &args.allowed_hosts)?; let paths = ServePaths::from_args(args)?; + let api = build_api_context(args.api_port)?; // Allow E2E / CI runs to suppress watch mode without editing the // package.json `start:server` script that devs share. let watch_enabled = args.watch && !watch_disabled_by_env(); @@ -461,7 +468,7 @@ fn run(args: &ServeArgs, control: Option) -> Result<()> { livereload: livereload.clone(), assets_dir: paths.serve_dir, component_assets_dir, - api_port: args.api_port, + api, plugin: args.app_args.plugin, base_path: args.base_path.clone(), // Pool sized for typical concurrent renders × channel capacity. @@ -513,7 +520,7 @@ fn serve_app( InitError = (), >, > { - let has_api_proxy = context.api_port.is_some(); + let has_api_proxy = context.api.is_some(); let mut app = App::new() .wrap_fn(move |req, service| { if host_policy.allows(req.request()) { @@ -813,7 +820,7 @@ struct ServerContext { livereload: Option, assets_dir: Option, component_assets_dir: Option, - api_port: Option, + api: Option, plugin: Option, /// Base path for sub-path deployment. base_path: Option, @@ -823,6 +830,12 @@ struct ServerContext { chunk_pool: Arc, } +#[derive(Clone)] +struct ApiContext { + port: u16, + client: reqwest::Client, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RequestPaths { route_path: String, @@ -876,18 +889,128 @@ fn request_paths(req: &HttpRequest) -> RequestPaths { } } +fn build_http_client() -> Result { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .context( + "Failed to initialize the HTTP client for API state and proxy requests; \ + verify the system network resolver configuration", + ) +} + +fn build_api_context(api_port: Option) -> Result> { + build_api_context_with(api_port, build_http_client) +} + +fn build_api_context_with( + api_port: Option, + build_client: impl FnOnce() -> Result, +) -> Result> { + match api_port { + Some(port) => Ok(Some(ApiContext { + port, + client: build_client()?, + })), + None => Ok(None), + } +} + +#[derive(Debug, thiserror::Error)] +enum BufferedBodyError { + #[error("response body exceeds the {limit}-byte limit")] + TooLarge { limit: usize }, + #[error("failed to read response body: {0}")] + Read(#[source] reqwest::Error), +} + +#[derive(Debug, thiserror::Error)] +enum ApiRequestError { + #[error("request timed out after {timeout:?}")] + Timeout { + timeout: Duration, + #[source] + source: reqwest::Error, + }, + #[error(transparent)] + Request(#[from] reqwest::Error), +} + +async fn send_bounded_api_request( + request: reqwest::RequestBuilder, + timeout: Duration, +) -> Result { + request.timeout(timeout).send().await.map_err(|source| { + if source.is_timeout() { + ApiRequestError::Timeout { timeout, source } + } else { + ApiRequestError::Request(source) + } + }) +} + +async fn send_api_stream_candidate( + request: reqwest::RequestBuilder, + timeout: Duration, +) -> Result<(reqwest::Response, tokio::time::Instant), String> { + let deadline = tokio::time::Instant::now() + timeout; + match tokio::time::timeout_at(deadline, request.send()).await { + Ok(Ok(response)) => Ok((response, deadline)), + Ok(Err(error)) => Err(format!("API proxy error: {error}")), + Err(_) => Err(format!( + "API proxy error: request timed out after {timeout:?}" + )), + } +} + +async fn read_bounded_body( + response: reqwest::Response, + limit: usize, +) -> Result, BufferedBodyError> { + let content_length = response + .content_length() + .and_then(|length| usize::try_from(length).ok()); + if content_length.is_some_and(|length| length > limit) { + return Err(BufferedBodyError::TooLarge { limit }); + } + let initial_capacity = content_length.unwrap_or(0); + let mut body = Vec::with_capacity(initial_capacity); + let mut stream = response.bytes_stream(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(BufferedBodyError::Read)?; + if chunk.len() > limit - body.len() { + return Err(BufferedBodyError::TooLarge { limit }); + } + body.extend_from_slice(&chunk); + } + + Ok(body) +} + /// Fetch state from the user's API server for a given request path, including query parameters. -async fn fetch_api_state(api_port: u16, path: &str) -> Result { - let client = awc::Client::new(); +async fn fetch_api_state( + client: &reqwest::Client, + api_port: u16, + path: &str, +) -> Result { + fetch_api_state_with_timeout(client, api_port, path, API_RESPONSE_TIMEOUT).await +} + +async fn fetch_api_state_with_timeout( + client: &reqwest::Client, + api_port: u16, + path: &str, + timeout: Duration, +) -> Result { let url = format!("http://127.0.0.1:{api_port}{path}"); - let mut resp = client + let request = client .get(&url) - .insert_header(("Accept", "application/json")) - .send() + .header(reqwest::header::ACCEPT, "application/json"); + let resp = send_bounded_api_request(request, timeout) .await .map_err(|e| format!("API proxy error: {e}"))?; - let body = resp - .body() + let body = read_bounded_body(resp, MAX_BUFFERED_API_BODY_BYTES) .await .map_err(|e| format!("API body error: {e}"))?; let json: Value = serde_json::from_slice(&body).map_err(|e| format!("API JSON error: {e}"))?; @@ -897,8 +1020,8 @@ async fn fetch_api_state(api_port: u16, path: &str) -> Result { /// Resolve state for a request: try API proxy first, then fall back to file state. async fn resolve_state(context: &ServerContext, request_path: &str) -> Value { - let (mut state, token_css) = if let Some(api_port) = context.api_port { - match fetch_api_state(api_port, request_path).await { + let (mut state, token_css) = if let Some(api) = &context.api { + match fetch_api_state(&api.client, api.port, request_path).await { Ok(state) => { let token_css = context.state.lock().ok().and_then(|s| s.token_css.clone()); (state, token_css) @@ -977,16 +1100,21 @@ async fn render_page_response( let route_params = webui_handler::route_handler::collect_nested_route_params(&proto, &entry, route_path); - let mut state = if let Some(api_port) = context.api_port { - let client = awc::Client::new(); - let url = format!("http://127.0.0.1:{api_port}{request_path}"); - match client + let mut state = if let Some(api) = &context.api { + let url = format!("http://127.0.0.1:{}{request_path}", api.port); + let request = api + .client .get(&url) - .insert_header(("Accept", streaming_api::ACCEPT)) - .send() - .await - { - Ok(response) if streaming_api::is_stream(response.headers()) => { + .header(reqwest::header::ACCEPT, streaming_api::ACCEPT); + match send_api_stream_candidate(request, API_RESPONSE_TIMEOUT).await { + Ok((response, _)) + if streaming_api::is_stream_content_type( + response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + ) => + { let status = response.status(); if !status.is_success() { // The upstream refused before writing a single record, so no @@ -1000,7 +1128,9 @@ async fn render_page_response( )); fallback_state(context) } else { - let backend = response.map(|chunk| chunk.map_err(|error| error.to_string())); + let backend = response + .bytes_stream() + .map(|chunk| chunk.map_err(|error| error.to_string())); let defaults = streaming_state_defaults(context, route_params); return streaming_api::render( backend, @@ -1017,21 +1147,32 @@ async fn render_page_response( .await; } } - Ok(mut response) => match response.body().await { - Ok(body) => match parse_api_state(&body) { - Ok(state) => state, - Err(error) => { - log_api_state_warning(&error); + Ok((response, deadline)) => { + match tokio::time::timeout_at( + deadline, + read_bounded_body(response, MAX_BUFFERED_API_BODY_BYTES), + ) + .await + { + Ok(Ok(body)) => match parse_api_state(&body) { + Ok(state) => state, + Err(error) => { + log_api_state_warning(&error); + fallback_state(context) + } + }, + Ok(Err(error)) => { + log_api_state_warning(&format!("API body error: {error}")); + fallback_state(context) + } + Err(_) => { + log_api_state_warning("API body error: request timed out"); fallback_state(context) } - }, - Err(error) => { - log_api_state_warning(&format!("API body error: {error}")); - fallback_state(context) } - }, + } Err(error) => { - log_api_state_warning(&format!("API proxy error: {error}")); + log_api_state_warning(&error); fallback_state(context) } } @@ -1157,7 +1298,7 @@ async fn handle_index(req: HttpRequest, context: web::Data) -> Ht } // With API proxy, render on-the-fly with fresh state - if context.api_port.is_some() { + if context.api.is_some() { let paths = build_request_paths("", req.query_string()); return render_page_response(&context, &paths.route_path, &paths.request_path).await; } @@ -1615,7 +1756,16 @@ async fn handle_api_proxy( body: web::Bytes, context: web::Data, ) -> HttpResponse { - let Some(api_port) = context.api_port else { + handle_api_proxy_with_timeout(req, body, context, API_RESPONSE_TIMEOUT).await +} + +async fn handle_api_proxy_with_timeout( + req: HttpRequest, + body: web::Bytes, + context: web::Data, + timeout: Duration, +) -> HttpResponse { + let Some(api) = &context.api else { return HttpResponse::NotFound().body("Not Found"); }; @@ -1631,31 +1781,52 @@ async fn handle_api_proxy( // Write the port digits straight into the reserved capacity instead of // allocating a temporary `String` via `to_string()` on the proxy hot path. use std::fmt::Write as _; - let _ = write!(url, "{api_port}"); + let _ = write!(url, "{}", api.port); url.push_str(path_and_query); - let client = awc::Client::new(); - let mut proxy_req = client.request(req.method().clone(), &url); + let method = match reqwest::Method::from_bytes(req.method().as_str().as_bytes()) { + Ok(method) => method, + Err(error) => { + return HttpResponse::BadRequest().body(format!("Invalid API proxy method: {error}")); + } + }; + let mut proxy_req = api.client.request(method, &url); // Forward content-type header if present if let Some(ct) = req.headers().get("content-type") { - proxy_req = proxy_req.insert_header(("content-type", ct.clone())); + proxy_req = proxy_req.header(reqwest::header::CONTENT_TYPE, ct.as_bytes()); } - let result = if body.is_empty() { - proxy_req.send().await - } else { - proxy_req.send_body(body).await - }; + let result = send_bounded_api_request(proxy_req.body(body), timeout).await; match result { - Ok(mut resp) => { + Ok(resp) => { let status = resp.status(); - match resp.body().await { + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .map(|value| value.as_bytes().to_vec()); + match read_bounded_body(resp, MAX_BUFFERED_API_BODY_BYTES).await { Ok(response_body) => { + let status = match actix_web::http::StatusCode::from_u16(status.as_u16()) { + Ok(status) => status, + Err(error) => { + return HttpResponse::BadGateway() + .body(format!("Invalid API proxy status: {error}")); + } + }; let mut builder = HttpResponse::build(status); - if let Some(ct) = resp.headers().get("content-type") { - builder.insert_header(("content-type", ct.clone())); + if let Some(content_type) = content_type { + match actix_web::http::header::HeaderValue::from_bytes(&content_type) { + Ok(value) => { + builder + .insert_header((actix_web::http::header::CONTENT_TYPE, value)); + } + Err(error) => { + return HttpResponse::BadGateway() + .body(format!("Invalid API proxy content type: {error}")); + } + } } builder.body(response_body) } @@ -1836,6 +2007,36 @@ mod tests { dir } + fn test_http_client() -> reqwest::Client { + build_http_client().unwrap() + } + + fn test_api_context(port: u16) -> ApiContext { + ApiContext { + port, + client: test_http_client(), + } + } + + #[test] + fn static_server_context_does_not_build_http_client() { + let api = build_api_context_with(None, || { + Err(anyhow::anyhow!("HTTP client builder must not run")) + }) + .unwrap(); + + assert!(api.is_none()); + } + + #[test] + fn api_server_context_builds_http_client() { + let api = build_api_context_with(Some(4321), || Ok(test_http_client())) + .unwrap() + .unwrap(); + + assert_eq!(api.port, 4321); + } + fn test_server_context(api_port: u16) -> web::Data { web::Data::new(ServerContext { state: Arc::new(Mutex::new(SharedState { @@ -1851,7 +2052,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: Some(api_port), + api: Some(test_api_context(api_port)), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -1876,7 +2077,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: None, + api: None, plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -1944,6 +2145,194 @@ mod tests { (port, handle, request_targets) } + fn start_http_client_test_server() -> (u16, actix_web::dev::ServerHandle) { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = HttpServer::new(|| { + App::new() + .route( + "/redirect", + web::get().to(|| async { + HttpResponse::Found() + .insert_header(("location", "/target")) + .finish() + }), + ) + .route( + "/target", + web::get().to(|| async { HttpResponse::Ok().body("redirect followed") }), + ) + .route( + "/api/redirect", + web::get().to(|| async { + HttpResponse::Found() + .insert_header(("location", "/api/target")) + .finish() + }), + ) + .route( + "/api/target", + web::get().to(|| async { HttpResponse::Ok().body("redirect followed") }), + ) + .route( + "/exact", + web::get().to(|| async { + let chunks = vec![ + Ok::<_, actix_web::Error>(web::Bytes::from(vec![ + b'a'; + MAX_BUFFERED_API_BODY_BYTES + - 1 + ])), + Ok(web::Bytes::from_static(b"b")), + ]; + HttpResponse::Ok().streaming(tokio_stream::iter(chunks)) + }), + ) + .route( + "/over", + web::get().to(|| async { + let chunks = vec![ + Ok::<_, actix_web::Error>(web::Bytes::from(vec![ + b'a'; + MAX_BUFFERED_API_BODY_BYTES + ])), + Ok(web::Bytes::from_static(b"b")), + ]; + HttpResponse::Ok().streaming(tokio_stream::iter(chunks)) + }), + ) + .route( + "/stall", + web::get().to(|| async { std::future::pending::().await }), + ) + .route( + "/slow-stream", + web::get().to(|| async { + let (tx, rx) = tokio::sync::mpsc::channel(1); + actix_web::rt::spawn(async move { + tokio::time::sleep(Duration::from_millis(750)).await; + let _ = tx + .send(Ok::<_, actix_web::Error>(web::Bytes::from_static( + b"late stream chunk", + ))) + .await; + }); + HttpResponse::Ok() + .content_type(streaming_api::MEDIA_TYPE) + .streaming(tokio_stream::wrappers::ReceiverStream::new(rx)) + }), + ) + }) + .listen(listener) + .unwrap() + .run(); + let handle = server.handle(); + actix_web::rt::spawn(server); + (port, handle) + } + + #[actix_web::test] + async fn test_http_client_disables_redirects() { + let (port, handle) = start_http_client_test_server(); + let response = test_http_client() + .get(format!("http://127.0.0.1:{port}/redirect")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + assert_eq!( + response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some("/target") + ); + + handle.stop(true).await; + } + + #[actix_web::test] + async fn test_api_body_limit_accepts_exact_boundary_and_rejects_chunked_overflow() { + let (port, handle) = start_http_client_test_server(); + let client = test_http_client(); + + let exact_response = client + .get(format!("http://127.0.0.1:{port}/exact")) + .send() + .await + .unwrap(); + assert_eq!(exact_response.content_length(), None); + let exact = read_bounded_body(exact_response, MAX_BUFFERED_API_BODY_BYTES) + .await + .unwrap(); + assert_eq!(exact.len(), MAX_BUFFERED_API_BODY_BYTES); + + let over_response = client + .get(format!("http://127.0.0.1:{port}/over")) + .send() + .await + .unwrap(); + assert_eq!(over_response.content_length(), None); + let error = read_bounded_body(over_response, MAX_BUFFERED_API_BODY_BYTES) + .await + .unwrap_err(); + assert!(matches!( + error, + BufferedBodyError::TooLarge { + limit: MAX_BUFFERED_API_BODY_BYTES + } + )); + + handle.stop(true).await; + } + + #[actix_web::test] + async fn test_stalled_state_and_proxy_requests_time_out() { + let (port, handle) = start_http_client_test_server(); + let timeout = Duration::from_millis(20); + let client = test_http_client(); + + let state_error = fetch_api_state_with_timeout(&client, port, "/stall", timeout) + .await + .unwrap_err(); + assert!(state_error.contains("timed out"), "{state_error}"); + + let context = test_server_context(port); + let request = actix_test::TestRequest::get() + .uri("/stall") + .to_http_request(); + let response = + handle_api_proxy_with_timeout(request, web::Bytes::new(), context, timeout).await; + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = to_bytes(response.into_body()).await.unwrap(); + assert!( + String::from_utf8_lossy(&body).contains("timed out"), + "{}", + String::from_utf8_lossy(&body) + ); + + handle.stop(false).await; + } + + #[actix_web::test] + async fn test_streaming_api_body_has_no_total_response_timeout() { + let (port, handle) = start_http_client_test_server(); + let request = test_http_client() + .get(format!("http://127.0.0.1:{port}/slow-stream")) + .header(reqwest::header::ACCEPT, streaming_api::ACCEPT); + let (response, _) = send_api_stream_candidate(request, Duration::from_millis(500)) + .await + .unwrap(); + + let body = read_bounded_body(response, MAX_BUFFERED_API_BODY_BYTES) + .await + .unwrap(); + assert_eq!(body, b"late stream chunk"); + + handle.stop(true).await; + } + #[test] fn test_take_new_warnings_suppresses_repeats_and_surfaces_changes() { // `take_new_warnings` dedupes on each diagnostic's plain `body()` and @@ -2376,7 +2765,7 @@ mod tests { let handle = server.handle(); actix_web::rt::spawn(server); - let state = fetch_api_state(port, "/search?q=shirt&sort=price-desc") + let state = fetch_api_state(&test_http_client(), port, "/search?q=shirt&sort=price-desc") .await .unwrap(); @@ -2469,7 +2858,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: Some(api_port), + api: Some(test_api_context(api_port)), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -2730,6 +3119,28 @@ mod tests { handle.stop(true).await; } + #[actix_web::test] + async fn test_api_proxy_relays_redirect_status_without_following() { + let (port, handle) = start_http_client_test_server(); + let app = actix_test::init_service( + App::new() + .app_data(test_server_context(port)) + .route("/api/{tail:.*}", web::route().to(handle_api_proxy)), + ) + .await; + + let response = actix_test::call_service( + &app, + actix_test::TestRequest::get() + .uri("/api/redirect") + .to_request(), + ) + .await; + + assert_eq!(response.status(), StatusCode::FOUND); + handle.stop(true).await; + } + #[test] fn test_hmr_script_is_injected_when_livereload_present() { let app = create_app_dir(&[("index.html", "

Hi

"), ("state.json", "{}")]); @@ -2836,7 +3247,7 @@ mod tests { livereload: Some(livereload.clone()), assets_dir: None, component_assets_dir: None, - api_port: None, + api: None, plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -2911,7 +3322,7 @@ mod tests { livereload: Some(livereload), assets_dir: None, component_assets_dir: None, - api_port: None, + api: None, plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3152,7 +3563,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: Some(port), + api: Some(test_api_context(port)), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3226,7 +3637,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: Some(port), + api: Some(test_api_context(port)), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3845,7 +4256,7 @@ mod tests { .ok() .unwrap(); context.assets_dir = Some(public); - context.api_port = None; + context.api = None; let app = actix_test::init_service( App::new() .app_data(web::Data::new(context)) @@ -3920,7 +4331,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: None, + api: None, plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( diff --git a/crates/webui-cli/src/commands/serve/streaming_api.rs b/crates/webui-cli/src/commands/serve/streaming_api.rs index 06531a993..6fd7c7797 100644 --- a/crates/webui-cli/src/commands/serve/streaming_api.rs +++ b/crates/webui-cli/src/commands/serve/streaming_api.rs @@ -9,7 +9,6 @@ use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Duration; -use actix_web::http::header::{HeaderMap, CONTENT_TYPE}; use actix_web::HttpResponse; use bytes::{Bytes, BytesMut}; use serde::{Deserialize, Deserializer}; @@ -449,10 +448,8 @@ fn unsupported_version(record: usize, version: u8) -> ApiStreamError { ApiStreamError::UnsupportedVersion { record, version } } -pub(super) fn is_stream(headers: &HeaderMap) -> bool { - headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) +pub(super) fn is_stream_content_type(content_type: Option<&str>) -> bool { + content_type .and_then(|value| value.split(';').next()) .is_some_and(|value| value.trim().eq_ignore_ascii_case(MEDIA_TYPE)) } @@ -945,7 +942,6 @@ where mod tests { use super::*; use actix_web::body::to_bytes; - use actix_web::http::header::HeaderValue; use webui_protocol::{FragmentList, WebUIFragment, WebUIProtocol}; fn defaults() -> StateDefaults { @@ -1582,12 +1578,9 @@ mod tests { #[test] fn stream_content_type_accepts_parameters_case_insensitively() { - let mut headers = HeaderMap::new(); - headers.insert( - CONTENT_TYPE, - HeaderValue::from_static("Application/X-WebUI-Stream; charset=utf-8"), - ); - assert!(is_stream(&headers)); + assert!(is_stream_content_type(Some( + "Application/X-WebUI-Stream; charset=utf-8" + ))); } #[test] diff --git a/crates/webui/Cargo.toml b/crates/webui/Cargo.toml index ae39eeb0b..1683769d1 100644 --- a/crates/webui/Cargo.toml +++ b/crates/webui/Cargo.toml @@ -43,7 +43,7 @@ serde_json = { workspace = true } tokio = { workspace = true } tokio-stream = { workspace = true } actix-web = { workspace = true } -awc = { workspace = true } +reqwest = { workspace = true } futures-util = { workspace = true } libc = { workspace = true } microsoft-webui-handler = { path = "../webui-handler", version = "3.0.4" } diff --git a/crates/webui/examples/streaming_e2e_ttfb_bench.rs b/crates/webui/examples/streaming_e2e_ttfb_bench.rs index 4a279bf88..5bae5804d 100644 --- a/crates/webui/examples/streaming_e2e_ttfb_bench.rs +++ b/crates/webui/examples/streaming_e2e_ttfb_bench.rs @@ -19,7 +19,7 @@ //! scales — not just the trivial 35 µs render we have in the contact- //! book bench. //! -//! Measurements (using `awc` as the HTTP client): +//! Measurements (using `reqwest` as the HTTP client): //! //! * **TTFB** — milliseconds from request send to first response byte //! * **TTLB** — milliseconds from request send to last response byte @@ -52,9 +52,9 @@ #![allow(unsafe_code)] use actix_web::{web, App, HttpResponse, HttpServer}; -use awc::Client; use bytes::Bytes; use futures_util::StreamExt; +use reqwest::Client; use serde::Deserialize; use serde_json::{json, Value}; use std::path::PathBuf; @@ -302,11 +302,12 @@ struct Measurement { async fn measure_one(client: &Client, url: &str) -> Measurement { let start = Instant::now(); - let mut resp = client.get(url).send().await.expect("send"); + let resp = client.get(url).send().await.expect("send"); let ttfb = start.elapsed(); let mut body_bytes = 0usize; + let mut body = resp.bytes_stream(); // Drain the body, but only the first byte's arrival is "TTFB". - while let Some(chunk) = resp.next().await { + while let Some(chunk) = body.next().await { let chunk = chunk.expect("chunk"); body_bytes += chunk.len(); } diff --git a/deny.toml b/deny.toml index 7d594e775..769893b57 100644 --- a/deny.toml +++ b/deny.toml @@ -2,7 +2,7 @@ ignore = [ # bincode 1.x (transitive dep of syntect) — unmaintained but no security issue "RUSTSEC-2025-0141", - # actix-http/awc still pin h2 0.3; the low-severity fix is available only in h2 0.4. + # actix-http still pins h2 0.3; the low-severity fix is available only in h2 0.4. "RUSTSEC-2026-0258", ] @@ -36,9 +36,6 @@ skip = [ { name = "hashbrown", version = "=0.15.5" }, { name = "foldhash", version = "=0.1.5" }, { name = "r-efi" }, - # awc 3.8.2 uses rand 0.9 directly while actix-http 3.12 uses rand 0.10. - { name = "rand" }, - { name = "rand_core" }, { name = "windows-sys" }, { name = "windows-targets" }, { name = "windows_aarch64_gnullvm" }, diff --git a/examples/app/commerce/README.md b/examples/app/commerce/README.md index f66bd75a0..8b936192a 100644 --- a/examples/app/commerce/README.md +++ b/examples/app/commerce/README.md @@ -33,6 +33,19 @@ pnpm start Then open http://127.0.0.1:3004 +### HTTP versions and TLS + +The server speaks cleartext HTTP/1.1 and HTTP/2 with prior knowledge (h2c). +Provide TLS, HTTP/2 to browsers, and HTTP/3 with a TLS-terminating ingress or +reverse proxy. For example, Caddy serves HTTPS with HTTP/1.1, HTTP/2, and +HTTP/3 by default: + +```caddyfile +commerce.example.com { + reverse_proxy h2c://127.0.0.1:3004 +} +``` + ## Running Tests ```bash @@ -116,7 +129,7 @@ Or pass flags directly: docker run -d -p 3004:3000 webui-commerce --css link --port 3000 ``` -These ports are plain HTTP because the container starts `marketplace-api` with `--no-tls`. +These ports serve cleartext HTTP/1.1 and h2c. Use a TLS-terminating proxy or ingress for external HTTPS. To tag with the project version: diff --git a/examples/app/commerce/demo.toml b/examples/app/commerce/demo.toml index 9600b68e9..659bc2414 100644 --- a/examples/app/commerce/demo.toml +++ b/examples/app/commerce/demo.toml @@ -8,4 +8,4 @@ backend = "rust" [server] type = "custom-binary" binary = "marketplace-api" -args = ["--css", "style", "--no-tls"] +args = ["--css", "style"] diff --git a/examples/app/commerce/docker-entrypoint.sh b/examples/app/commerce/docker-entrypoint.sh index cabe58b92..d525d11d6 100644 --- a/examples/app/commerce/docker-entrypoint.sh +++ b/examples/app/commerce/docker-entrypoint.sh @@ -25,4 +25,4 @@ case "${CSS_STRATEGY}" in *) echo "Invalid CSS strategy: ${CSS_STRATEGY} (expected link, module, or style)" >&2; exit 1 ;; esac -exec marketplace-api --port "${PORT}" --css "${CSS_STRATEGY}" --no-tls +exec marketplace-api --port "${PORT}" --css "${CSS_STRATEGY}" diff --git a/examples/app/commerce/package.json b/examples/app/commerce/package.json index f7884a8ae..e14f039d0 100644 --- a/examples/app/commerce/package.json +++ b/examples/app/commerce/package.json @@ -11,7 +11,7 @@ "build:client": "node ../../build-client.mjs", "build:server": "cargo check -p marketplace-api", "start:client": "pnpm run build:deps && node ../../build-client.mjs --watch", - "start:server": "cargo run -p marketplace-api -- --port 3004 --no-tls --css=module", + "start:server": "cargo run -p marketplace-api -- --port 3004 --css=module", "start": "cargo xtask dev commerce", "test": "playwright test", "test:update-snapshots": "playwright test --update-snapshots" diff --git a/examples/app/commerce/server/Cargo.toml b/examples/app/commerce/server/Cargo.toml index 447dc0dec..f1f34205b 100644 --- a/examples/app/commerce/server/Cargo.toml +++ b/examples/app/commerce/server/Cargo.toml @@ -10,7 +10,7 @@ name = "marketplace-api" path = "src/main.rs" [dependencies] -actix-web = { workspace = true, features = ["compress-gzip", "rustls-0_23"] } +actix-web = { workspace = true, features = ["compress-gzip"] } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } @@ -18,8 +18,6 @@ clap = { workspace = true } thiserror = { workspace = true } hmac = { workspace = true } sha2 = { workspace = true } -rustls = { workspace = true } -rcgen = { workspace = true } microsoft-webui = { path = "../../../../crates/webui" } microsoft-webui-handler = { path = "../../../../crates/webui-handler" } bytes = { workspace = true } @@ -28,5 +26,9 @@ tokio = { workspace = true } tokio-stream = { workspace = true } log = { workspace = true } +[dev-dependencies] +h2 = { workspace = true } +http = { workspace = true } + [lints] workspace = true diff --git a/examples/app/commerce/server/src/main.rs b/examples/app/commerce/server/src/main.rs index 46be6845b..85b340397 100644 --- a/examples/app/commerce/server/src/main.rs +++ b/examples/app/commerce/server/src/main.rs @@ -16,10 +16,9 @@ mod security; mod server; mod state; -use actix_web::{web, App, HttpServer}; +use actix_web::{dev::Server, web, App, HttpServer}; use anyhow::{Context, Result}; use clap::Parser; -use rustls::ServerConfig; use crate::app::AppState; use crate::security::security_headers; @@ -41,20 +40,6 @@ struct ApiArgs { #[arg(long)] css_bundle: bool, - /// Disable TLS and serve plain HTTP. Use behind a TLS-terminating - /// reverse proxy (e.g. Azure Container Apps ingress). - #[arg(long)] - no_tls: bool, - - /// Path to TLS certificate PEM file. When omitted a self-signed - /// certificate is generated for localhost development. - #[arg(long)] - tls_cert: Option, - - /// Path to TLS private key PEM file. - #[arg(long)] - tls_key: Option, - /// Base path for sub-path deployment (e.g., `/commerce/`). /// Injected as `basePath` in template state for ``. #[arg(long, default_value = "/")] @@ -84,121 +69,95 @@ fn main() -> Result<()> { ); let state = web::Data::new(app_state); - let no_tls = args.no_tls; - let tls_config = if no_tls { - None - } else { - Some(build_tls_config( - args.tls_cert.as_deref(), - args.tls_key.as_deref(), - )?) - }; actix_web::rt::System::new().block_on(async move { - let server = HttpServer::new(move || { - App::new() - .wrap(actix_web::middleware::Compress::default()) - .wrap(security_headers()) - .app_data(state.clone()) - .configure(configure_app) - }) - .keep_alive(std::time::Duration::from_secs(75)) - .shutdown_timeout(5); - - if let Some(tls) = tls_config { - eprintln!(" HTTPS (HTTP/2) listening on https://localhost:{port}"); - server.bind_rustls_0_23(("0.0.0.0", port), tls)?.run().await - } else { - eprintln!(" HTTP (plain) listening on http://0.0.0.0:{port}"); - server.bind(("0.0.0.0", port))?.run().await - } + let listener = std::net::TcpListener::bind(("0.0.0.0", port))?; + eprintln!(" HTTP/1.1 + HTTP/2 (h2c) listening on http://0.0.0.0:{port}"); + build_server(listener, state)?.await })?; Ok(()) } -/// Build a rustls [`ServerConfig`] for HTTP/2. -/// -/// When `cert_path` and `key_path` are provided, loads PEM files from -/// disk. Otherwise, generates a self-signed certificate for `localhost` -/// using `rcgen` — suitable for local development. -fn build_tls_config(cert_path: Option<&str>, key_path: Option<&str>) -> Result { - let (certs, key) = match (cert_path, key_path) { - (Some(cert), Some(key)) => load_pem_files(cert, key)?, - _ => generate_self_signed()?, - }; - - let config = ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certs, key) - .context("Failed to build TLS config")?; - - Ok(config) -} - -fn load_pem_files( - cert_path: &str, - key_path: &str, -) -> Result<( - Vec>, - rustls::pki_types::PrivateKeyDer<'static>, -)> { - let cert_data = std::fs::read(cert_path) - .with_context(|| format!("Failed to read TLS certificate from {cert_path}"))?; - let key_data = std::fs::read(key_path) - .with_context(|| format!("Failed to read TLS key from {key_path}"))?; - - use rustls::pki_types::pem::PemObject; - use rustls::pki_types::{CertificateDer, PrivateKeyDer}; - - let certs: Vec> = CertificateDer::pem_slice_iter(&cert_data) - .collect::, _>>() - .context("Failed to parse TLS certificate PEM")?; - - let key = PrivateKeyDer::from_pem_slice(&key_data).context("Failed to parse TLS key PEM")?; - - Ok((certs, key)) -} - -fn generate_self_signed() -> Result<( - Vec>, - rustls::pki_types::PrivateKeyDer<'static>, -)> { - let mut params = rcgen::CertificateParams::new(vec!["localhost".to_string()]) - .context("Failed to create certificate params")?; - params - .subject_alt_names - .push(rcgen::SanType::IpAddress(std::net::IpAddr::V4( - std::net::Ipv4Addr::LOCALHOST, - ))); - params - .subject_alt_names - .push(rcgen::SanType::IpAddress(std::net::IpAddr::V6( - std::net::Ipv6Addr::LOCALHOST, - ))); - - let key_pair = rcgen::KeyPair::generate().context("Failed to generate key pair")?; - let cert = params - .self_signed(&key_pair) - .context("Failed to generate self-signed certificate")?; - - let cert_der = cert.der().clone(); - let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der()) - .map_err(|e| anyhow::anyhow!("Failed to serialize private key: {e}"))?; - - eprintln!(" Generated self-signed TLS certificate for localhost"); - - Ok((vec![cert_der], key_der)) +/// Serve cleartext HTTP/1.1 and HTTP/2 prior-knowledge (h2c) on `listener`. +fn build_server( + listener: std::net::TcpListener, + state: web::Data, +) -> std::io::Result { + Ok(HttpServer::new(move || { + App::new() + .wrap(actix_web::middleware::Compress::default()) + .wrap(security_headers()) + .app_data(state.clone()) + .configure(configure_app) + }) + .keep_alive(std::time::Duration::from_secs(75)) + .shutdown_timeout(5) + .listen_auto_h2c(listener)? + .run()) } #[cfg(test)] mod tests { - use super::ApiArgs; + use super::{build_server, ApiArgs}; + use actix_web::{dev::ServerHandle, web}; + use anyhow::{Context, Result}; use clap::Parser; + use std::path::Path; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[test] fn parses_custom_port() { let args = ApiArgs::parse_from(["marketplace-api", "--port", "4001"]); assert_eq!(args.port, 4001); } + + fn start_server() -> Result<(u16, ServerHandle)> { + let commerce_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .context("commerce server crate must have a parent directory")?; + let app_state = + crate::app::AppState::load(commerce_root, webui::CssStrategy::Module, false, "/")?; + let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + let server = build_server(listener, web::Data::new(app_state))?; + let handle = server.handle(); + actix_web::rt::spawn(server); + Ok((port, handle)) + } + + #[actix_web::test] + async fn serves_http2_prior_knowledge_over_cleartext() -> Result<()> { + let (port, handle) = start_server()?; + + let tcp = actix_web::rt::net::TcpStream::connect(("127.0.0.1", port)).await?; + let (mut client, connection) = h2::client::handshake(tcp).await?; + let connection_task = actix_web::rt::spawn(connection); + let request = http::Request::builder() + .uri(format!("http://127.0.0.1:{port}/")) + .body(())?; + let (response, _) = client.send_request(request, true)?; + let response = response.await?; + assert!(response.status().is_success()); + assert_eq!(response.version(), http::Version::HTTP_2); + + connection_task.abort(); + handle.stop(true).await; + Ok(()) + } + + #[actix_web::test] + async fn serves_http1_over_cleartext() -> Result<()> { + let (port, handle) = start_server()?; + + let mut tcp = actix_web::rt::net::TcpStream::connect(("127.0.0.1", port)).await?; + tcp.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") + .await?; + let mut status_line = [0_u8; 12]; + tcp.read_exact(&mut status_line).await?; + assert_eq!(&status_line, b"HTTP/1.1 200"); + + handle.stop(true).await; + Ok(()) + } } diff --git a/examples/demo/server/Cargo.toml b/examples/demo/server/Cargo.toml index f9a3e0a20..32266a7c9 100644 --- a/examples/demo/server/Cargo.toml +++ b/examples/demo/server/Cargo.toml @@ -11,7 +11,8 @@ path = "src/main.rs" [dependencies] actix-web = { workspace = true } -awc = { workspace = true } +reqwest = { workspace = true } +futures-util = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/examples/demo/server/src/main.rs b/examples/demo/server/src/main.rs index e9baaa8f8..6757cc078 100644 --- a/examples/demo/server/src/main.rs +++ b/examples/demo/server/src/main.rs @@ -59,6 +59,7 @@ async fn main() -> anyhow::Result<()> { let proxy_state = web::Data::new(proxy::ProxyState { routes }); let apps_data = web::Data::new(apps.clone()); let shell_data = web::Data::new(shell_state); + let proxy_client = web::Data::new(proxy::build_client()?); // Spawn all child app servers log::info!("Starting app servers…"); @@ -70,15 +71,11 @@ async fn main() -> anyhow::Result<()> { log::info!("Starting demo shell on port {}", args.port); let result = HttpServer::new(move || { - let client = awc::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .finish(); - App::new() .app_data(proxy_state.clone()) .app_data(apps_data.clone()) .app_data(shell_data.clone()) - .app_data(web::Data::new(client)) + .app_data(proxy_client.clone()) // Shell UI (rendered from examples/demo/src via WebUIHandler) .route("/", web::get().to(shell::shell_page)) // Shell static assets (bundled JS, sourcemaps, etc.) diff --git a/examples/demo/server/src/proxy.rs b/examples/demo/server/src/proxy.rs index 15e644c21..2a661d742 100644 --- a/examples/demo/server/src/proxy.rs +++ b/examples/demo/server/src/proxy.rs @@ -4,12 +4,13 @@ use actix_web::{ body::{BodySize, BodyStream, MessageBody}, http::{ - header::{self, HeaderMap, HeaderName}, + header::{self, HeaderMap, HeaderName, HeaderValue}, StatusCode, }, web, HttpRequest, HttpResponse, HttpResponseBuilder, }; -use awc::Client; +use futures_util::StreamExt; +use reqwest::Client; use std::{ collections::HashMap, error::Error as StdError, @@ -20,6 +21,14 @@ use std::{ // Maximum response body size (16 MB) — generous for HTML/JSON/assets. const MAX_BODY_SIZE: usize = 16 * 1024 * 1024; +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +pub(crate) fn build_client() -> Result { + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() +} struct LimitedBody { body: B, @@ -140,29 +149,31 @@ pub(crate) async fn proxy_handler( // Preserve upstream content codings byte-for-byte. The proxy streams the // encoded body and matching Content-Encoding header instead of risking a // decoder pass-through for an unsupported or stacked encoding. - let mut forwarded = client - .request(req.method().clone(), &target_url) - .no_decompress(); - - // Copy relevant headers (skip Host — we set our own) - for (name, value) in req.headers() { - if name != "host" { - forwarded = forwarded.insert_header((name.clone(), value.clone())); + let method = match reqwest::Method::from_bytes(req.method().as_str().as_bytes()) { + Ok(method) => method, + Err(error) => { + return HttpResponse::BadRequest().body(format!("Invalid proxy method: {error}")); } - } + }; + let mut forwarded = client.request(method, &target_url); - // Add forwarding headers + forwarded = match copy_request_headers(forwarded, req.headers()) { + Ok(forwarded) => forwarded, + Err(error) => return HttpResponse::BadGateway().body(error), + }; + + // Add forwarding headers derived from this connection. No trusted-proxy + // configuration exists here, so inbound forwarding metadata is untrusted. if let Some(peer) = req.peer_addr() { - forwarded = forwarded.insert_header(("X-Forwarded-For", peer.ip().to_string())); + forwarded = forwarded.header("X-Forwarded-For", peer.ip().to_string()); } if let Some(host) = req.headers().get("host") { - forwarded = - forwarded.insert_header(("X-Forwarded-Host", host.to_str().unwrap_or_default())); + forwarded = forwarded.header("X-Forwarded-Host", host.as_bytes()); } - forwarded = forwarded.insert_header(("X-Forwarded-Proto", req.connection_info().scheme())); + forwarded = forwarded.header("X-Forwarded-Proto", req.connection_info().scheme()); // Send the request - let response = match forwarded.send_body(body).await { + let response = match forwarded.body(body).send().await { Ok(r) => r, Err(e) => { log::error!("Proxy error for {slug} → {target_url}: {e}"); @@ -170,7 +181,24 @@ pub(crate) async fn proxy_handler( } }; - if known_content_length_exceeds(response.headers(), MAX_BODY_SIZE) { + let status = match StatusCode::from_u16(response.status().as_u16()) { + Ok(status) => status, + Err(error) => { + log::error!("Proxy response for {slug} has invalid status: {error}"); + return HttpResponse::BadGateway() + .body(format!("App '{slug}' returned an invalid HTTP status")); + } + }; + let response_headers = match convert_response_headers(response.headers()) { + Ok(headers) => headers, + Err(error) => { + log::error!("Proxy response for {slug} has invalid headers: {error}"); + return HttpResponse::BadGateway() + .body(format!("App '{slug}' returned invalid HTTP headers")); + } + }; + + if known_content_length_exceeds(&response_headers, MAX_BODY_SIZE) { log::error!( "Proxy response for {slug} exceeds the {MAX_BODY_SIZE} byte limit declared by Content-Length" ); @@ -180,9 +208,57 @@ pub(crate) async fn proxy_handler( } // Build the response back to the client. - let status = response.status(); - let mut builder = proxy_response_builder(status, response.headers()); - builder.body(LimitedBody::new(BodyStream::new(response), MAX_BODY_SIZE)) + let mut builder = proxy_response_builder(status, &response_headers); + builder.body(LimitedBody::new( + BodyStream::new( + response + .bytes_stream() + .map(|chunk| chunk.map_err(|error| std::io::Error::other(error.to_string()))), + ), + MAX_BODY_SIZE, + )) +} + +fn copy_request_headers( + mut forwarded: reqwest::RequestBuilder, + headers: &HeaderMap, +) -> Result { + for (name, value) in headers { + if name != "host" && !is_forwarding_header(name) { + let forwarded_name = + match reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()) { + Ok(name) => name, + Err(error) => { + return Err(format!("Invalid proxy request header name: {error}")); + } + }; + let forwarded_value = match reqwest::header::HeaderValue::from_bytes(value.as_bytes()) { + Ok(value) => value, + Err(error) => { + return Err(format!("Invalid proxy request header value: {error}")); + } + }; + forwarded = forwarded.header(forwarded_name, forwarded_value); + } + } + + Ok(forwarded) +} + +fn is_forwarding_header(name: &HeaderName) -> bool { + name == "forwarded" || name.as_str().starts_with("x-forwarded-") +} + +fn convert_response_headers(headers: &reqwest::header::HeaderMap) -> Result { + let mut converted = HeaderMap::with_capacity(headers.len()); + for (name, value) in headers { + let name = HeaderName::from_bytes(name.as_str().as_bytes()) + .map_err(|error| format!("invalid header name: {error}"))?; + let value = HeaderValue::from_bytes(value.as_bytes()) + .map_err(|error| format!("invalid header value: {error}"))?; + converted.append(name, value); + } + Ok(converted) } fn known_content_length_exceeds(headers: &HeaderMap, limit: usize) -> bool { @@ -256,8 +332,20 @@ pub(crate) async fn slug_redirect( #[cfg(test)] mod tests { use super::*; - use actix_web::http::header::HeaderValue; - use std::{cell::Cell, collections::VecDeque, future::poll_fn, rc::Rc}; + use std::{ + cell::Cell, + collections::VecDeque, + future::poll_fn, + rc::Rc, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + }; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; fn proxy_response( status: StatusCode, @@ -472,6 +560,105 @@ mod tests { ); } + #[actix_web::test] + async fn relays_redirect_status_and_location_without_following() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + let server_hits = Arc::clone(&hits); + let server = actix_web::rt::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0_u8; 1024]; + let read = stream.read(&mut request).await.unwrap(); + server_hits.fetch_add(1, Ordering::Relaxed); + + let response = if request[..read].starts_with(b"GET /destination ") { + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + } else { + "HTTP/1.1 302 Found\r\nLocation: /destination\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + }; + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + + let upstream = build_client() + .unwrap() + .get(format!("http://{address}/redirect")) + .send() + .await + .unwrap(); + let status = StatusCode::from_u16(upstream.status().as_u16()).unwrap(); + let headers = convert_response_headers(upstream.headers()).unwrap(); + let response = proxy_response( + status, + &headers, + TestBody::new([], Rc::new(Cell::new(0))), + 1024, + ); + + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "/destination" + ); + assert_eq!(hits.load(Ordering::Relaxed), 1); + + server.abort(); + let _ = server.await; + } + + #[test] + fn replaces_untrusted_forwarding_headers_with_proxy_values() { + let mut incoming = HeaderMap::new(); + incoming.insert( + HeaderName::from_static("x-forwarded-for"), + HeaderValue::from_static("198.51.100.7, 203.0.113.9"), + ); + incoming.insert( + HeaderName::from_static("x-forwarded-host"), + HeaderValue::from_static("attacker.example"), + ); + incoming.insert( + HeaderName::from_static("x-forwarded-proto"), + HeaderValue::from_static("https"), + ); + incoming.insert( + HeaderName::from_static("x-forwarded-prefix"), + HeaderValue::from_static("/spoofed"), + ); + incoming.insert( + HeaderName::from_static("forwarded"), + HeaderValue::from_static("for=198.51.100.7;proto=https;host=attacker.example"), + ); + incoming.insert( + HeaderName::from_static("x-demo-header"), + HeaderValue::from_static("preserved"), + ); + + let client = Client::new(); + let forwarded = copy_request_headers(client.get("http://127.0.0.1/"), &incoming) + .unwrap() + .header("X-Forwarded-For", "192.0.2.42") + .header("X-Forwarded-Host", "demo.example") + .header("X-Forwarded-Proto", "http") + .build() + .unwrap(); + let headers = forwarded.headers(); + + assert_eq!( + headers.get("x-forwarded-for").unwrap(), + "192.0.2.42", + "client-supplied identity must not precede the proxy identity" + ); + assert_eq!(headers.get_all("x-forwarded-for").iter().count(), 1); + assert_eq!(headers.get("x-forwarded-host").unwrap(), "demo.example"); + assert_eq!(headers.get("x-forwarded-proto").unwrap(), "http"); + assert!(!headers.contains_key("x-forwarded-prefix")); + assert!(!headers.contains_key("forwarded")); + assert_eq!(headers.get("x-demo-header").unwrap(), "preserved"); + } + #[test] fn strips_hop_by_hop_and_body_framing_headers() { let mut headers = HeaderMap::new();