From 6140be926264bc305c563e7b959e49ed7bcedee9 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 13 Oct 2026 10:25:09 -0700 Subject: [PATCH 01/12] fix: replace unapproved crypto dependencies with platform-native TLS Remove the ring, sha1, Rustls, AWS-LC, and rcgen dependency paths flagged by the crypto compliance scanner across the workspace. Replace the awc HTTP client with reqwest configured for native-tls, which wraps the approved platform libraries (SChannel on Windows, OpenSSL on Linux, Secure Transport on macOS). Disable Actix Web default features so the WebSocket upgrade path no longer pulls in sha1, and move the commerce example from Rustls plus generated self-signed certificates to caller-supplied certificate and PKCS#8 key PEM files served through the native-tls acceptor. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- Cargo.lock | 936 ++++++++---------- Cargo.toml | 21 +- crates/webui-cli/Cargo.toml | 2 +- crates/webui-cli/src/commands/serve.rs | 73 +- .../src/commands/serve/streaming_api.rs | 17 +- crates/webui/Cargo.toml | 2 +- .../examples/streaming_e2e_ttfb_bench.rs | 9 +- deny.toml | 5 +- examples/app/commerce/README.md | 6 + examples/app/commerce/server/Cargo.toml | 10 +- examples/app/commerce/server/src/main.rs | 171 ++-- examples/demo/server/Cargo.toml | 3 +- examples/demo/server/src/main.rs | 11 +- examples/demo/server/src/proxy.rs | 85 +- 14 files changed, 648 insertions(+), 703 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 53336705a..1b359a6b2 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", @@ -45,12 +43,9 @@ dependencies = [ "httpdate", "itoa", "language-tags", - "local-channel", "mime", "percent-encoding", "pin-project-lite", - "rand 0.10.2", - "sha1", "smallvec", "tokio", "tokio-util", @@ -129,13 +124,10 @@ dependencies = [ "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-native-tls", "tokio-util", "tracing", ] @@ -163,7 +155,6 @@ dependencies = [ "actix-rt", "actix-server", "actix-service", - "actix-tls", "actix-utils", "actix-web-codegen", "bytes", @@ -307,45 +298,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 +321,28 @@ dependencies = [ ] [[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "awc" -version = "3.8.2" +name = "atomic-waker" +version = "1.1.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", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[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 +353,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 +516,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 +583,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" @@ -794,6 +673,22 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "core_detect" version = "1.0.0" @@ -928,12 +823,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 +860,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 +939,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" @@ -1214,6 +1084,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1223,12 +1108,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 +1262,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 +1270,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", + "r-efi", ] [[package]] @@ -1702,6 +1557,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 +1601,65 @@ 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-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[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 +1818,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 +1926,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 +1967,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 +2001,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" @@ -2100,6 +2026,10 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" name = "marketplace-api" version = "3.0.1" dependencies = [ + "actix-http", + "actix-server", + "actix-service", + "actix-tls", "actix-web", "anyhow", "bytes", @@ -2109,13 +2039,13 @@ dependencies = [ "microsoft-webui", "microsoft-webui-handler", "mime_guess", - "rcgen", - "rustls", + "native-tls", "serde", "serde_json", "sha2", "thiserror", "tokio", + "tokio-native-tls", "tokio-stream", ] @@ -2139,7 +2069,6 @@ name = "microsoft-webui" version = "3.0.1" dependencies = [ "actix-web", - "awc", "bytes", "crc32fast", "criterion", @@ -2154,6 +2083,7 @@ dependencies = [ "microsoft-webui-protocol", "microsoft-webui-tokens", "rayon", + "reqwest", "serde", "serde_json", "sha2", @@ -2170,7 +2100,6 @@ version = "3.0.1" dependencies = [ "actix-web", "anyhow", - "awc", "bytes", "clap", "console", @@ -2185,6 +2114,7 @@ dependencies = [ "microsoft-webui-protocol", "microsoft-webui-tokens", "mime_guess", + "reqwest", "serde", "serde_json", "tempfile", @@ -2199,7 +2129,7 @@ name = "microsoft-webui-desktop" version = "3.0.1" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "block2", "clap", "console", @@ -2208,7 +2138,7 @@ dependencies = [ "futures-channel", "futures-executor", "futures-util", - "getrandom 0.4.3", + "getrandom", "gtk4", "libc", "microsoft-webui", @@ -2462,12 +2392,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,13 +2512,30 @@ version = "0.1.0" dependencies = [ "anyhow", "futures-executor", - "getrandom 0.4.3", + "getrandom", "microsoft-webui-desktop", "microsoft-webui-handler", "microsoft-webui-tokens", "serde_json", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.31.3" @@ -2613,16 +2554,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" @@ -2663,30 +2594,11 @@ dependencies = [ ] [[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" +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 +2692,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" @@ -2829,6 +2732,49 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "page_size" version = "0.6.0" @@ -2885,16 +2831,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 +2957,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 +3112,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 +3138,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 +3183,39 @@ 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-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", ] [[package]] @@ -3350,15 +3233,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,21 +3246,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3396,18 +3255,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -3429,12 +3276,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -3516,17 +3395,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 +3516,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 +3538,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 +3621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3863,12 +3734,12 @@ dependencies = [ ] [[package]] -name = "tokio-rustls" -version = "0.26.5" +name = "tokio-native-tls" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ - "rustls", + "native-tls", "tokio", ] @@ -3972,6 +3843,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 +3920,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 +3977,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" @@ -4085,6 +4001,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4108,19 +4030,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 +4057,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 +4099,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 +4351,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 +4369,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 +4376,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 +4395,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 +4467,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 +4500,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" @@ -4728,9 +4566,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 373da94da..441f88ba1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,10 +40,23 @@ 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.45" -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", +] } +actix-http = "3.13.6" +actix-server = "2.6.0" +actix-service = "2.0.3" +actix-tls = { version = "3.6.0", default-features = false, features = ["accept", "native-tls"] } +native-tls = "0.2.18" +tokio-native-tls = "0.3.1" +reqwest = { version = "0.13.5", default-features = false, features = ["native-tls", "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 da8b24d2e..6950857e5 100644 --- a/crates/webui-cli/Cargo.toml +++ b/crates/webui-cli/Cargo.toml @@ -29,7 +29,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..f0787ca43 100644 --- a/crates/webui-cli/src/commands/serve.rs +++ b/crates/webui-cli/src/commands/serve.rs @@ -878,16 +878,16 @@ fn request_paths(req: &HttpRequest) -> RequestPaths { /// 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(); + let client = reqwest::Client::new(); let url = format!("http://127.0.0.1:{api_port}{path}"); - let mut resp = client + let resp = client .get(&url) - .insert_header(("Accept", "application/json")) + .header(reqwest::header::ACCEPT, "application/json") .send() .await .map_err(|e| format!("API proxy error: {e}"))?; let body = resp - .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}"))?; @@ -978,15 +978,22 @@ async fn render_page_response( 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 client = reqwest::Client::new(); let url = format!("http://127.0.0.1:{api_port}{request_path}"); match client .get(&url) - .insert_header(("Accept", streaming_api::ACCEPT)) + .header(reqwest::header::ACCEPT, streaming_api::ACCEPT) .send() .await { - Ok(response) if streaming_api::is_stream(response.headers()) => { + 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 +1007,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,7 +1026,7 @@ async fn render_page_response( .await; } } - Ok(mut response) => match response.body().await { + Ok(response) => match response.bytes().await { Ok(body) => match parse_api_state(&body) { Ok(state) => state, Err(error) => { @@ -1634,28 +1643,50 @@ async fn handle_api_proxy( 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 client = reqwest::Client::new(); + let mut proxy_req = 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 = proxy_req.body(body).send().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 resp.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) } 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 a8aceaa7b..4102c37ec 100644 --- a/crates/webui/Cargo.toml +++ b/crates/webui/Cargo.toml @@ -42,7 +42,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.1" } 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..e62fea001 100644 --- a/examples/app/commerce/README.md +++ b/examples/app/commerce/README.md @@ -33,6 +33,12 @@ pnpm start Then open http://127.0.0.1:3004 +The commerce server does not generate certificates. For direct HTTPS +development, provide a certificate and PKCS#8 private key PEM with `--tls-cert` +and `--tls-key`; otherwise use `--no-tls` behind a TLS-terminating proxy or +ingress. TLS uses the platform-native implementation through `native-tls` +(SChannel on Windows, OpenSSL on Linux, or Secure Transport on macOS). + ## Running Tests ```bash diff --git a/examples/app/commerce/server/Cargo.toml b/examples/app/commerce/server/Cargo.toml index d4f40057d..cd1ac86a3 100644 --- a/examples/app/commerce/server/Cargo.toml +++ b/examples/app/commerce/server/Cargo.toml @@ -9,7 +9,11 @@ name = "marketplace-api" path = "src/main.rs" [dependencies] -actix-web = { workspace = true, features = ["compress-gzip", "rustls-0_23"] } +actix-http = { workspace = true } +actix-server = { workspace = true } +actix-service = { workspace = true } +actix-tls = { workspace = true } +actix-web = { workspace = true, features = ["compress-gzip"] } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } @@ -17,8 +21,8 @@ clap = { workspace = true } thiserror = { workspace = true } hmac = { workspace = true } sha2 = { workspace = true } -rustls = { workspace = true } -rcgen = { workspace = true } +native-tls = { workspace = true } +tokio-native-tls = { workspace = true } microsoft-webui = { path = "../../../../crates/webui" } microsoft-webui-handler = { path = "../../../../crates/webui-handler" } bytes = { workspace = true } diff --git a/examples/app/commerce/server/src/main.rs b/examples/app/commerce/server/src/main.rs index 46be6845b..bf9335d7f 100644 --- a/examples/app/commerce/server/src/main.rs +++ b/examples/app/commerce/server/src/main.rs @@ -16,10 +16,14 @@ mod security; mod server; mod state; -use actix_web::{web, App, HttpServer}; +use actix_http::{HttpService, Protocol}; +use actix_server::Server; +use actix_service::{fn_service, map_config, IntoServiceFactory, ServiceFactoryExt}; +use actix_tls::accept::native_tls::{reexports::TlsAcceptor, Acceptor as NativeTlsAcceptor}; +use actix_web::{dev::AppConfig, web, App, HttpServer}; use anyhow::{Context, Result}; use clap::Parser; -use rustls::ServerConfig; +use native_tls::Identity; use crate::app::AppState; use crate::security::security_headers; @@ -46,12 +50,11 @@ struct ApiArgs { #[arg(long)] no_tls: bool, - /// Path to TLS certificate PEM file. When omitted a self-signed - /// certificate is generated for localhost development. + /// Path to a TLS certificate PEM file. Required unless --no-tls is set. #[arg(long)] tls_cert: Option, - /// Path to TLS private key PEM file. + /// Path to a TLS private key PEM file. Required unless --no-tls is set. #[arg(long)] tls_key: Option, @@ -93,22 +96,61 @@ fn main() -> Result<()> { args.tls_key.as_deref(), )?) }; + let tls_acceptor = tls_config + .map(native_tls::TlsAcceptor::new) + .transpose() + .context("Failed to create TLS acceptor")?; 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 + if let Some(tls) = tls_acceptor { + eprintln!(" HTTPS listening on https://localhost:{port}"); + let tls_acceptor = TlsAcceptor::from(tls); + let native_tls_acceptor = NativeTlsAcceptor::new(tls_acceptor); + let server_state = state.clone(); + + Server::build() + .bind("marketplace-api", ("0.0.0.0", port), move || { + let factory = App::new() + .wrap(actix_web::middleware::Compress::default()) + .wrap(security_headers()) + .app_data(server_state.clone()) + .configure(configure_app) + .into_factory() + .map_err(|error: actix_web::Error| error.error_response()); + + let http_service = HttpService::build() + .keep_alive(std::time::Duration::from_secs(75)) + .secure() + .finish(map_config(factory, move |_| { + AppConfig::__priv_test_new( + true, + format!("localhost:{port}"), + std::net::SocketAddr::from(([0, 0, 0, 0], port)), + ) + })) + .map_err(|_| ()); + + native_tls_acceptor + .clone() + .map_err(|_| ()) + .and_then(fn_service(|stream| async move { + Ok::<_, ()>((stream, Protocol::Http1, None)) + })) + .and_then(http_service) + })? + .run() + .await } else { + 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); + eprintln!(" HTTP (plain) listening on http://0.0.0.0:{port}"); server.bind(("0.0.0.0", port))?.run().await } @@ -117,83 +159,32 @@ fn main() -> Result<()> { 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) { +/// Build a platform-native TLS identity from caller-provided PEM files. +fn build_tls_config(cert_path: Option<&str>, key_path: Option<&str>) -> Result { + let (certificate, key) = match (cert_path, key_path) { (Some(cert), Some(key)) => load_pem_files(cert, key)?, - _ => generate_self_signed()?, + (None, None) => anyhow::bail!( + "TLS certificate and key are required unless --no-tls is set; \ + provide --tls-cert and --tls-key " + ), + _ => anyhow::bail!("--tls-cert and --tls-key must be provided together, or use --no-tls"), }; - let config = ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certs, key) - .context("Failed to build TLS config")?; - - Ok(config) + Identity::from_pkcs8(&certificate, &key) + .context("Failed to build platform-native TLS identity; the private key must be PKCS#8 PEM") } -fn load_pem_files( - cert_path: &str, - key_path: &str, -) -> Result<( - Vec>, - rustls::pki_types::PrivateKeyDer<'static>, -)> { +fn load_pem_files(cert_path: &str, key_path: &str) -> Result<(Vec, Vec)> { 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)) + Ok((cert_data, key_data)) } - #[cfg(test)] mod tests { - use super::ApiArgs; + use super::{build_tls_config, ApiArgs}; use clap::Parser; #[test] @@ -201,4 +192,20 @@ mod tests { let args = ApiArgs::parse_from(["marketplace-api", "--port", "4001"]); assert_eq!(args.port, 4001); } + + #[test] + fn requires_certificate_and_key_for_tls() { + let error = build_tls_config(None, None) + .err() + .expect("TLS configuration without certificate inputs must fail"); + assert!(error.to_string().contains("--no-tls")); + } + + #[test] + fn rejects_partial_certificate_inputs() { + let error = build_tls_config(Some("certificate.pem"), None) + .err() + .expect("TLS configuration with only one PEM path must fail"); + assert!(error.to_string().contains("provided together")); + } } diff --git a/examples/demo/server/Cargo.toml b/examples/demo/server/Cargo.toml index 90a2648a5..3c3be11d5 100644 --- a/examples/demo/server/Cargo.toml +++ b/examples/demo/server/Cargo.toml @@ -10,7 +10,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..725200d2b 100644 --- a/examples/demo/server/src/main.rs +++ b/examples/demo/server/src/main.rs @@ -59,6 +59,11 @@ 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( + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?, + ); // Spawn all child app servers log::info!("Starting app servers…"); @@ -70,15 +75,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..7afb35d4c 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, @@ -140,29 +141,47 @@ 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(); + 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); // 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 forwarded_name = + match reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()) { + Ok(name) => name, + Err(error) => { + return HttpResponse::BadGateway() + .body(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 HttpResponse::BadGateway() + .body(format!("Invalid proxy request header value: {error}")); + } + }; + forwarded = forwarded.header(forwarded_name, forwarded_value); } } // Add forwarding headers 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 +189,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 +216,27 @@ 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 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,7 +310,6 @@ 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}; fn proxy_response( From 136c571d80adcc5bf40313e6f2481b45bc77059b Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 13 Oct 2026 11:03:09 -0700 Subject: [PATCH 02/12] fix: install OpenSSL for Linux ARM64 desktop builds Install the target libssl development package in the existing Linux ARM64 cross-build setup and verify pkg-config resolves openssl from the target directory. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- scripts/setup-linux-desktop-target.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/setup-linux-desktop-target.sh b/scripts/setup-linux-desktop-target.sh index 587a0722d..cab8c9754 100644 --- a/scripts/setup-linux-desktop-target.sh +++ b/scripts/setup-linux-desktop-target.sh @@ -49,7 +49,7 @@ EOF sudo apt-get "${apt_options[@]}" update -q sudo apt-get "${apt_options[@]}" install -y -q \ protobuf-compiler pkg-config gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \ - libgtk-4-dev:arm64 libwebkitgtk-6.0-dev:arm64 + libgtk-4-dev:arm64 libwebkitgtk-6.0-dev:arm64 libssl-dev:arm64 rm "$sources" export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc @@ -57,7 +57,7 @@ EOF export PKG_CONFIG_ALLOW_CROSS=1 unset PKG_CONFIG_PATH PKG_CONFIG_SYSROOT_DIR export PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig - for pc in gtk4 webkitgtk-6.0; do + for pc in gtk4 webkitgtk-6.0 openssl; do pcdir="$(pkg-config --variable=pcfiledir "$pc")" if [[ "$pcdir" != /usr/lib/aarch64-linux-gnu/pkgconfig ]]; then echo "$pc resolved from unexpected directory: $pcdir" >&2 From 6dafb96c55bf7705a378fa38a37f2a5fcc9fb987 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 13 Oct 2026 12:07:53 -0700 Subject: [PATCH 03/12] fix: preserve HTTP/2 with native TLS Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- Cargo.toml | 2 +- examples/app/commerce/README.md | 5 ++ examples/app/commerce/server/src/main.rs | 79 ++++++++++++++++++------ 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 441f88ba1..148b66f51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ actix-http = "3.13.6" actix-server = "2.6.0" actix-service = "2.0.3" actix-tls = { version = "3.6.0", default-features = false, features = ["accept", "native-tls"] } -native-tls = "0.2.18" +native-tls = { version = "0.2.18", features = ["alpn", "alpn-accept"] } tokio-native-tls = "0.3.1" reqwest = { version = "0.13.5", default-features = false, features = ["native-tls", "stream"] } hmac = "0.13.0" diff --git a/examples/app/commerce/README.md b/examples/app/commerce/README.md index e62fea001..708b6b09b 100644 --- a/examples/app/commerce/README.md +++ b/examples/app/commerce/README.md @@ -39,6 +39,11 @@ and `--tls-key`; otherwise use `--no-tls` behind a TLS-terminating proxy or ingress. TLS uses the platform-native implementation through `native-tls` (SChannel on Windows, OpenSSL on Linux, or Secure Transport on macOS). +Direct HTTPS advertises HTTP/2 and HTTP/1.1 through ALPN on Windows and Linux. +Apple's native TLS backend does not support server-side ALPN, so macOS +development must use `--no-tls` behind an HTTP/2 TLS-terminating proxy rather +than silently reducing direct HTTPS to HTTP/1.1. + ## Running Tests ```bash diff --git a/examples/app/commerce/server/src/main.rs b/examples/app/commerce/server/src/main.rs index bf9335d7f..eeec2ba08 100644 --- a/examples/app/commerce/server/src/main.rs +++ b/examples/app/commerce/server/src/main.rs @@ -23,7 +23,7 @@ use actix_tls::accept::native_tls::{reexports::TlsAcceptor, Acceptor as NativeTl use actix_web::{dev::AppConfig, web, App, HttpServer}; use anyhow::{Context, Result}; use clap::Parser; -use native_tls::Identity; +use native_tls::{Identity, TlsAcceptor as PlatformTlsAcceptor}; use crate::app::AppState; use crate::security::security_headers; @@ -88,22 +88,18 @@ fn main() -> Result<()> { let state = web::Data::new(app_state); let no_tls = args.no_tls; - let tls_config = if no_tls { + let tls_acceptor = if no_tls { None } else { - Some(build_tls_config( + Some(build_tls_acceptor( args.tls_cert.as_deref(), args.tls_key.as_deref(), )?) }; - let tls_acceptor = tls_config - .map(native_tls::TlsAcceptor::new) - .transpose() - .context("Failed to create TLS acceptor")?; actix_web::rt::System::new().block_on(async move { if let Some(tls) = tls_acceptor { - eprintln!(" HTTPS listening on https://localhost:{port}"); + eprintln!(" HTTPS (HTTP/2) listening on https://localhost:{port}"); let tls_acceptor = TlsAcceptor::from(tls); let native_tls_acceptor = NativeTlsAcceptor::new(tls_acceptor); let server_state = state.clone(); @@ -133,9 +129,14 @@ fn main() -> Result<()> { native_tls_acceptor .clone() .map_err(|_| ()) - .and_then(fn_service(|stream| async move { - Ok::<_, ()>((stream, Protocol::Http1, None)) - })) + .and_then(fn_service( + |stream: actix_tls::accept::native_tls::TlsStream< + actix_web::rt::net::TcpStream, + >| async move { + let alpn = stream.get_ref().negotiated_alpn().map_err(|_| ())?; + Ok::<_, ()>((stream, protocol_from_alpn(alpn.as_deref()), None)) + }, + )) .and_then(http_service) })? .run() @@ -159,8 +160,11 @@ fn main() -> Result<()> { Ok(()) } -/// Build a platform-native TLS identity from caller-provided PEM files. -fn build_tls_config(cert_path: Option<&str>, key_path: Option<&str>) -> Result { +/// Build a platform-native TLS acceptor from caller-provided PEM files. +fn build_tls_acceptor( + cert_path: Option<&str>, + key_path: Option<&str>, +) -> Result { let (certificate, key) = match (cert_path, key_path) { (Some(cert), Some(key)) => load_pem_files(cert, key)?, (None, None) => anyhow::bail!( @@ -170,8 +174,35 @@ fn build_tls_config(cert_path: Option<&str>, key_path: Option<&str>) -> Result anyhow::bail!("--tls-cert and --tls-key must be provided together, or use --no-tls"), }; - Identity::from_pkcs8(&certificate, &key) - .context("Failed to build platform-native TLS identity; the private key must be PKCS#8 PEM") + let identity = Identity::from_pkcs8(&certificate, &key).context( + "Failed to build platform-native TLS identity; the private key must be PKCS#8 PEM", + )?; + + #[cfg(target_vendor = "apple")] + { + let _ = identity; + anyhow::bail!( + "Direct HTTPS is unavailable on Apple platforms because native-tls does not support \ + server-side ALPN there; use --no-tls behind an HTTP/2 TLS-terminating proxy" + ); + } + + #[cfg(not(target_vendor = "apple"))] + { + let mut builder = PlatformTlsAcceptor::builder(identity); + builder.accept_alpn(&["h2", "http/1.1"]); + builder + .build() + .context("Failed to create platform-native TLS acceptor") + } +} + +fn protocol_from_alpn(alpn: Option<&[u8]>) -> Protocol { + if alpn == Some(b"h2") { + Protocol::Http2 + } else { + Protocol::Http1 + } } fn load_pem_files(cert_path: &str, key_path: &str) -> Result<(Vec, Vec)> { @@ -184,7 +215,8 @@ fn load_pem_files(cert_path: &str, key_path: &str) -> Result<(Vec, Vec)> } #[cfg(test)] mod tests { - use super::{build_tls_config, ApiArgs}; + use super::{build_tls_acceptor, protocol_from_alpn, ApiArgs}; + use actix_http::Protocol; use clap::Parser; #[test] @@ -195,7 +227,7 @@ mod tests { #[test] fn requires_certificate_and_key_for_tls() { - let error = build_tls_config(None, None) + let error = build_tls_acceptor(None, None) .err() .expect("TLS configuration without certificate inputs must fail"); assert!(error.to_string().contains("--no-tls")); @@ -203,9 +235,20 @@ mod tests { #[test] fn rejects_partial_certificate_inputs() { - let error = build_tls_config(Some("certificate.pem"), None) + let error = build_tls_acceptor(Some("certificate.pem"), None) .err() .expect("TLS configuration with only one PEM path must fail"); assert!(error.to_string().contains("provided together")); } + + #[test] + fn selects_http2_for_h2_alpn() { + assert_eq!(protocol_from_alpn(Some(b"h2")), Protocol::Http2); + } + + #[test] + fn selects_http1_without_h2_alpn() { + assert_eq!(protocol_from_alpn(Some(b"http/1.1")), Protocol::Http1); + assert_eq!(protocol_from_alpn(None), Protocol::Http1); + } } From 1db6005fbdd4473a7b6c9805cd68f1d49cb96008 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 13 Oct 2026 13:41:18 -0700 Subject: [PATCH 04/12] fix: preserve HTTPS shutdown timeout Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- examples/app/commerce/server/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/app/commerce/server/src/main.rs b/examples/app/commerce/server/src/main.rs index eeec2ba08..4f93d6a89 100644 --- a/examples/app/commerce/server/src/main.rs +++ b/examples/app/commerce/server/src/main.rs @@ -105,6 +105,7 @@ fn main() -> Result<()> { let server_state = state.clone(); Server::build() + .shutdown_timeout(5) .bind("marketplace-api", ("0.0.0.0", port), move || { let factory = App::new() .wrap(actix_web::middleware::Compress::default()) From d04f3a890b66165b58eb0c376873cdec55f4cbd7 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 13 Oct 2026 14:25:30 -0700 Subject: [PATCH 05/12] fix: preserve HTTP client and HTTPS behavior Reuse fallibly constructed Reqwest clients with redirect following disabled, restore bounded response buffering, and preserve reverse-proxy semantics. Complete native-TLS packaging and commerce HTTP/2 coverage across supported platforms while keeping documentation and E2E instructions aligned. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- Cargo.lock | 24 +- Cargo.toml | 3 +- crates/webui-cli/src/commands/serve.rs | 229 ++++++++++++++++- examples/app/commerce/README.md | 12 +- examples/app/commerce/server/Cargo.toml | 6 +- examples/app/commerce/server/src/main.rs | 254 ++++++++++++++----- examples/app/commerce/tests/commerce.spec.ts | 2 +- examples/demo/server/src/main.rs | 6 +- examples/demo/server/src/proxy.rs | 71 +++++- scripts/setup-linux-desktop-target.sh | 2 +- 10 files changed, 517 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b359a6b2..6e69ebf39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,7 @@ dependencies = [ "flate2", "foldhash 0.2.0", "futures-core", - "h2", + "h2 0.3.27", "http 0.2.12", "httparse", "httpdate", @@ -1480,6 +1480,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" @@ -2034,7 +2053,9 @@ dependencies = [ "anyhow", "bytes", "clap", + "h2 0.4.20", "hmac", + "http 1.5.0", "log", "microsoft-webui", "microsoft-webui-handler", @@ -2045,7 +2066,6 @@ dependencies = [ "sha2", "thiserror", "tokio", - "tokio-native-tls", "tokio-stream", ] diff --git a/Cargo.toml b/Cargo.toml index 148b66f51..d8babfcab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,8 @@ actix-server = "2.6.0" actix-service = "2.0.3" actix-tls = { version = "3.6.0", default-features = false, features = ["accept", "native-tls"] } native-tls = { version = "0.2.18", features = ["alpn", "alpn-accept"] } -tokio-native-tls = "0.3.1" +h2 = "0.4.20" +http = "1.4.0" reqwest = { version = "0.13.5", default-features = false, features = ["native-tls", "stream"] } hmac = "0.13.0" sha2 = "0.11.0" diff --git a/crates/webui-cli/src/commands/serve.rs b/crates/webui-cli/src/commands/serve.rs index f0787ca43..2dcfc8220 100644 --- a/crates/webui-cli/src/commands/serve.rs +++ b/crates/webui-cli/src/commands/serve.rs @@ -287,6 +287,9 @@ 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; + fn watch_disabled_by_env() -> bool { match std::env::var(WATCH_DISABLE_ENV) { Ok(v) => !v.is_empty() && v != "0", @@ -316,6 +319,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 http_client = build_http_client()?; // 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(); @@ -462,6 +466,7 @@ fn run(args: &ServeArgs, control: Option) -> Result<()> { assets_dir: paths.serve_dir, component_assets_dir, api_port: args.api_port, + http_client, plugin: args.app_args.plugin, base_path: args.base_path.clone(), // Pool sized for typical concurrent renders × channel capacity. @@ -814,6 +819,7 @@ struct ServerContext { assets_dir: Option, component_assets_dir: Option, api_port: Option, + http_client: reqwest::Client, plugin: Option, /// Base path for sub-path deployment. base_path: Option, @@ -876,9 +882,55 @@ 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 TLS and network resolver configuration", + ) +} + +#[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), +} + +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 = reqwest::Client::new(); +async fn fetch_api_state( + client: &reqwest::Client, + api_port: u16, + path: &str, +) -> Result { let url = format!("http://127.0.0.1:{api_port}{path}"); let resp = client .get(&url) @@ -886,8 +938,7 @@ async fn fetch_api_state(api_port: u16, path: &str) -> Result { .send() .await .map_err(|e| format!("API proxy error: {e}"))?; - let body = resp - .bytes() + 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}"))?; @@ -898,7 +949,7 @@ 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 { + match fetch_api_state(&context.http_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) @@ -978,9 +1029,9 @@ async fn render_page_response( 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 = reqwest::Client::new(); let url = format!("http://127.0.0.1:{api_port}{request_path}"); - match client + match context + .http_client .get(&url) .header(reqwest::header::ACCEPT, streaming_api::ACCEPT) .send() @@ -1026,7 +1077,7 @@ async fn render_page_response( .await; } } - Ok(response) => match response.bytes().await { + Ok(response) => match read_bounded_body(response, MAX_BUFFERED_API_BODY_BYTES).await { Ok(body) => match parse_api_state(&body) { Ok(state) => state, Err(error) => { @@ -1649,8 +1700,7 @@ async fn handle_api_proxy( return HttpResponse::BadRequest().body(format!("Invalid API proxy method: {error}")); } }; - let client = reqwest::Client::new(); - let mut proxy_req = client.request(method, &url); + let mut proxy_req = context.http_client.request(method, &url); // Forward content-type header if present if let Some(ct) = req.headers().get("content-type") { @@ -1666,7 +1716,7 @@ async fn handle_api_proxy( .headers() .get(reqwest::header::CONTENT_TYPE) .map(|value| value.as_bytes().to_vec()); - match resp.bytes().await { + 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, @@ -1867,6 +1917,10 @@ mod tests { dir } + fn test_http_client() -> reqwest::Client { + build_http_client().unwrap() + } + fn test_server_context(api_port: u16) -> web::Data { web::Data::new(ServerContext { state: Arc::new(Mutex::new(SharedState { @@ -1883,6 +1937,7 @@ mod tests { assets_dir: None, component_assets_dir: None, api_port: Some(api_port), + http_client: test_http_client(), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -1908,6 +1963,7 @@ mod tests { assets_dir: None, component_assets_dir: None, api_port: None, + http_client: test_http_client(), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -1975,6 +2031,127 @@ 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)) + }), + ) + }) + .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; + } + #[test] fn test_take_new_warnings_suppresses_repeats_and_surfaces_changes() { // `take_new_warnings` dedupes on each diagnostic's plain `body()` and @@ -2407,7 +2584,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(); @@ -2501,6 +2678,7 @@ mod tests { assets_dir: None, component_assets_dir: None, api_port: Some(api_port), + http_client: test_http_client(), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -2761,6 +2939,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", "{}")]); @@ -2868,6 +3068,7 @@ mod tests { assets_dir: None, component_assets_dir: None, api_port: None, + http_client: test_http_client(), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -2943,6 +3144,7 @@ mod tests { assets_dir: None, component_assets_dir: None, api_port: None, + http_client: test_http_client(), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3184,6 +3386,7 @@ mod tests { assets_dir: None, component_assets_dir: None, api_port: Some(port), + http_client: test_http_client(), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3258,6 +3461,7 @@ mod tests { assets_dir: None, component_assets_dir: None, api_port: Some(port), + http_client: test_http_client(), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3952,6 +4156,7 @@ mod tests { assets_dir: None, component_assets_dir: None, api_port: None, + http_client: test_http_client(), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( diff --git a/examples/app/commerce/README.md b/examples/app/commerce/README.md index 708b6b09b..e3d6be813 100644 --- a/examples/app/commerce/README.md +++ b/examples/app/commerce/README.md @@ -35,14 +35,16 @@ Then open http://127.0.0.1:3004 The commerce server does not generate certificates. For direct HTTPS development, provide a certificate and PKCS#8 private key PEM with `--tls-cert` -and `--tls-key`; otherwise use `--no-tls` behind a TLS-terminating proxy or -ingress. TLS uses the platform-native implementation through `native-tls` -(SChannel on Windows, OpenSSL on Linux, or Secure Transport on macOS). +and `--tls-key` on Windows or Linux. Otherwise, including on macOS, use +`--no-tls` behind an HTTP/2 TLS-terminating proxy or ingress. TLS uses the +platform-native implementation through `native-tls` (SChannel on Windows or +OpenSSL on Linux). Direct HTTPS advertises HTTP/2 and HTTP/1.1 through ALPN on Windows and Linux. Apple's native TLS backend does not support server-side ALPN, so macOS -development must use `--no-tls` behind an HTTP/2 TLS-terminating proxy rather -than silently reducing direct HTTPS to HTTP/1.1. +rejects direct HTTPS before reading certificate inputs. macOS development must +use `--no-tls` behind an HTTP/2 TLS-terminating proxy rather than silently +reducing direct HTTPS to HTTP/1.1. ## Running Tests diff --git a/examples/app/commerce/server/Cargo.toml b/examples/app/commerce/server/Cargo.toml index cd1ac86a3..d293323fa 100644 --- a/examples/app/commerce/server/Cargo.toml +++ b/examples/app/commerce/server/Cargo.toml @@ -22,7 +22,6 @@ thiserror = { workspace = true } hmac = { workspace = true } sha2 = { workspace = true } native-tls = { workspace = true } -tokio-native-tls = { workspace = true } microsoft-webui = { path = "../../../../crates/webui" } microsoft-webui-handler = { path = "../../../../crates/webui-handler" } bytes = { workspace = true } @@ -31,5 +30,10 @@ tokio = { workspace = true } tokio-stream = { workspace = true } log = { workspace = true } +[dev-dependencies] +actix-tls = { workspace = true, features = ["connect"] } +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 4f93d6a89..be0b2b369 100644 --- a/examples/app/commerce/server/src/main.rs +++ b/examples/app/commerce/server/src/main.rs @@ -23,7 +23,9 @@ use actix_tls::accept::native_tls::{reexports::TlsAcceptor, Acceptor as NativeTl use actix_web::{dev::AppConfig, web, App, HttpServer}; use anyhow::{Context, Result}; use clap::Parser; -use native_tls::{Identity, TlsAcceptor as PlatformTlsAcceptor}; +#[cfg(not(target_vendor = "apple"))] +use native_tls::Identity; +use native_tls::TlsAcceptor as PlatformTlsAcceptor; use crate::app::AppState; use crate::security::security_headers; @@ -99,49 +101,9 @@ fn main() -> Result<()> { actix_web::rt::System::new().block_on(async move { if let Some(tls) = tls_acceptor { - eprintln!(" HTTPS (HTTP/2) listening on https://localhost:{port}"); - let tls_acceptor = TlsAcceptor::from(tls); - let native_tls_acceptor = NativeTlsAcceptor::new(tls_acceptor); - let server_state = state.clone(); - - Server::build() - .shutdown_timeout(5) - .bind("marketplace-api", ("0.0.0.0", port), move || { - let factory = App::new() - .wrap(actix_web::middleware::Compress::default()) - .wrap(security_headers()) - .app_data(server_state.clone()) - .configure(configure_app) - .into_factory() - .map_err(|error: actix_web::Error| error.error_response()); - - let http_service = HttpService::build() - .keep_alive(std::time::Duration::from_secs(75)) - .secure() - .finish(map_config(factory, move |_| { - AppConfig::__priv_test_new( - true, - format!("localhost:{port}"), - std::net::SocketAddr::from(([0, 0, 0, 0], port)), - ) - })) - .map_err(|_| ()); - - native_tls_acceptor - .clone() - .map_err(|_| ()) - .and_then(fn_service( - |stream: actix_tls::accept::native_tls::TlsStream< - actix_web::rt::net::TcpStream, - >| async move { - let alpn = stream.get_ref().negotiated_alpn().map_err(|_| ())?; - Ok::<_, ()>((stream, protocol_from_alpn(alpn.as_deref()), None)) - }, - )) - .and_then(http_service) - })? - .run() - .await + eprintln!(" HTTPS (HTTP/2, HTTP/1.1 fallback) listening on https://localhost:{port}"); + let listener = std::net::TcpListener::bind(("0.0.0.0", port))?; + build_https_server(listener, port, state, tls)?.await } else { let server = HttpServer::new(move || { App::new() @@ -161,27 +123,63 @@ fn main() -> Result<()> { Ok(()) } +fn build_https_server( + listener: std::net::TcpListener, + port: u16, + state: web::Data, + tls: PlatformTlsAcceptor, +) -> std::io::Result { + let tls_acceptor = TlsAcceptor::from(tls); + let native_tls_acceptor = NativeTlsAcceptor::new(tls_acceptor); + + let server = Server::build() + .shutdown_timeout(5) + .listen("marketplace-api", listener, move || { + let factory = App::new() + .wrap(actix_web::middleware::Compress::default()) + .wrap(security_headers()) + .app_data(state.clone()) + .configure(configure_app) + .into_factory() + .map_err(|error: actix_web::Error| error.error_response()); + + let http_service = HttpService::build() + .keep_alive(std::time::Duration::from_secs(75)) + .secure() + .finish(map_config(factory, move |_| { + AppConfig::__priv_test_new( + true, + format!("localhost:{port}"), + std::net::SocketAddr::from(([0, 0, 0, 0], port)), + ) + })) + .map_err(|_| ()); + + native_tls_acceptor + .clone() + .map_err(|_| ()) + .and_then(fn_service( + |stream: actix_tls::accept::native_tls::TlsStream< + actix_web::rt::net::TcpStream, + >| async move { + let alpn = stream.get_ref().negotiated_alpn().map_err(|_| ())?; + Ok::<_, ()>((stream, protocol_from_alpn(alpn.as_deref()), None)) + }, + )) + .and_then(http_service) + })? + .run(); + Ok(server) +} + /// Build a platform-native TLS acceptor from caller-provided PEM files. fn build_tls_acceptor( cert_path: Option<&str>, key_path: Option<&str>, ) -> Result { - let (certificate, key) = match (cert_path, key_path) { - (Some(cert), Some(key)) => load_pem_files(cert, key)?, - (None, None) => anyhow::bail!( - "TLS certificate and key are required unless --no-tls is set; \ - provide --tls-cert and --tls-key " - ), - _ => anyhow::bail!("--tls-cert and --tls-key must be provided together, or use --no-tls"), - }; - - let identity = Identity::from_pkcs8(&certificate, &key).context( - "Failed to build platform-native TLS identity; the private key must be PKCS#8 PEM", - )?; - #[cfg(target_vendor = "apple")] { - let _ = identity; + let _ = (cert_path, key_path); anyhow::bail!( "Direct HTTPS is unavailable on Apple platforms because native-tls does not support \ server-side ALPN there; use --no-tls behind an HTTP/2 TLS-terminating proxy" @@ -190,14 +188,33 @@ fn build_tls_acceptor( #[cfg(not(target_vendor = "apple"))] { - let mut builder = PlatformTlsAcceptor::builder(identity); - builder.accept_alpn(&["h2", "http/1.1"]); - builder - .build() - .context("Failed to create platform-native TLS acceptor") + let (certificate, key) = match (cert_path, key_path) { + (Some(cert), Some(key)) => load_pem_files(cert, key)?, + (None, None) => anyhow::bail!( + "TLS certificate and key are required unless --no-tls is set; \ + provide --tls-cert and --tls-key " + ), + _ => { + anyhow::bail!("--tls-cert and --tls-key must be provided together, or use --no-tls") + } + }; + + build_tls_acceptor_from_pem(&certificate, &key) } } +#[cfg(not(target_vendor = "apple"))] +fn build_tls_acceptor_from_pem(certificate: &[u8], key: &[u8]) -> Result { + let identity = Identity::from_pkcs8(certificate, key).context( + "Failed to build platform-native TLS identity; the private key must be PKCS#8 PEM", + )?; + let mut builder = PlatformTlsAcceptor::builder(identity); + builder.accept_alpn(&["h2", "http/1.1"]); + builder + .build() + .context("Failed to create platform-native TLS acceptor") +} + fn protocol_from_alpn(alpn: Option<&[u8]>) -> Protocol { if alpn == Some(b"h2") { Protocol::Http2 @@ -216,9 +233,70 @@ fn load_pem_files(cert_path: &str, key_path: &str) -> Result<(Vec, Vec)> } #[cfg(test)] mod tests { + #[cfg(not(target_vendor = "apple"))] + use super::{build_https_server, build_tls_acceptor_from_pem}; use super::{build_tls_acceptor, protocol_from_alpn, ApiArgs}; use actix_http::Protocol; + #[cfg(not(target_vendor = "apple"))] + use actix_service::Service as _; + #[cfg(not(target_vendor = "apple"))] + use actix_web::web; + use anyhow::{Context, Result}; use clap::Parser; + #[cfg(not(target_vendor = "apple"))] + use std::path::Path; + + // Test-only self-signed localhost identity generated with .NET CertificateRequest. + #[cfg(not(target_vendor = "apple"))] + const TEST_CERTIFICATE_PEM: &[u8] = br#"-----BEGIN CERTIFICATE----- +MIICyDCCAbCgAwIBAgIIfMjByGjAEVUwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UE +AxMJbG9jYWxob3N0MB4XDTI2MTAxMjIxMDY0M1oXDTM2MTAxMzIxMDY0M1owFDES +MBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAygXnxYp/M6kbCsMapHjwv5amvkdC8082o+IBmcSLAbiXq7xyVkm1c87pgna/ +KBZZjMRdjcxlsQtbxqzImWLolBW5ntupJ4o/EtQ4RZykAH/SBSU8HXHwRoVJIkpA +Zo5udLHIJ92ZjBJ30ByWT3qFlWNjGRwtO83VO9ql+MUwyS2xBDnEOdUBBXxTLAKM +CDse+0Ze2vsNyQJoSUwvshAwFThqAHbKsYsfeT21/WwxWfNJmTvrcAm2zHNtIl0r +isBTsmKiTlBlDUHdEEKrqo2NmAUlpSmB685in07/gwh9x9wt/EKBdI7RtdpRNxH4 +98fN6/T1qem4i44TKsiatwJB0QIDAQABox4wHDAaBgNVHREEEzARgglsb2NhbGhv +c3SHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAADUm6L2fcV5VuqQNi9dNBWAdZpK +aOakgZXN0VsZsVq7vX+lqXKbDSbvaU8CZ47nDMxOWFyRPhnwN6Bkukdjfarng5+Y +tNKPn0p4DCW3eiZMgro0YRxZMKZ0pzzeSyyjSoOpdckXynhKAHm/WF8BsyZcuC1K +n4Ej5cvQipfn6/L7UAnmK4QyzSylXzh65r+GUU6kM0qd+B2+vfh2/HSrHl0qZFts +2DMdrfLAqDWY9L/0hvRcJflbdg3qHuSJJFQtxeF5nZxPtmmHfWxW/+XSpToJygE9 +ARsd6aCM6/oMqCXX8X5PwzhnUPe/U0XCOvjXup4FkPoy0o+xgynR8hEAuYs= +-----END CERTIFICATE----- +"#; + + #[cfg(not(target_vendor = "apple"))] + const TEST_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDKBefFin8zqRsK +wxqkePC/lqa+R0LzTzaj4gGZxIsBuJervHJWSbVzzumCdr8oFlmMxF2NzGWxC1vG +rMiZYuiUFbme26knij8S1DhFnKQAf9IFJTwdcfBGhUkiSkBmjm50scgn3ZmMEnfQ +HJZPeoWVY2MZHC07zdU72qX4xTDJLbEEOcQ51QEFfFMsAowIOx77Rl7a+w3JAmhJ +TC+yEDAVOGoAdsqxix95PbX9bDFZ80mZO+twCbbMc20iXSuKwFOyYqJOUGUNQd0Q +QquqjY2YBSWlKYHrzmKfTv+DCH3H3C38QoF0jtG12lE3Efj3x83r9PWp6biLjhMq +yJq3AkHRAgMBAAECggEBAMMmHzRgS+3QXdHwk+zq8sxvr5TA67hy/XojmJ48EHdS +JBj+CYi23PQInOKs6IzFwi1Lhn+yQU1bhoUNZR1eQQLDVbJRyD3sum7q14zO+lGN +J4hW5pIe/RZyLCpWhY1L6wEItytjUa7QLDK5Wq1d0mpzPkgEQuGxPFj8JjV/7ld1 +9yx4MRRc/85igSN9TuwynOBR3Z2NvtS2taNKFSeuF0l1RyO23ehTUkATKDonUD78 +bgw6y62u9V4qoUwrHRssklPp9eshbPcUmdcvOPwG2YSqikpt+tJnIKI2os9Ek4fI +nAMLWz9iZ1pvr5wSk+OCf8xYqD3ubav558syFcIUxz0CgYEA3PFzuxBCRjGAR9qD +wb28ep+aCjJobLfHfAoccB9nEQDdjfwLgl4C2Sz3N1x090UtoSetz8c0Nq6HXz3B +XwjlOvYcmd7jn7e70xSloKNDOCcNAS1vT1fApj0Vs9cfapVrZLLpsPHC2YYvNgi0 +mA/L1Vir879iq1+4JC8s/CyoIbsCgYEA6hPuwzkNbgK/60iSp9kXtg/jvSoW+glu +es1sfgcykNhd+pSKI12FAUCzTbrkbgOn88KupAOZJplc250GGMWbU//Msh3ASnMO +n0voGFbxBkctMWyRxl10ZCvQRjmVPGjsBHf+m8sKWI/6pbO2P74kQgwqDMeGntCN +jgIlravu++MCgYBijW0zBtiZGOGAq47T779P7zs1yzBbNkrMIHJ47Pj7U6h/+epk +V2zr1KvdTOd7mKb22I0mPlQjBAysRg6oRn6Bo2A0noBoMPud9vq+QbTtKjYZzVt2 +R81OGbdBhfakg0vpGgk1fVGN19XdQ1rS7VDdtHTXAAqSKNspgMWPIphImwKBgCUS ++yIYizP9Y4BUgkXrcIH5ikyPugvoKoNwvdpIMY4XdusVU7u+OC62nK+XBk8WbSeC +kj6QA1XFvJtcB3cs9SkcvsMHS0l/Z7V7REt2mb4wz8ugMoaEjsa+2cIFpMlopNkg +WEc8pWrZgw1zpU37Q6riWlaj04Kb7Y+sb2NOdA+ZAoGBAKXgQRVBUECfqYuHaSgP +BkF6SEnD+tCmEnp90NHpSswHUqeEGxbqPwj4zpxaAw6UBdGFtDgREmDNFeyrJMBj +koxpNyC2ZWgNDOJSxSCCnC4BSMeJNC1mOKmvx8HSXk0fLjzqymLfJI691B38GXyq +oIU51zOAgvknQMLr2aNPzPZJ +-----END PRIVATE KEY----- +"#; #[test] fn parses_custom_port() { @@ -226,6 +304,7 @@ mod tests { assert_eq!(args.port, 4001); } + #[cfg(not(target_vendor = "apple"))] #[test] fn requires_certificate_and_key_for_tls() { let error = build_tls_acceptor(None, None) @@ -234,6 +313,7 @@ mod tests { assert!(error.to_string().contains("--no-tls")); } + #[cfg(not(target_vendor = "apple"))] #[test] fn rejects_partial_certificate_inputs() { let error = build_tls_acceptor(Some("certificate.pem"), None) @@ -242,6 +322,15 @@ mod tests { assert!(error.to_string().contains("provided together")); } + #[cfg(target_vendor = "apple")] + #[test] + fn rejects_apple_https_before_validating_inputs() { + let error = build_tls_acceptor(Some("missing-certificate.pem"), Some("missing-key.pem")) + .err() + .expect("direct HTTPS must fail on Apple targets"); + assert!(error.to_string().contains("unavailable on Apple platforms")); + } + #[test] fn selects_http2_for_h2_alpn() { assert_eq!(protocol_from_alpn(Some(b"h2")), Protocol::Http2); @@ -252,4 +341,43 @@ mod tests { assert_eq!(protocol_from_alpn(Some(b"http/1.1")), Protocol::Http1); assert_eq!(protocol_from_alpn(None), Protocol::Http1); } + + #[cfg(not(target_vendor = "apple"))] + #[actix_web::test] + async fn serves_http2_through_native_tls_service() -> Result<()> { + 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 state = web::Data::new(app_state); + let tls = build_tls_acceptor_from_pem(TEST_CERTIFICATE_PEM, TEST_PRIVATE_KEY_PEM)?; + let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + let server = build_https_server(listener, port, state, tls)?; + let handle = server.handle(); + actix_web::rt::spawn(server); + + let mut connector = native_tls::TlsConnector::builder(); + connector.danger_accept_invalid_certs(true); + connector.request_alpns(&["h2"]); + let connector = actix_tls::connect::native_tls::TlsConnector::new(connector.build()?); + let tcp = actix_web::rt::net::TcpStream::connect(("127.0.0.1", port)).await?; + let connection = actix_tls::connect::Connection::new(String::from("localhost"), tcp); + let tls = connector.call(connection).await?.into_parts().0; + assert_eq!(tls.get_ref().negotiated_alpn()?, Some(b"h2".to_vec())); + + let (mut client, connection) = h2::client::handshake(tls).await?; + let connection_task = actix_web::rt::spawn(connection); + let request = http::Request::builder() + .uri(format!("https://localhost:{port}/")) + .body(())?; + let (response, _) = client.send_request(request, true)?; + let response = response.await?; + assert!(response.status().is_success()); + + connection_task.abort(); + handle.stop(true).await; + Ok(()) + } } diff --git a/examples/app/commerce/tests/commerce.spec.ts b/examples/app/commerce/tests/commerce.spec.ts index a665909b8..8432baa1b 100644 --- a/examples/app/commerce/tests/commerce.spec.ts +++ b/examples/app/commerce/tests/commerce.spec.ts @@ -8,7 +8,7 @@ * category switching, product pages, and visual regression screenshots. * * Start the server before running: - * cd examples/app/commerce && cargo run -p marketplace-api --release -- --port 3004 + * cd examples/app/commerce && cargo run -p marketplace-api --release -- --port 3004 --no-tls */ import { test, expect, type Page } from '@playwright/test'; diff --git a/examples/demo/server/src/main.rs b/examples/demo/server/src/main.rs index 725200d2b..6757cc078 100644 --- a/examples/demo/server/src/main.rs +++ b/examples/demo/server/src/main.rs @@ -59,11 +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( - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build()?, - ); + let proxy_client = web::Data::new(proxy::build_client()?); // Spawn all child app servers log::info!("Starting app servers…"); diff --git a/examples/demo/server/src/proxy.rs b/examples/demo/server/src/proxy.rs index 7afb35d4c..f3fa789ff 100644 --- a/examples/demo/server/src/proxy.rs +++ b/examples/demo/server/src/proxy.rs @@ -21,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, @@ -310,7 +318,20 @@ pub(crate) async fn slug_redirect( #[cfg(test)] mod tests { use super::*; - 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, @@ -525,6 +546,54 @@ 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 strips_hop_by_hop_and_body_framing_headers() { let mut headers = HeaderMap::new(); diff --git a/scripts/setup-linux-desktop-target.sh b/scripts/setup-linux-desktop-target.sh index cab8c9754..a5735f86d 100644 --- a/scripts/setup-linux-desktop-target.sh +++ b/scripts/setup-linux-desktop-target.sh @@ -8,7 +8,7 @@ set -euo pipefail target="${1:?pass a Linux Rust target triple}" if [[ "$target" == x86_64-unknown-linux-gnu ]]; then sudo apt-get update -q - sudo apt-get install -y -q protobuf-compiler pkg-config libgtk-4-dev libwebkitgtk-6.0-dev + sudo apt-get install -y -q protobuf-compiler pkg-config libgtk-4-dev libwebkitgtk-6.0-dev libssl-dev elif [[ "$target" == aarch64-unknown-linux-gnu ]]; then source /etc/os-release if [[ "$ID" != ubuntu || -z "${VERSION_CODENAME:-}" ]]; then From dbe847c835774888b2fd3b82da9a498ef28500ce Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 13 Oct 2026 15:24:04 -0700 Subject: [PATCH 06/12] fix: validate commerce test certificate Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- examples/app/commerce/server/src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/app/commerce/server/src/main.rs b/examples/app/commerce/server/src/main.rs index be0b2b369..5cb722898 100644 --- a/examples/app/commerce/server/src/main.rs +++ b/examples/app/commerce/server/src/main.rs @@ -244,6 +244,8 @@ mod tests { use anyhow::{Context, Result}; use clap::Parser; #[cfg(not(target_vendor = "apple"))] + use native_tls::Certificate; + #[cfg(not(target_vendor = "apple"))] use std::path::Path; // Test-only self-signed localhost identity generated with .NET CertificateRequest. @@ -359,7 +361,7 @@ oIU51zOAgvknQMLr2aNPzPZJ actix_web::rt::spawn(server); let mut connector = native_tls::TlsConnector::builder(); - connector.danger_accept_invalid_certs(true); + connector.add_root_certificate(Certificate::from_pem(TEST_CERTIFICATE_PEM)?); connector.request_alpns(&["h2"]); let connector = actix_tls::connect::native_tls::TlsConnector::new(connector.build()?); let tcp = actix_web::rt::net::TcpStream::connect(("127.0.0.1", port)).await?; From 70af10a5e1d7173f3a22690516ca6bc069568cdd Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 13 Oct 2026 15:39:01 -0700 Subject: [PATCH 07/12] fix: restore API response deadlines Apply the prior five-second response deadline to buffered state and proxy requests while limiting streaming acquisition only until response headers arrive. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- crates/webui-cli/src/commands/serve.rs | 178 ++++++++++++++++++++++--- 1 file changed, 157 insertions(+), 21 deletions(-) diff --git a/crates/webui-cli/src/commands/serve.rs b/crates/webui-cli/src/commands/serve.rs index 2dcfc8220..6f7d051ff 100644 --- a/crates/webui-cli/src/commands/serve.rs +++ b/crates/webui-cli/src/commands/serve.rs @@ -290,6 +290,9 @@ 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", @@ -900,6 +903,45 @@ enum BufferedBodyError { 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, @@ -930,12 +972,21 @@ 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 resp = client + let request = client .get(&url) - .header(reqwest::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 = read_bounded_body(resp, MAX_BUFFERED_API_BODY_BYTES) @@ -1030,14 +1081,12 @@ async fn render_page_response( let mut state = if let Some(api_port) = context.api_port { let url = format!("http://127.0.0.1:{api_port}{request_path}"); - match context + let request = context .http_client .get(&url) - .header(reqwest::header::ACCEPT, streaming_api::ACCEPT) - .send() - .await - { - Ok(response) + .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() @@ -1077,21 +1126,32 @@ async fn render_page_response( .await; } } - Ok(response) => match read_bounded_body(response, MAX_BUFFERED_API_BODY_BYTES).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) } } @@ -1674,6 +1734,15 @@ async fn handle_api_proxy( req: HttpRequest, body: web::Bytes, context: web::Data, +) -> HttpResponse { + 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_port) = context.api_port else { return HttpResponse::NotFound().body("Not Found"); @@ -1707,7 +1776,7 @@ async fn handle_api_proxy( proxy_req = proxy_req.header(reqwest::header::CONTENT_TYPE, ct.as_bytes()); } - let result = proxy_req.body(body).send().await; + let result = send_bounded_api_request(proxy_req.body(body), timeout).await; match result { Ok(resp) => { @@ -2087,6 +2156,27 @@ mod tests { 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() @@ -2152,6 +2242,52 @@ mod tests { 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 From 11cfd02c929cf8f76638bcd37b32a5bd30c10394 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 13 Oct 2026 16:33:22 -0700 Subject: [PATCH 08/12] fix: initialize serve API client conditionally Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- crates/webui-cli/src/commands/serve.rs | 105 +++++++++++++++++-------- 1 file changed, 72 insertions(+), 33 deletions(-) diff --git a/crates/webui-cli/src/commands/serve.rs b/crates/webui-cli/src/commands/serve.rs index 6f7d051ff..25e74cee2 100644 --- a/crates/webui-cli/src/commands/serve.rs +++ b/crates/webui-cli/src/commands/serve.rs @@ -322,7 +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 http_client = build_http_client()?; + 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(); @@ -468,8 +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, - http_client, + api, plugin: args.app_args.plugin, base_path: args.base_path.clone(), // Pool sized for typical concurrent renders × channel capacity. @@ -521,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()) { @@ -821,8 +820,7 @@ struct ServerContext { livereload: Option, assets_dir: Option, component_assets_dir: Option, - api_port: Option, - http_client: reqwest::Client, + api: Option, plugin: Option, /// Base path for sub-path deployment. base_path: Option, @@ -832,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, @@ -895,6 +899,23 @@ fn build_http_client() -> Result { ) } +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")] @@ -999,8 +1020,8 @@ async fn fetch_api_state_with_timeout( /// 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(&context.http_client, 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) @@ -1079,10 +1100,10 @@ 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 url = format!("http://127.0.0.1:{api_port}{request_path}"); - let request = context - .http_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) .header(reqwest::header::ACCEPT, streaming_api::ACCEPT); match send_api_stream_candidate(request, API_RESPONSE_TIMEOUT).await { @@ -1277,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; } @@ -1744,7 +1765,7 @@ async fn handle_api_proxy_with_timeout( context: web::Data, timeout: Duration, ) -> HttpResponse { - let Some(api_port) = context.api_port else { + let Some(api) = &context.api else { return HttpResponse::NotFound().body("Not Found"); }; @@ -1760,7 +1781,7 @@ async fn handle_api_proxy_with_timeout( // 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 method = match reqwest::Method::from_bytes(req.method().as_str().as_bytes()) { @@ -1769,7 +1790,7 @@ async fn handle_api_proxy_with_timeout( return HttpResponse::BadRequest().body(format!("Invalid API proxy method: {error}")); } }; - let mut proxy_req = context.http_client.request(method, &url); + let mut proxy_req = api.client.request(method, &url); // Forward content-type header if present if let Some(ct) = req.headers().get("content-type") { @@ -1990,6 +2011,32 @@ mod tests { 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 { @@ -2005,8 +2052,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: Some(api_port), - http_client: test_http_client(), + api: Some(test_api_context(api_port)), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -2031,8 +2077,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: None, - http_client: test_http_client(), + api: None, plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -2813,8 +2858,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: Some(api_port), - http_client: test_http_client(), + api: Some(test_api_context(api_port)), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3203,8 +3247,7 @@ mod tests { livereload: Some(livereload.clone()), assets_dir: None, component_assets_dir: None, - api_port: None, - http_client: test_http_client(), + api: None, plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3279,8 +3322,7 @@ mod tests { livereload: Some(livereload), assets_dir: None, component_assets_dir: None, - api_port: None, - http_client: test_http_client(), + api: None, plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3521,8 +3563,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: Some(port), - http_client: test_http_client(), + api: Some(test_api_context(port)), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -3596,8 +3637,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: Some(port), - http_client: test_http_client(), + api: Some(test_api_context(port)), plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( @@ -4216,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)) @@ -4291,8 +4331,7 @@ mod tests { livereload: None, assets_dir: None, component_assets_dir: None, - api_port: None, - http_client: test_http_client(), + api: None, plugin: None, base_path: None, chunk_pool: Arc::new(webui::streaming::ChunkPool::new( From 063d2ca2dc310c59872179f902c4b0dde6c65a71 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 20 Oct 2026 11:02:34 -0700 Subject: [PATCH 09/12] Provision OpenSSL for Linux ARM64 assets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- .ado/pipelines/azure-pipelines-build.yml | 51 ++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/.ado/pipelines/azure-pipelines-build.yml b/.ado/pipelines/azure-pipelines-build.yml index 8f246c2ff..80617997f 100644 --- a/.ado/pipelines/azure-pipelines-build.yml +++ b/.ado/pipelines/azure-pipelines-build.yml @@ -312,13 +312,58 @@ stages: : "${TARGET_TRIPLE:?TARGET_TRIPLE is required}" : "${ARTIFACT_NAME:?ARTIFACT_NAME is required}" - sudo apt-get update -q - sudo apt-get install -y -q protobuf-compiler binutils if [[ "$TARGET_TRIPLE" == aarch64-unknown-linux-gnu ]]; then - sudo apt-get install -y -q gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + source /etc/os-release + if [[ "$ID" != ubuntu || -z "${VERSION_CODENAME:-}" ]]; then + echo "Linux ARM64 release cross-build requires an Ubuntu agent with a release codename" >&2 + exit 1 + fi + sudo dpkg --add-architecture arm64 + sources="$BUILD_SOURCESDIRECTORY/.ubuntu-arm64.sources" + trap 'rm -f "$sources"' EXIT + cat > "$sources" <&2 + exit 1 + fi + rm "$sources" + trap - EXIT + else + sudo apt-get update -q + sudo apt-get install -y -q protobuf-compiler binutils pkg-config fi rustup toolchain install 1.93 --profile minimal --target "$TARGET_TRIPLE" From bf89010114f1b9afc44a626b7dd0f92a97dd99d0 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 20 Oct 2026 11:17:33 -0700 Subject: [PATCH 10/12] Strip untrusted forwarding headers in demo proxy Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- examples/demo/server/src/proxy.rs | 109 ++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 22 deletions(-) diff --git a/examples/demo/server/src/proxy.rs b/examples/demo/server/src/proxy.rs index f3fa789ff..2a661d742 100644 --- a/examples/demo/server/src/proxy.rs +++ b/examples/demo/server/src/proxy.rs @@ -157,29 +157,13 @@ pub(crate) async fn proxy_handler( }; let mut forwarded = client.request(method, &target_url); - // Copy relevant headers (skip Host — we set our own) - for (name, value) in req.headers() { - if name != "host" { - let forwarded_name = - match reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()) { - Ok(name) => name, - Err(error) => { - return HttpResponse::BadGateway() - .body(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 HttpResponse::BadGateway() - .body(format!("Invalid proxy request header value: {error}")); - } - }; - forwarded = forwarded.header(forwarded_name, forwarded_value); - } - } + forwarded = match copy_request_headers(forwarded, req.headers()) { + Ok(forwarded) => forwarded, + Err(error) => return HttpResponse::BadGateway().body(error), + }; - // Add forwarding headers + // 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.header("X-Forwarded-For", peer.ip().to_string()); } @@ -235,6 +219,36 @@ pub(crate) async fn proxy_handler( )) } +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 { @@ -594,6 +608,57 @@ mod tests { 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(); From c4c37fe51f3bf1de718066a117302bab8c004623 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Tue, 20 Oct 2026 11:35:13 -0700 Subject: [PATCH 11/12] Clarify Commerce direct HTTPS platform support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- examples/app/commerce/README.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/examples/app/commerce/README.md b/examples/app/commerce/README.md index e3d6be813..8718ed55c 100644 --- a/examples/app/commerce/README.md +++ b/examples/app/commerce/README.md @@ -33,18 +33,24 @@ pnpm start Then open http://127.0.0.1:3004 -The commerce server does not generate certificates. For direct HTTPS -development, provide a certificate and PKCS#8 private key PEM with `--tls-cert` -and `--tls-key` on Windows or Linux. Otherwise, including on macOS, use -`--no-tls` behind an HTTP/2 TLS-terminating proxy or ingress. TLS uses the -platform-native implementation through `native-tls` (SChannel on Windows or -OpenSSL on Linux). - -Direct HTTPS advertises HTTP/2 and HTTP/1.1 through ALPN on Windows and Linux. -Apple's native TLS backend does not support server-side ALPN, so macOS -rejects direct HTTPS before reading certificate inputs. macOS development must -use `--no-tls` behind an HTTP/2 TLS-terminating proxy rather than silently -reducing direct HTTPS to HTTP/1.1. +### Direct HTTPS + +The commerce server does not generate certificates. To serve HTTPS directly, +pass a certificate and PKCS#8 private key PEM with `--tls-cert` and +`--tls-key`. TLS uses the platform-native implementation through `native-tls` +and advertises HTTP/2 with an HTTP/1.1 fallback through ALPN. + +| Platform | Direct HTTPS | TLS backend | +|----------|--------------|-------------| +| Windows | HTTP/2 + HTTP/1.1 | SChannel (serves the leaf certificate only) | +| Linux | HTTP/2 + HTTP/1.1 | OpenSSL | +| macOS | Not supported | n/a | + +Apple's native TLS backend (Secure Transport) cannot select ALPN protocols on +the server side, so it cannot negotiate HTTP/2. Rather than silently +downgrading to HTTP/1.1, the server rejects `--tls-cert`/`--tls-key` on macOS. +On macOS, run with `--no-tls` behind an HTTP/2 TLS-terminating proxy or +ingress. ## Running Tests From 458788cec94702e42193997d214662b944ced398 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Wed, 21 Oct 2026 14:50:15 -0700 Subject: [PATCH 12/12] Drop in-process TLS from Commerce and Reqwest Commerce now serves cleartext HTTP/1.1 and h2c behind a TLS-terminating ingress, and Reqwest no longer enables a TLS backend because every client targets loopback HTTP. Remove the OpenSSL CI provisioning that only existed for native-tls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a09d1a39-a596-4cdf-b413-b823dc3b7104 --- .ado/pipelines/azure-pipelines-build.yml | 51 +-- Cargo.lock | 197 ------------ Cargo.toml | 7 +- crates/webui-cli/src/commands/serve.rs | 2 +- examples/app/commerce/README.md | 28 +- examples/app/commerce/demo.toml | 2 +- examples/app/commerce/docker-entrypoint.sh | 2 +- examples/app/commerce/package.json | 2 +- examples/app/commerce/server/Cargo.toml | 6 - examples/app/commerce/server/src/main.rs | 314 +++---------------- examples/app/commerce/tests/commerce.spec.ts | 2 +- scripts/setup-linux-desktop-target.sh | 6 +- 12 files changed, 69 insertions(+), 550 deletions(-) diff --git a/.ado/pipelines/azure-pipelines-build.yml b/.ado/pipelines/azure-pipelines-build.yml index 80617997f..8f246c2ff 100644 --- a/.ado/pipelines/azure-pipelines-build.yml +++ b/.ado/pipelines/azure-pipelines-build.yml @@ -312,58 +312,13 @@ stages: : "${TARGET_TRIPLE:?TARGET_TRIPLE is required}" : "${ARTIFACT_NAME:?ARTIFACT_NAME is required}" + sudo apt-get update -q + sudo apt-get install -y -q protobuf-compiler binutils if [[ "$TARGET_TRIPLE" == aarch64-unknown-linux-gnu ]]; then - source /etc/os-release - if [[ "$ID" != ubuntu || -z "${VERSION_CODENAME:-}" ]]; then - echo "Linux ARM64 release cross-build requires an Ubuntu agent with a release codename" >&2 - exit 1 - fi - sudo dpkg --add-architecture arm64 - sources="$BUILD_SOURCESDIRECTORY/.ubuntu-arm64.sources" - trap 'rm -f "$sources"' EXIT - cat > "$sources" <&2 - exit 1 - fi - rm "$sources" - trap - EXIT - else - sudo apt-get update -q - sudo apt-get install -y -q protobuf-compiler binutils pkg-config fi rustup toolchain install 1.93 --profile minimal --target "$TARGET_TRIPLE" diff --git a/Cargo.lock b/Cargo.lock index b340aab36..6aa7481ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,24 +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", - "impl-more", - "pin-project-lite", - "tokio", - "tokio-native-tls", - "tokio-util", - "tracing", -] - [[package]] name = "actix-utils" version = "3.0.1" @@ -673,22 +655,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "core_detect" version = "1.0.0" @@ -1084,21 +1050,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1639,22 +1590,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.21" @@ -2045,10 +1980,6 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" name = "marketplace-api" version = "3.0.2" dependencies = [ - "actix-http", - "actix-server", - "actix-service", - "actix-tls", "actix-web", "anyhow", "bytes", @@ -2060,7 +1991,6 @@ dependencies = [ "microsoft-webui", "microsoft-webui-handler", "mime_guess", - "native-tls", "serde", "serde_json", "sha2", @@ -2539,23 +2469,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nix" version = "0.31.3" @@ -2752,49 +2665,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "page_size" version = "0.6.0" @@ -3216,17 +3086,13 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", - "rustls-pki-types", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-util", "tower", "tower-http", @@ -3266,15 +3132,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[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 = "rustversion" version = "1.0.23" @@ -3296,44 +3153,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.2", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -3753,16 +3578,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.19" @@ -4021,12 +3836,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version-compare" version = "0.2.1" @@ -4584,12 +4393,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" - [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 03849b1dd..2be3c7c30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,9 @@ actix-web = { version = "4.15.0", default-features = false, features = [ "unicode", "compat", ] } -actix-http = "3.13.6" -actix-server = "2.6.0" -actix-service = "2.0.3" -actix-tls = { version = "3.6.0", default-features = false, features = ["accept", "native-tls"] } -native-tls = { version = "0.2.18", features = ["alpn", "alpn-accept"] } h2 = "0.4.20" http = "1.4.0" -reqwest = { version = "0.13.5", default-features = false, features = ["native-tls", "stream"] } +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/src/commands/serve.rs b/crates/webui-cli/src/commands/serve.rs index 25e74cee2..3616bf768 100644 --- a/crates/webui-cli/src/commands/serve.rs +++ b/crates/webui-cli/src/commands/serve.rs @@ -895,7 +895,7 @@ fn build_http_client() -> Result { .build() .context( "Failed to initialize the HTTP client for API state and proxy requests; \ - verify the system TLS and network resolver configuration", + verify the system network resolver configuration", ) } diff --git a/examples/app/commerce/README.md b/examples/app/commerce/README.md index 8718ed55c..8b936192a 100644 --- a/examples/app/commerce/README.md +++ b/examples/app/commerce/README.md @@ -33,24 +33,18 @@ pnpm start Then open http://127.0.0.1:3004 -### Direct HTTPS +### HTTP versions and TLS -The commerce server does not generate certificates. To serve HTTPS directly, -pass a certificate and PKCS#8 private key PEM with `--tls-cert` and -`--tls-key`. TLS uses the platform-native implementation through `native-tls` -and advertises HTTP/2 with an HTTP/1.1 fallback through ALPN. +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: -| Platform | Direct HTTPS | TLS backend | -|----------|--------------|-------------| -| Windows | HTTP/2 + HTTP/1.1 | SChannel (serves the leaf certificate only) | -| Linux | HTTP/2 + HTTP/1.1 | OpenSSL | -| macOS | Not supported | n/a | - -Apple's native TLS backend (Secure Transport) cannot select ALPN protocols on -the server side, so it cannot negotiate HTTP/2. Rather than silently -downgrading to HTTP/1.1, the server rejects `--tls-cert`/`--tls-key` on macOS. -On macOS, run with `--no-tls` behind an HTTP/2 TLS-terminating proxy or -ingress. +```caddyfile +commerce.example.com { + reverse_proxy h2c://127.0.0.1:3004 +} +``` ## Running Tests @@ -135,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 d1e41184e..c8722baa2 100644 --- a/examples/app/commerce/server/Cargo.toml +++ b/examples/app/commerce/server/Cargo.toml @@ -9,10 +9,6 @@ name = "marketplace-api" path = "src/main.rs" [dependencies] -actix-http = { workspace = true } -actix-server = { workspace = true } -actix-service = { workspace = true } -actix-tls = { workspace = true } actix-web = { workspace = true, features = ["compress-gzip"] } serde = { workspace = true } serde_json = { workspace = true } @@ -21,7 +17,6 @@ clap = { workspace = true } thiserror = { workspace = true } hmac = { workspace = true } sha2 = { workspace = true } -native-tls = { workspace = true } microsoft-webui = { path = "../../../../crates/webui" } microsoft-webui-handler = { path = "../../../../crates/webui-handler" } bytes = { workspace = true } @@ -31,7 +26,6 @@ tokio-stream = { workspace = true } log = { workspace = true } [dev-dependencies] -actix-tls = { workspace = true, features = ["connect"] } h2 = { workspace = true } http = { workspace = true } diff --git a/examples/app/commerce/server/src/main.rs b/examples/app/commerce/server/src/main.rs index 5cb722898..85b340397 100644 --- a/examples/app/commerce/server/src/main.rs +++ b/examples/app/commerce/server/src/main.rs @@ -16,16 +16,9 @@ mod security; mod server; mod state; -use actix_http::{HttpService, Protocol}; -use actix_server::Server; -use actix_service::{fn_service, map_config, IntoServiceFactory, ServiceFactoryExt}; -use actix_tls::accept::native_tls::{reexports::TlsAcceptor, Acceptor as NativeTlsAcceptor}; -use actix_web::{dev::AppConfig, web, App, HttpServer}; +use actix_web::{dev::Server, web, App, HttpServer}; use anyhow::{Context, Result}; use clap::Parser; -#[cfg(not(target_vendor = "apple"))] -use native_tls::Identity; -use native_tls::TlsAcceptor as PlatformTlsAcceptor; use crate::app::AppState; use crate::security::security_headers; @@ -47,19 +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 a TLS certificate PEM file. Required unless --no-tls is set. - #[arg(long)] - tls_cert: Option, - - /// Path to a TLS private key PEM file. Required unless --no-tls is set. - #[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 = "/")] @@ -89,216 +69,42 @@ fn main() -> Result<()> { ); let state = web::Data::new(app_state); - let no_tls = args.no_tls; - let tls_acceptor = if no_tls { - None - } else { - Some(build_tls_acceptor( - args.tls_cert.as_deref(), - args.tls_key.as_deref(), - )?) - }; actix_web::rt::System::new().block_on(async move { - if let Some(tls) = tls_acceptor { - eprintln!(" HTTPS (HTTP/2, HTTP/1.1 fallback) listening on https://localhost:{port}"); - let listener = std::net::TcpListener::bind(("0.0.0.0", port))?; - build_https_server(listener, port, state, tls)?.await - } else { - 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); - - 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(()) } -fn build_https_server( +/// Serve cleartext HTTP/1.1 and HTTP/2 prior-knowledge (h2c) on `listener`. +fn build_server( listener: std::net::TcpListener, - port: u16, state: web::Data, - tls: PlatformTlsAcceptor, ) -> std::io::Result { - let tls_acceptor = TlsAcceptor::from(tls); - let native_tls_acceptor = NativeTlsAcceptor::new(tls_acceptor); - - let server = Server::build() - .shutdown_timeout(5) - .listen("marketplace-api", listener, move || { - let factory = App::new() - .wrap(actix_web::middleware::Compress::default()) - .wrap(security_headers()) - .app_data(state.clone()) - .configure(configure_app) - .into_factory() - .map_err(|error: actix_web::Error| error.error_response()); - - let http_service = HttpService::build() - .keep_alive(std::time::Duration::from_secs(75)) - .secure() - .finish(map_config(factory, move |_| { - AppConfig::__priv_test_new( - true, - format!("localhost:{port}"), - std::net::SocketAddr::from(([0, 0, 0, 0], port)), - ) - })) - .map_err(|_| ()); - - native_tls_acceptor - .clone() - .map_err(|_| ()) - .and_then(fn_service( - |stream: actix_tls::accept::native_tls::TlsStream< - actix_web::rt::net::TcpStream, - >| async move { - let alpn = stream.get_ref().negotiated_alpn().map_err(|_| ())?; - Ok::<_, ()>((stream, protocol_from_alpn(alpn.as_deref()), None)) - }, - )) - .and_then(http_service) - })? - .run(); - Ok(server) -} - -/// Build a platform-native TLS acceptor from caller-provided PEM files. -fn build_tls_acceptor( - cert_path: Option<&str>, - key_path: Option<&str>, -) -> Result { - #[cfg(target_vendor = "apple")] - { - let _ = (cert_path, key_path); - anyhow::bail!( - "Direct HTTPS is unavailable on Apple platforms because native-tls does not support \ - server-side ALPN there; use --no-tls behind an HTTP/2 TLS-terminating proxy" - ); - } - - #[cfg(not(target_vendor = "apple"))] - { - let (certificate, key) = match (cert_path, key_path) { - (Some(cert), Some(key)) => load_pem_files(cert, key)?, - (None, None) => anyhow::bail!( - "TLS certificate and key are required unless --no-tls is set; \ - provide --tls-cert and --tls-key " - ), - _ => { - anyhow::bail!("--tls-cert and --tls-key must be provided together, or use --no-tls") - } - }; - - build_tls_acceptor_from_pem(&certificate, &key) - } -} - -#[cfg(not(target_vendor = "apple"))] -fn build_tls_acceptor_from_pem(certificate: &[u8], key: &[u8]) -> Result { - let identity = Identity::from_pkcs8(certificate, key).context( - "Failed to build platform-native TLS identity; the private key must be PKCS#8 PEM", - )?; - let mut builder = PlatformTlsAcceptor::builder(identity); - builder.accept_alpn(&["h2", "http/1.1"]); - builder - .build() - .context("Failed to create platform-native TLS acceptor") + 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()) } -fn protocol_from_alpn(alpn: Option<&[u8]>) -> Protocol { - if alpn == Some(b"h2") { - Protocol::Http2 - } else { - Protocol::Http1 - } -} - -fn load_pem_files(cert_path: &str, key_path: &str) -> Result<(Vec, Vec)> { - 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}"))?; - - Ok((cert_data, key_data)) -} #[cfg(test)] mod tests { - #[cfg(not(target_vendor = "apple"))] - use super::{build_https_server, build_tls_acceptor_from_pem}; - use super::{build_tls_acceptor, protocol_from_alpn, ApiArgs}; - use actix_http::Protocol; - #[cfg(not(target_vendor = "apple"))] - use actix_service::Service as _; - #[cfg(not(target_vendor = "apple"))] - use actix_web::web; + use super::{build_server, ApiArgs}; + use actix_web::{dev::ServerHandle, web}; use anyhow::{Context, Result}; use clap::Parser; - #[cfg(not(target_vendor = "apple"))] - use native_tls::Certificate; - #[cfg(not(target_vendor = "apple"))] use std::path::Path; - - // Test-only self-signed localhost identity generated with .NET CertificateRequest. - #[cfg(not(target_vendor = "apple"))] - const TEST_CERTIFICATE_PEM: &[u8] = br#"-----BEGIN CERTIFICATE----- -MIICyDCCAbCgAwIBAgIIfMjByGjAEVUwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UE -AxMJbG9jYWxob3N0MB4XDTI2MTAxMjIxMDY0M1oXDTM2MTAxMzIxMDY0M1owFDES -MBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC -AQEAygXnxYp/M6kbCsMapHjwv5amvkdC8082o+IBmcSLAbiXq7xyVkm1c87pgna/ -KBZZjMRdjcxlsQtbxqzImWLolBW5ntupJ4o/EtQ4RZykAH/SBSU8HXHwRoVJIkpA -Zo5udLHIJ92ZjBJ30ByWT3qFlWNjGRwtO83VO9ql+MUwyS2xBDnEOdUBBXxTLAKM -CDse+0Ze2vsNyQJoSUwvshAwFThqAHbKsYsfeT21/WwxWfNJmTvrcAm2zHNtIl0r -isBTsmKiTlBlDUHdEEKrqo2NmAUlpSmB685in07/gwh9x9wt/EKBdI7RtdpRNxH4 -98fN6/T1qem4i44TKsiatwJB0QIDAQABox4wHDAaBgNVHREEEzARgglsb2NhbGhv -c3SHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAADUm6L2fcV5VuqQNi9dNBWAdZpK -aOakgZXN0VsZsVq7vX+lqXKbDSbvaU8CZ47nDMxOWFyRPhnwN6Bkukdjfarng5+Y -tNKPn0p4DCW3eiZMgro0YRxZMKZ0pzzeSyyjSoOpdckXynhKAHm/WF8BsyZcuC1K -n4Ej5cvQipfn6/L7UAnmK4QyzSylXzh65r+GUU6kM0qd+B2+vfh2/HSrHl0qZFts -2DMdrfLAqDWY9L/0hvRcJflbdg3qHuSJJFQtxeF5nZxPtmmHfWxW/+XSpToJygE9 -ARsd6aCM6/oMqCXX8X5PwzhnUPe/U0XCOvjXup4FkPoy0o+xgynR8hEAuYs= ------END CERTIFICATE----- -"#; - - #[cfg(not(target_vendor = "apple"))] - const TEST_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDKBefFin8zqRsK -wxqkePC/lqa+R0LzTzaj4gGZxIsBuJervHJWSbVzzumCdr8oFlmMxF2NzGWxC1vG -rMiZYuiUFbme26knij8S1DhFnKQAf9IFJTwdcfBGhUkiSkBmjm50scgn3ZmMEnfQ -HJZPeoWVY2MZHC07zdU72qX4xTDJLbEEOcQ51QEFfFMsAowIOx77Rl7a+w3JAmhJ -TC+yEDAVOGoAdsqxix95PbX9bDFZ80mZO+twCbbMc20iXSuKwFOyYqJOUGUNQd0Q -QquqjY2YBSWlKYHrzmKfTv+DCH3H3C38QoF0jtG12lE3Efj3x83r9PWp6biLjhMq -yJq3AkHRAgMBAAECggEBAMMmHzRgS+3QXdHwk+zq8sxvr5TA67hy/XojmJ48EHdS -JBj+CYi23PQInOKs6IzFwi1Lhn+yQU1bhoUNZR1eQQLDVbJRyD3sum7q14zO+lGN -J4hW5pIe/RZyLCpWhY1L6wEItytjUa7QLDK5Wq1d0mpzPkgEQuGxPFj8JjV/7ld1 -9yx4MRRc/85igSN9TuwynOBR3Z2NvtS2taNKFSeuF0l1RyO23ehTUkATKDonUD78 -bgw6y62u9V4qoUwrHRssklPp9eshbPcUmdcvOPwG2YSqikpt+tJnIKI2os9Ek4fI -nAMLWz9iZ1pvr5wSk+OCf8xYqD3ubav558syFcIUxz0CgYEA3PFzuxBCRjGAR9qD -wb28ep+aCjJobLfHfAoccB9nEQDdjfwLgl4C2Sz3N1x090UtoSetz8c0Nq6HXz3B -XwjlOvYcmd7jn7e70xSloKNDOCcNAS1vT1fApj0Vs9cfapVrZLLpsPHC2YYvNgi0 -mA/L1Vir879iq1+4JC8s/CyoIbsCgYEA6hPuwzkNbgK/60iSp9kXtg/jvSoW+glu -es1sfgcykNhd+pSKI12FAUCzTbrkbgOn88KupAOZJplc250GGMWbU//Msh3ASnMO -n0voGFbxBkctMWyRxl10ZCvQRjmVPGjsBHf+m8sKWI/6pbO2P74kQgwqDMeGntCN -jgIlravu++MCgYBijW0zBtiZGOGAq47T779P7zs1yzBbNkrMIHJ47Pj7U6h/+epk -V2zr1KvdTOd7mKb22I0mPlQjBAysRg6oRn6Bo2A0noBoMPud9vq+QbTtKjYZzVt2 -R81OGbdBhfakg0vpGgk1fVGN19XdQ1rS7VDdtHTXAAqSKNspgMWPIphImwKBgCUS -+yIYizP9Y4BUgkXrcIH5ikyPugvoKoNwvdpIMY4XdusVU7u+OC62nK+XBk8WbSeC -kj6QA1XFvJtcB3cs9SkcvsMHS0l/Z7V7REt2mb4wz8ugMoaEjsa+2cIFpMlopNkg -WEc8pWrZgw1zpU37Q6riWlaj04Kb7Y+sb2NOdA+ZAoGBAKXgQRVBUECfqYuHaSgP -BkF6SEnD+tCmEnp90NHpSswHUqeEGxbqPwj4zpxaAw6UBdGFtDgREmDNFeyrJMBj -koxpNyC2ZWgNDOJSxSCCnC4BSMeJNC1mOKmvx8HSXk0fLjzqymLfJI691B38GXyq -oIU51zOAgvknQMLr2aNPzPZJ ------END PRIVATE KEY----- -"#; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[test] fn parses_custom_port() { @@ -306,80 +112,52 @@ oIU51zOAgvknQMLr2aNPzPZJ assert_eq!(args.port, 4001); } - #[cfg(not(target_vendor = "apple"))] - #[test] - fn requires_certificate_and_key_for_tls() { - let error = build_tls_acceptor(None, None) - .err() - .expect("TLS configuration without certificate inputs must fail"); - assert!(error.to_string().contains("--no-tls")); - } - - #[cfg(not(target_vendor = "apple"))] - #[test] - fn rejects_partial_certificate_inputs() { - let error = build_tls_acceptor(Some("certificate.pem"), None) - .err() - .expect("TLS configuration with only one PEM path must fail"); - assert!(error.to_string().contains("provided together")); - } - - #[cfg(target_vendor = "apple")] - #[test] - fn rejects_apple_https_before_validating_inputs() { - let error = build_tls_acceptor(Some("missing-certificate.pem"), Some("missing-key.pem")) - .err() - .expect("direct HTTPS must fail on Apple targets"); - assert!(error.to_string().contains("unavailable on Apple platforms")); - } - - #[test] - fn selects_http2_for_h2_alpn() { - assert_eq!(protocol_from_alpn(Some(b"h2")), Protocol::Http2); - } - - #[test] - fn selects_http1_without_h2_alpn() { - assert_eq!(protocol_from_alpn(Some(b"http/1.1")), Protocol::Http1); - assert_eq!(protocol_from_alpn(None), Protocol::Http1); - } - - #[cfg(not(target_vendor = "apple"))] - #[actix_web::test] - async fn serves_http2_through_native_tls_service() -> Result<()> { + 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 state = web::Data::new(app_state); - let tls = build_tls_acceptor_from_pem(TEST_CERTIFICATE_PEM, TEST_PRIVATE_KEY_PEM)?; let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; let port = listener.local_addr()?.port(); - let server = build_https_server(listener, port, state, tls)?; + let server = build_server(listener, web::Data::new(app_state))?; let handle = server.handle(); actix_web::rt::spawn(server); + Ok((port, handle)) + } - let mut connector = native_tls::TlsConnector::builder(); - connector.add_root_certificate(Certificate::from_pem(TEST_CERTIFICATE_PEM)?); - connector.request_alpns(&["h2"]); - let connector = actix_tls::connect::native_tls::TlsConnector::new(connector.build()?); - let tcp = actix_web::rt::net::TcpStream::connect(("127.0.0.1", port)).await?; - let connection = actix_tls::connect::Connection::new(String::from("localhost"), tcp); - let tls = connector.call(connection).await?.into_parts().0; - assert_eq!(tls.get_ref().negotiated_alpn()?, Some(b"h2".to_vec())); + #[actix_web::test] + async fn serves_http2_prior_knowledge_over_cleartext() -> Result<()> { + let (port, handle) = start_server()?; - let (mut client, connection) = h2::client::handshake(tls).await?; + 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!("https://localhost:{port}/")) + .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/app/commerce/tests/commerce.spec.ts b/examples/app/commerce/tests/commerce.spec.ts index 8432baa1b..a665909b8 100644 --- a/examples/app/commerce/tests/commerce.spec.ts +++ b/examples/app/commerce/tests/commerce.spec.ts @@ -8,7 +8,7 @@ * category switching, product pages, and visual regression screenshots. * * Start the server before running: - * cd examples/app/commerce && cargo run -p marketplace-api --release -- --port 3004 --no-tls + * cd examples/app/commerce && cargo run -p marketplace-api --release -- --port 3004 */ import { test, expect, type Page } from '@playwright/test'; diff --git a/scripts/setup-linux-desktop-target.sh b/scripts/setup-linux-desktop-target.sh index a5735f86d..587a0722d 100644 --- a/scripts/setup-linux-desktop-target.sh +++ b/scripts/setup-linux-desktop-target.sh @@ -8,7 +8,7 @@ set -euo pipefail target="${1:?pass a Linux Rust target triple}" if [[ "$target" == x86_64-unknown-linux-gnu ]]; then sudo apt-get update -q - sudo apt-get install -y -q protobuf-compiler pkg-config libgtk-4-dev libwebkitgtk-6.0-dev libssl-dev + sudo apt-get install -y -q protobuf-compiler pkg-config libgtk-4-dev libwebkitgtk-6.0-dev elif [[ "$target" == aarch64-unknown-linux-gnu ]]; then source /etc/os-release if [[ "$ID" != ubuntu || -z "${VERSION_CODENAME:-}" ]]; then @@ -49,7 +49,7 @@ EOF sudo apt-get "${apt_options[@]}" update -q sudo apt-get "${apt_options[@]}" install -y -q \ protobuf-compiler pkg-config gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \ - libgtk-4-dev:arm64 libwebkitgtk-6.0-dev:arm64 libssl-dev:arm64 + libgtk-4-dev:arm64 libwebkitgtk-6.0-dev:arm64 rm "$sources" export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc export CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc @@ -57,7 +57,7 @@ EOF export PKG_CONFIG_ALLOW_CROSS=1 unset PKG_CONFIG_PATH PKG_CONFIG_SYSROOT_DIR export PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig - for pc in gtk4 webkitgtk-6.0 openssl; do + for pc in gtk4 webkitgtk-6.0; do pcdir="$(pkg-config --variable=pcfiledir "$pc")" if [[ "$pcdir" != /usr/lib/aarch64-linux-gnu/pkgconfig ]]; then echo "$pc resolved from unexpected directory: $pcdir" >&2