From 46c77ec8d351e92215116c9ca9586a6c55d2143f Mon Sep 17 00:00:00 2001 From: lukekania Date: Mon, 8 Jun 2026 15:38:35 +0200 Subject: [PATCH] feat(dev-server): support ssl/sslKey/sslCert for HTTPS dev (#142) The dev-server builder hard-failed whenever ssl/sslKey/sslCert was set, so projects needing HTTPS in dev (OAuth callbacks, secure-cookie testing, mixed-content debugging, service-worker registration) had to stand up a separate TLS-terminating proxy. ngc-rs now serves HTTPS directly. - dev-server: new TlsConfig (from_pem for explicit material, self_signed via rcgen for the auto-generated case, covering the bind host plus the loopback names). DevServerConfig::with_tls wires the PEM into tiny_http's ssl-rustls backend; the SSE live-reload stream rides the same TLS connection. DevServer::scheme() reports https/http. The private key is redacted from Debug output. - cli: --ssl/--ssl-key/--ssl-cert on serve. resolve_tls enforces both-or- neither cert paths and that key/cert require --ssl; the printed URL uses the right scheme. - builder: options.ts forwards the flags, resolves cert paths against the workspace root, emits an https:// URL, and rejects ssl + proxyConfig (the proxy is the browser-facing endpoint; Node-side TLS termination is out of scope). Schema descriptions updated; README serve section corrected. ssl: true without explicit key/cert mints a throwaway self-signed certificate, matching @angular/build:dev-server; browsers show the usual untrusted-cert warning. Bump version to 0.10.16. --- Cargo.lock | 584 +++++++++++++++++- Cargo.toml | 2 +- README.md | 7 +- crates/cli/src/main.rs | 23 + crates/cli/src/serve_cmd.rs | 122 +++- crates/dev-server/Cargo.toml | 5 +- crates/dev-server/src/lib.rs | 155 ++++- crates/dev-server/tests/integration.rs | 202 ++++++ packages/builder/schemas/dev-server.json | 6 +- .../src/serve/__tests__/options.test.ts | 53 +- packages/builder/src/serve/options.ts | 65 +- 11 files changed, 1172 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9324fa0..25bd796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,12 +85,57 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[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", + "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", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -107,6 +152,15 @@ dependencies = [ "vsimd", ] +[[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" @@ -147,6 +201,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "castaway" version = "0.2.4" @@ -156,6 +216,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -347,6 +417,35 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -369,6 +468,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dragonbox_ecma" version = "0.1.12" @@ -432,6 +542,12 @@ dependencies = [ "libredox", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -473,6 +589,17 @@ dependencies = [ "version_check", ] +[[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.4.2" @@ -614,6 +741,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "json-escape-simd" version = "3.0.1" @@ -706,6 +844,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[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.8.9" @@ -730,7 +874,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.15" +version = "0.10.16" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,9 +899,11 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.15" +version = "0.10.16" dependencies = [ "ngc-diagnostics", + "rcgen", + "rustls", "serde_json", "tempfile", "tiny_http", @@ -766,7 +912,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.15" +version = "0.10.16" dependencies = [ "serde_json", "thiserror", @@ -774,7 +920,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.15" +version = "0.10.16" dependencies = [ "dashmap", "insta", @@ -792,7 +938,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.15" +version = "0.10.16" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +953,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.15" +version = "0.10.16" dependencies = [ "dashmap", "glob", @@ -823,9 +969,9 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.15" +version = "0.10.16" dependencies = [ - "base64", + "base64 0.22.1", "clap", "colored", "ctrlc", @@ -857,7 +1003,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.15" +version = "0.10.16" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +1025,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.15" +version = "0.10.16" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +1044,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.15" +version = "0.10.16" dependencies = [ "ngc-diagnostics", "notify", @@ -918,6 +1064,16 @@ dependencies = [ "libc", ] +[[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 = "nonmax" version = "0.5.5" @@ -961,6 +1117,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -994,6 +1156,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[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" @@ -1362,7 +1533,7 @@ version = "0.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a216c0a1291fcb42f6be51ce32d928921cf2a6e232e43e6339c8e48d0e4048f" dependencies = [ - "base64", + "base64 0.22.1", "compact_str", "indexmap", "itoa", @@ -1417,6 +1588,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1545,6 +1726,12 @@ dependencies = [ "serde", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1599,6 +1786,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring 0.17.14", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1646,6 +1847,35 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "ropey" version = "1.6.1" @@ -1662,6 +1892,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[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" @@ -1675,6 +1914,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + +[[package]] +name = "rustls-pemfile" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9" +dependencies = [ + "base64 0.13.1", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1702,6 +1971,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -1795,6 +2074,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1825,6 +2110,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1854,6 +2145,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1861,7 +2163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1907,6 +2209,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -1917,6 +2250,9 @@ dependencies = [ "chunked_transfer", "httpdate", "log", + "rustls", + "rustls-pemfile", + "zeroize", ] [[package]] @@ -2028,6 +2364,18 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2086,6 +2434,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2120,6 +2513,26 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "which" version = "8.0.2" @@ -2129,6 +2542,22 @@ dependencies = [ "libc", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2138,6 +2567,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -2150,7 +2585,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -2168,13 +2612,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "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]] @@ -2183,42 +2643,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2307,6 +2815,40 @@ dependencies = [ "wasmparser", ] +[[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 0.17.14", + "rusticata-macros", + "thiserror", + "time", +] + +[[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 = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 4ca96ca..b76a437 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/cli", "crates/diagnostics", "crates/project-resolver", "crates/ts-transform", "crates/bundler", "crates/template-compiler", "crates/npm-resolver", "crates/linker", "crates/watch", "crates/dev-server"] [workspace.package] -version = "0.10.15" +version = "0.10.16" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] diff --git a/README.md b/README.md index 5b863e3..c530ce6 100644 --- a/README.md +++ b/README.md @@ -116,11 +116,16 @@ When an `angular.json` is found, ngc-rs reads styles, assets, polyfills, and fil ### `ngc-rs serve` -Build the project, watch for source changes, and host `dist/` over HTTP with live reload — the `ng serve` equivalent for everyday Angular development: +Build the project, watch for source changes, and host `dist/` over HTTP (or HTTPS) with live reload — the `ng serve` equivalent for everyday Angular development: ```sh ngc-rs serve --project tsconfig.app.json ngc-rs serve --project tsconfig.app.json --host 0.0.0.0 --port 4300 --open + +# HTTPS with an auto-generated self-signed certificate (browsers show the +# usual untrusted-certificate warning), or pass your own cert/key: +ngc-rs serve --project tsconfig.app.json --ssl +ngc-rs serve --project tsconfig.app.json --ssl --ssl-key dev.key --ssl-cert dev.crt ``` ## Benchmark comparison diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3abff63..44c41cc 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -262,6 +262,23 @@ enum Commands { /// overridden by these. #[arg(long = "headers")] headers: Option, + /// Serve over HTTPS instead of HTTP. When set without `--ssl-key` + /// and `--ssl-cert`, a throwaway self-signed certificate is + /// generated for the bind host plus the loopback names; browsers + /// show the usual untrusted-certificate warning. Mirrors the `ssl` + /// option of `@angular/build:dev-server`. + #[arg(long)] + ssl: bool, + /// Path to a PEM-encoded private key for HTTPS. Requires `--ssl` and + /// `--ssl-cert`. Mirrors the `sslKey` option of + /// `@angular/build:dev-server`. + #[arg(long = "ssl-key")] + ssl_key: Option, + /// Path to a PEM-encoded certificate for HTTPS. Requires `--ssl` and + /// `--ssl-key`. Mirrors the `sslCert` option of + /// `@angular/build:dev-server`. + #[arg(long = "ssl-cert")] + ssl_cert: Option, }, /// Extract translatable messages from every component template in the /// project and emit a translation file (XLIFF 2.0 by default; XLIFF 1.2 @@ -390,6 +407,9 @@ fn main() { serve_path, allowed_hosts, headers, + ssl, + ssl_key, + ssl_cert, } => { let parsed_headers = match parse_header_overrides(headers.as_deref()) { Ok(h) => h, @@ -407,6 +427,9 @@ fn main() { serve_path.as_deref(), &allowed_hosts, &parsed_headers, + ssl, + ssl_key.as_deref(), + ssl_cert.as_deref(), ) { eprintln!("{} {e}", "Error:".red().bold()); process::exit(1); diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index 7ea2119..f8465d7 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -18,7 +18,7 @@ use std::sync::mpsc::channel; use std::sync::Arc; use colored::Colorize; -use ngc_dev_server::{DevServer, DevServerConfig, DevServerEvent}; +use ngc_dev_server::{DevServer, DevServerConfig, DevServerEvent, TlsConfig}; use ngc_diagnostics::{NgcError, NgcResult}; use ngc_watch::{Watcher, WatcherConfig}; @@ -37,6 +37,9 @@ pub fn run( serve_path: Option<&str>, allowed_hosts: &[String], headers: &[(String, String)], + ssl: bool, + ssl_key: Option<&Path>, + ssl_cert: Option<&Path>, ) -> NgcResult<()> { run_with_stop( project, @@ -47,10 +50,60 @@ pub fn run( serve_path, allowed_hosts, headers, + ssl, + ssl_key, + ssl_cert, install_ctrlc, ) } +/// Resolve the `--ssl`/`--ssl-key`/`--ssl-cert` flags into an optional +/// [`TlsConfig`]. +/// +/// * `ssl` off → `None` (plain HTTP), and supplying a key/cert path without +/// `--ssl` is rejected so a typo doesn't silently serve over HTTP. +/// * `ssl` on with both a key and cert path → load that PEM material. +/// * `ssl` on with only one of the two → an error, since both halves are +/// required. +/// * `ssl` on with neither → mint a throwaway self-signed certificate for +/// the bind host (matching `@angular/build:dev-server`). +fn resolve_tls( + ssl: bool, + ssl_key: Option<&Path>, + ssl_cert: Option<&Path>, + host: &str, +) -> NgcResult> { + if !ssl { + if ssl_key.is_some() || ssl_cert.is_some() { + return Err(NgcError::ServeError { + message: "--ssl-key/--ssl-cert require --ssl to be set".to_string(), + }); + } + return Ok(None); + } + + match (ssl_key, ssl_cert) { + (Some(key_path), Some(cert_path)) => { + let key_pem = std::fs::read(key_path).map_err(|source| NgcError::Io { + path: key_path.to_path_buf(), + source, + })?; + let cert_pem = std::fs::read(cert_path).map_err(|source| NgcError::Io { + path: cert_path.to_path_buf(), + source, + })?; + Ok(Some(TlsConfig::from_pem(cert_pem, key_pem))) + } + (None, None) => Ok(Some(TlsConfig::self_signed(&[host.to_string()])?)), + (Some(_), None) => Err(NgcError::ServeError { + message: "--ssl-key was set without --ssl-cert; both are required".to_string(), + }), + (None, Some(_)) => Err(NgcError::ServeError { + message: "--ssl-cert was set without --ssl-key; both are required".to_string(), + }), + } +} + /// Variant of [`run`] that lets the caller decide how the shutdown flag is /// armed. Tests use a no-op installer so the watcher loop can be exited via /// the returned [`Arc`] without touching the real signal @@ -65,8 +118,13 @@ pub(crate) fn run_with_stop( serve_path: Option<&str>, allowed_hosts: &[String], headers: &[(String, String)], + ssl: bool, + ssl_key: Option<&Path>, + ssl_cert: Option<&Path>, install_stop: impl FnOnce(Arc), ) -> NgcResult<()> { + let tls = resolve_tls(ssl, ssl_key, ssl_cert, host)?; + let out_dir = crate::resolve_out_dir(project, None, configuration)?; let mut cache = BuildCache::new(); @@ -100,11 +158,13 @@ pub(crate) fn run_with_stop( .with_port(port) .with_serve_path(normalized_serve_path.as_deref()) .with_allowed_hosts(allowed_hosts.iter().cloned()) - .with_headers(headers.iter().cloned()); + .with_headers(headers.iter().cloned()) + .with_tls(tls); let server = DevServer::start(cfg, event_rx)?; + let scheme = server.scheme(); let url = match server.serve_path() { - Some(prefix) => format!("http://{}{}", server.addr(), prefix), - None => format!("http://{}", server.addr()), + Some(prefix) => format!("{scheme}://{}{}", server.addr(), prefix), + None => format!("{scheme}://{}", server.addr()), }; eprintln!( "{} {}", @@ -335,6 +395,60 @@ mod tests { assert_eq!(file.as_deref(), Some(Path::new("/proj/src/app.ts"))); } + #[test] + fn resolve_tls_disabled_returns_none() { + assert!(resolve_tls(false, None, None, "localhost") + .expect("ok") + .is_none()); + } + + #[test] + fn resolve_tls_rejects_key_or_cert_without_ssl() { + assert!(resolve_tls(false, Some(Path::new("/k")), None, "localhost").is_err()); + assert!(resolve_tls(false, None, Some(Path::new("/c")), "localhost").is_err()); + } + + #[test] + fn resolve_tls_auto_generates_when_ssl_without_paths() { + let tls = resolve_tls(true, None, None, "localhost") + .expect("ok") + .expect("some tls"); + // Round-trips through the dev server's SslConfig as PEM bytes; just + // confirm something was minted. + let _ = tls; // opaque material; presence is the assertion + } + + #[test] + fn resolve_tls_requires_both_key_and_cert() { + assert!(resolve_tls(true, Some(Path::new("/k")), None, "localhost").is_err()); + assert!(resolve_tls(true, None, Some(Path::new("/c")), "localhost").is_err()); + } + + #[test] + fn resolve_tls_reads_explicit_pem_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let key = dir.path().join("dev.key"); + let cert = dir.path().join("dev.crt"); + std::fs::write(&key, b"KEYDATA").expect("write key"); + std::fs::write(&cert, b"CERTDATA").expect("write cert"); + let tls = resolve_tls(true, Some(&key), Some(&cert), "localhost") + .expect("ok") + .expect("some tls"); + let _ = tls; + } + + #[test] + fn resolve_tls_errors_when_explicit_pem_missing() { + let err = resolve_tls( + true, + Some(Path::new("/no/such/key.pem")), + Some(Path::new("/no/such/cert.pem")), + "localhost", + ) + .expect_err("missing file should error"); + assert!(matches!(err, NgcError::Io { .. })); + } + #[test] fn build_failure_event_omits_path_for_pathless_errors() { let err = NgcError::ServeError { diff --git a/crates/dev-server/Cargo.toml b/crates/dev-server/Cargo.toml index 5af3598..cbb1642 100644 --- a/crates/dev-server/Cargo.toml +++ b/crates/dev-server/Cargo.toml @@ -10,10 +10,13 @@ publish = false [dependencies] ngc-diagnostics = { path = "../diagnostics" } +rcgen = "0.14.8" serde_json = "1.0" -tiny_http = "0.12" +tiny_http = { version = "0.12", features = ["ssl-rustls"] } tracing = "0.1" [dev-dependencies] +rcgen = "0.14.8" +rustls = { version = "0.20", features = ["dangerous_configuration"] } serde_json = "1.0" tempfile = "3" diff --git a/crates/dev-server/src/lib.rs b/crates/dev-server/src/lib.rs index 65b6b35..890af2d 100644 --- a/crates/dev-server/src/lib.rs +++ b/crates/dev-server/src/lib.rs @@ -35,7 +35,7 @@ use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use ngc_diagnostics::{NgcError, NgcResult}; -use tiny_http::{Header, Method, Response, Server, StatusCode}; +use tiny_http::{Header, Method, Response, Server, SslConfig, StatusCode}; /// An event the dev server fans out to connected browsers over SSE. /// @@ -102,6 +102,11 @@ pub struct DevServerConfig { /// `headers` option. Header names the server sets itself are never /// overridden by these — see [`CustomHeaders`]. pub headers: Vec<(String, String)>, + /// TLS material to serve over HTTPS. When `None` (the default) the + /// server speaks plain HTTP. When `Some`, every connection — including + /// the long-lived SSE live-reload stream — is wrapped in TLS. Mirrors + /// `@angular/build:dev-server`'s `ssl`/`sslKey`/`sslCert` options. + pub tls: Option, } impl DevServerConfig { @@ -115,6 +120,7 @@ impl DevServerConfig { serve_path: None, allowed_hosts: Vec::new(), headers: Vec::new(), + tls: None, } } @@ -164,6 +170,86 @@ impl DevServerConfig { .collect(); self } + + /// Serve over HTTPS using the supplied [`TlsConfig`]. Passing `None` + /// (the default) keeps the server on plain HTTP. + pub fn with_tls(mut self, tls: Option) -> Self { + self.tls = tls; + self + } +} + +/// PEM-encoded TLS material used to serve the dev server over HTTPS. +/// +/// Construct one either from caller-supplied certificate and key files +/// ([`TlsConfig::from_pem`]) or by minting a throwaway self-signed +/// certificate for local development ([`TlsConfig::self_signed`]). The bytes +/// are handed to `tiny_http`'s `ssl-rustls` backend, which performs the TLS +/// handshake for every accepted connection. +#[derive(Clone)] +pub struct TlsConfig { + /// PEM-encoded certificate (chain). + cert_pem: Vec, + /// PEM-encoded private key. + key_pem: Vec, +} + +impl TlsConfig { + /// Wrap caller-supplied PEM bytes (e.g. read from `sslCert`/`sslKey` + /// files) without inspecting them — `tiny_http` validates the material + /// when the server is created and surfaces a clear error if either is + /// malformed. + pub fn from_pem(cert_pem: Vec, key_pem: Vec) -> Self { + Self { cert_pem, key_pem } + } + + /// Generate a throwaway self-signed certificate covering `hosts` plus the + /// loopback names (`localhost`, `127.0.0.1`, `::1`), matching what + /// `@angular/build:dev-server` does when `ssl: true` is set without an + /// explicit key/cert. Browsers will show the usual "untrusted + /// certificate" warning the first time. + /// + /// Each host string is added as an IP SAN when it parses as an IP + /// address and a DNS SAN otherwise, so `--host 192.168.1.10` produces a + /// certificate the browser accepts for that address. + pub fn self_signed(hosts: &[String]) -> NgcResult { + let mut sans: Vec = vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + ]; + for host in hosts { + let trimmed = host.trim(); + // Skip blanks and wildcard binds — `0.0.0.0`/`::` are never a + // hostname the browser connects to, and the loopback SANs above + // already cover local development. + if trimmed.is_empty() || matches!(trimmed, "0.0.0.0" | "::" | "[::]") { + continue; + } + let normalized = trimmed.trim_start_matches('[').trim_end_matches(']'); + if !sans.iter().any(|s| s == normalized) { + sans.push(normalized.to_string()); + } + } + let cert = rcgen::generate_simple_self_signed(sans).map_err(|e| NgcError::ServeError { + message: format!("could not generate self-signed certificate: {e}"), + })?; + Ok(Self { + cert_pem: cert.cert.pem().into_bytes(), + key_pem: cert.signing_key.serialize_pem().into_bytes(), + }) + } +} + +// Hand-written so the private key never lands in a `Debug` dump (e.g. when +// `DevServerConfig` is logged). +impl std::fmt::Debug for TlsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsConfig") + .field("cert_pem", &format_args!("{} bytes", self.cert_pem.len())) + .field("key_pem", &"") + .finish() + } } /// Normalize a `servePath` string to the canonical `/foo/` form. @@ -417,6 +503,7 @@ pub struct DevServer { server: Arc, accept_join: Option>, serve_path: Option, + is_tls: bool, } impl DevServer { @@ -449,9 +536,15 @@ impl DevServer { message: format!("could not read local address: {e}"), })?; - let server = Server::from_listener(listener, None).map_err(|e| NgcError::ServeError { - message: format!("tiny_http server init failed: {e}"), - })?; + let is_tls = config.tls.is_some(); + let ssl_config = config.tls.as_ref().map(|t| SslConfig { + certificate: t.cert_pem.clone(), + private_key: t.key_pem.clone(), + }); + let server = + Server::from_listener(listener, ssl_config).map_err(|e| NgcError::ServeError { + message: format!("tiny_http server init failed: {e}"), + })?; let server = Arc::new(server); let clients: SseClients = Arc::new(Mutex::new(Vec::new())); @@ -492,6 +585,7 @@ impl DevServer { server, accept_join: Some(join), serve_path, + is_tls, }) } @@ -507,6 +601,17 @@ impl DevServer { self.serve_path.as_deref() } + /// The URL scheme the server answers on: `"https"` when TLS is enabled, + /// `"http"` otherwise. Use this to build a browser-facing URL that + /// matches the wire protocol. + pub fn scheme(&self) -> &'static str { + if self.is_tls { + "https" + } else { + "http" + } + } + /// Send a reload event to all connected browsers without going through /// an external channel. Convenient for tests and ad-hoc tooling. pub fn trigger_reload(&self) -> NgcResult<()> { @@ -1483,4 +1588,46 @@ mod tests { assert_eq!(cfg.headers.len(), 2); assert_eq!(cfg.headers[0], ("X-A".to_string(), "1".to_string())); } + + #[test] + fn devserver_config_tls_defaults_to_none() { + assert!(DevServerConfig::new("/tmp/dist").tls.is_none()); + } + + #[test] + fn devserver_config_with_tls_stores_material() { + let tls = TlsConfig::from_pem(b"CERT".to_vec(), b"KEY".to_vec()); + let cfg = DevServerConfig::new("/tmp/dist").with_tls(Some(tls)); + let stored = cfg.tls.expect("tls present"); + assert_eq!(stored.cert_pem, b"CERT"); + assert_eq!(stored.key_pem, b"KEY"); + } + + #[test] + fn tls_self_signed_emits_pem_for_cert_and_key() { + let tls = TlsConfig::self_signed(&["app.local".to_string()]).expect("generate"); + let cert = String::from_utf8(tls.cert_pem.clone()).expect("utf8 cert"); + let key = String::from_utf8(tls.key_pem.clone()).expect("utf8 key"); + assert!(cert.contains("BEGIN CERTIFICATE")); + assert!(cert.contains("END CERTIFICATE")); + assert!(key.contains("PRIVATE KEY")); + } + + #[test] + fn tls_self_signed_skips_blank_and_wildcard_hosts() { + // Should not error on wildcard/blank binds — they're dropped and the + // loopback SANs still cover local development. + let tls = + TlsConfig::self_signed(&["0.0.0.0".to_string(), "".to_string(), "::".to_string()]) + .expect("generate"); + assert!(!tls.cert_pem.is_empty()); + } + + #[test] + fn tls_config_debug_redacts_private_key() { + let tls = TlsConfig::from_pem(b"CERTBYTES".to_vec(), b"SECRETKEY".to_vec()); + let rendered = format!("{tls:?}"); + assert!(rendered.contains("redacted")); + assert!(!rendered.contains("SECRETKEY")); + } } diff --git a/crates/dev-server/tests/integration.rs b/crates/dev-server/tests/integration.rs index 3f26f14..fa3ac40 100644 --- a/crates/dev-server/tests/integration.rs +++ b/crates/dev-server/tests/integration.rs @@ -720,3 +720,205 @@ fn custom_headers_are_emitted_on_the_sse_stream() { } assert!(saw_header, "custom header missing from SSE response head"); } + +// ---------------------------------------------------------------------------- +// HTTPS / TLS (#142) +// +// These tests stand up a dev server with a throwaway self-signed certificate +// and drive it through a rustls client that skips certificate verification — +// the equivalent of clicking through the browser's untrusted-certificate +// warning. They confirm both ordinary static serving and the long-lived SSE +// live-reload stream work once the connection is wrapped in TLS. +// ---------------------------------------------------------------------------- + +mod tls { + use std::io::{BufRead, BufReader, Read, Write}; + use std::net::TcpStream; + use std::sync::mpsc::channel; + use std::sync::Arc; + use std::time::Duration; + + use ngc_dev_server::{ + DevServer, DevServerConfig, DevServerEvent, TlsConfig, LIVE_RELOAD_SCRIPT, + }; + use rustls::{ClientConfig, ClientConnection, StreamOwned}; + use tempfile::TempDir; + + struct TlsFixture { + server: DevServer, + _root: TempDir, + } + + impl TlsFixture { + fn new() -> Self { + let root = TempDir::new().expect("tempdir"); + std::fs::write( + root.path().join("index.html"), + b"

secure

", + ) + .expect("write index"); + let tls = TlsConfig::self_signed(&["127.0.0.1".to_string()]).expect("self-signed"); + let cfg = DevServerConfig::new(root.path()) + .with_port(0) + .with_tls(Some(tls)); + let (_tx, rx) = channel::(); + let server = DevServer::start(cfg, rx).expect("start tls dev server"); + Self { + server, + _root: root, + } + } + } + + // A certificate verifier that accepts everything — the test cert is + // self-signed and not in any trust store, which is exactly the dev + // workflow this feature targets. + struct NoVerify; + + impl rustls::client::ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &rustls::Certificate, + _intermediates: &[rustls::Certificate], + _server_name: &rustls::ServerName, + _scts: &mut dyn Iterator, + _ocsp_response: &[u8], + _now: std::time::SystemTime, + ) -> Result { + Ok(rustls::client::ServerCertVerified::assertion()) + } + } + + fn tls_stream(addr: std::net::SocketAddr) -> StreamOwned { + let config = ClientConfig::builder() + .with_safe_defaults() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth(); + let server_name = rustls::ServerName::try_from("localhost").expect("server name"); + let conn = ClientConnection::new(Arc::new(config), server_name).expect("client conn"); + let sock = TcpStream::connect(addr).expect("connect"); + sock.set_read_timeout(Some(Duration::from_secs(5))) + .expect("read timeout"); + StreamOwned::new(conn, sock) + } + + #[test] + fn serves_index_over_https_with_injected_live_reload_script() { + let fx = TlsFixture::new(); + let mut stream = tls_stream(fx.server.addr()); + let req = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + stream.write_all(req.as_bytes()).expect("write"); + stream.flush().expect("flush"); + + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).expect("read response"); + let text = String::from_utf8_lossy(&raw); + + let status_line = text.lines().next().expect("status line"); + assert!(status_line.contains("200"), "status was {status_line}"); + // The SPA index is served and the live-reload client is injected, + // proving TLS framing of an ordinary file response works. + assert!(text.contains("

secure

"), "body missing app markup"); + assert!( + text.contains(LIVE_RELOAD_SCRIPT), + "live-reload script not injected over https" + ); + } + + #[test] + fn scheme_reports_https_when_tls_enabled() { + let fx = TlsFixture::new(); + assert_eq!(fx.server.scheme(), "https"); + } + + #[test] + fn sse_live_reload_stream_works_over_https() { + let fx = TlsFixture::new(); + let stream = tls_stream(fx.server.addr()); + let mut writer = stream; + let req = + "GET /__ngc_reload HTTP/1.1\r\nHost: localhost\r\nAccept: text/event-stream\r\n\r\n"; + writer.write_all(req.as_bytes()).expect("write"); + writer.flush().expect("flush"); + + let mut reader = BufReader::new(writer); + let mut status_line = String::new(); + reader.read_line(&mut status_line).expect("status line"); + assert!(status_line.contains("200"), "status was {status_line}"); + + let mut saw_event_stream = false; + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).expect("header"); + if n == 0 || line == "\r\n" { + break; + } + if line.to_ascii_lowercase().contains("text/event-stream") { + saw_event_stream = true; + } + } + assert!( + saw_event_stream, + "missing event-stream content type over tls" + ); + + let mut connected = String::new(); + reader.read_line(&mut connected).expect("connected"); + assert!(connected.starts_with(": connected"), "got {connected:?}"); + let mut blank = String::new(); + reader.read_line(&mut blank).expect("blank"); + + std::thread::sleep(Duration::from_millis(100)); + fx.server.trigger_reload().expect("trigger reload"); + + let mut event = String::new(); + reader.read_line(&mut event).expect("event line"); + assert_eq!(event, "event: reload\n"); + let mut data = String::new(); + reader.read_line(&mut data).expect("data line"); + assert_eq!(data, "data: rebuild\n"); + } + + #[test] + fn serves_over_https_with_explicit_cert_and_key() { + // Mint a cert/key pair and feed the raw PEM through `from_pem` — the + // path explicit sslKey/sslCert files take — then confirm the server + // comes up and serves over TLS. + let ck = rcgen_pair(); + let root = TempDir::new().expect("tempdir"); + std::fs::write( + root.path().join("index.html"), + b"ok", + ) + .expect("write index"); + let cfg = DevServerConfig::new(root.path()) + .with_port(0) + .with_tls(Some(TlsConfig::from_pem(ck.0, ck.1))); + let (_tx, rx) = channel::(); + let server = DevServer::start(cfg, rx).expect("start with explicit pem"); + + let mut stream = tls_stream(server.addr()); + let req = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + stream.write_all(req.as_bytes()).expect("write"); + stream.flush().expect("flush"); + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).expect("read"); + let text = String::from_utf8_lossy(&raw); + assert!(text.lines().next().unwrap_or("").contains("200")); + } + + // Generate a (cert_pem, key_pem) pair the same way the production + // self-signed path does, but expose the raw PEM so the test can feed it + // through `TlsConfig::from_pem`. + fn rcgen_pair() -> (Vec, Vec) { + let ck = rcgen::generate_simple_self_signed(vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + ]) + .expect("rcgen"); + ( + ck.cert.pem().into_bytes(), + ck.signing_key.serialize_pem().into_bytes(), + ) + } +} diff --git a/packages/builder/schemas/dev-server.json b/packages/builder/schemas/dev-server.json index 324fe32..3396d2f 100644 --- a/packages/builder/schemas/dev-server.json +++ b/packages/builder/schemas/dev-server.json @@ -27,16 +27,16 @@ }, "ssl": { "type": "boolean", - "description": "Serve over HTTPS. Currently unsupported by ngc-rs serve. Setting this to true fails the build with an explanatory error.", + "description": "Serve over HTTPS. When set without sslKey/sslCert, the dev server generates a throwaway self-signed certificate for the bind host plus the loopback names; browsers show the usual untrusted-certificate warning. Cannot be combined with proxyConfig — terminate TLS at the proxy or drop the proxy when enabling ssl.", "default": false }, "sslKey": { "type": "string", - "description": "SSL key path. Currently unsupported." + "description": "Path to a PEM-encoded private key used when ssl is true. Resolved relative to the workspace root. Requires sslCert." }, "sslCert": { "type": "string", - "description": "SSL certificate path. Currently unsupported." + "description": "Path to a PEM-encoded certificate used when ssl is true. Resolved relative to the workspace root. Requires sslKey." }, "proxyConfig": { "type": "string", diff --git a/packages/builder/src/serve/__tests__/options.test.ts b/packages/builder/src/serve/__tests__/options.test.ts index 584c5ba..e228107 100644 --- a/packages/builder/src/serve/__tests__/options.test.ts +++ b/packages/builder/src/serve/__tests__/options.test.ts @@ -51,18 +51,55 @@ describe('translateOptions', () => { expect(t.args[portIdx + 1]).toBe('0'); }); - it('rejects ssl=true with a clear error', () => { + it('forwards --ssl and uses an https url when ssl is true without key/cert', () => { + const t = translateOptions({ ...base, ssl: true }, '/ws'); + expect(t.args).toContain('--ssl'); + expect(t.args).not.toContain('--ssl-key'); + expect(t.args).not.toContain('--ssl-cert'); + expect(t.url).toBe('https://localhost:4200/'); + }); + + it('forwards resolved --ssl-key/--ssl-cert when both are provided', () => { + const t = translateOptions( + { ...base, ssl: true, sslKey: 'certs/dev.key', sslCert: 'certs/dev.crt' }, + '/ws', + ); + expect(t.args).toContain('--ssl'); + const keyIdx = t.args.indexOf('--ssl-key'); + const certIdx = t.args.indexOf('--ssl-cert'); + expect(t.args[keyIdx + 1]).toBe('/ws/certs/dev.key'); + expect(t.args[certIdx + 1]).toBe('/ws/certs/dev.crt'); + expect(t.url).toBe('https://localhost:4200/'); + }); + + it('throws when only one of sslKey/sslCert is provided', () => { + expect(() => + translateOptions({ ...base, ssl: true, sslKey: '/k' }, '/ws'), + ).toThrow(OptionTranslationError); expect(() => - translateOptions({ ...base, ssl: true }, '/ws'), + translateOptions({ ...base, ssl: true, sslCert: '/c' }, '/ws'), ).toThrow(OptionTranslationError); }); - it('rejects sslKey/sslCert', () => { + it('rejects ssl combined with proxyConfig', () => { expect(() => - translateOptions({ ...base, sslKey: '/k' }, '/ws'), + translateOptions( + { ...base, ssl: true, proxyConfig: 'proxy.conf.json' }, + '/ws', + ), ).toThrow(OptionTranslationError); }); + it('ignores sslKey/sslCert and stays on http when ssl is not enabled', () => { + const t = translateOptions( + { ...base, sslKey: 'certs/dev.key', sslCert: 'certs/dev.crt' }, + '/ws', + ); + expect(t.args).not.toContain('--ssl'); + expect(t.args).not.toContain('--ssl-key'); + expect(t.url).toBe('http://localhost:4200/'); + }); + it('honors a custom project tsconfig', () => { const t = translateOptions( { ...base, project: 'tsconfig.app.json' }, @@ -203,4 +240,12 @@ describe('formatUrl', () => { 'http://localhost:4200/admin/', ); }); + it('uses the https scheme when requested', () => { + expect(formatUrl('localhost', 4200, null, 'https')).toBe( + 'https://localhost:4200/', + ); + expect(formatUrl('app.local', 8080, '/admin/', 'https')).toBe( + 'https://app.local:8080/admin/', + ); + }); }); diff --git a/packages/builder/src/serve/options.ts b/packages/builder/src/serve/options.ts index c3e4d62..c22e9b5 100644 --- a/packages/builder/src/serve/options.ts +++ b/packages/builder/src/serve/options.ts @@ -46,20 +46,10 @@ export function translateOptions( raw: Partial, workspaceRoot: string, ): TranslatedServeArgs { - if (raw.ssl === true) { - throw new OptionTranslationError( - 'ssl=true is not yet supported by ngc-rs serve. Remove the option or run a separate TLS-terminating proxy in front of ngc-rs.', - ); - } - if (raw.sslKey || raw.sslCert) { - throw new OptionTranslationError( - 'sslKey/sslCert are not yet supported by ngc-rs serve.', - ); - } - const userPort = raw.port ?? DEFAULT_PORT; const userHost = raw.host ?? DEFAULT_HOST; const open = raw.open === true; + const ssl = raw.ssl === true; const configuration = parseConfigurationFromBuildTarget(raw.buildTarget); const project = raw.project ?? 'tsconfig.json'; @@ -70,6 +60,22 @@ export function translateOptions( : null; const proxyEnabled = proxyConfigPath !== null; + // The proxy is the browser-facing endpoint, so HTTPS would have to be + // terminated there rather than at the spawned ngc-rs serve. That's not + // wired up, so reject the combination with an actionable message rather + // than silently serving plain HTTP behind the proxy. + if (ssl && proxyEnabled) { + throw new OptionTranslationError( + 'ssl cannot be combined with proxyConfig in ngc-rs serve. Remove proxyConfig to serve HTTPS directly, or terminate TLS at a proxy in front of the (plain-HTTP) dev server.', + ); + } + + // Resolve the SSL flags up-front so a bad key/cert combination fails the + // build before the server is spawned. `ssl` is the master switch: + // sslKey/sslCert are honored only when ssl is true (matching + // `@angular/build:dev-server`). + const sslArgs = buildSslArgs(raw, workspaceRoot, ssl); + const spawnHost = proxyEnabled ? '127.0.0.1' : userHost; const spawnPort = proxyEnabled ? 0 : userPort; @@ -89,6 +95,7 @@ export function translateOptions( if (headers !== null) { args.push('--headers', headers); } + args.push(...sslArgs); return { args, @@ -100,10 +107,41 @@ export function translateOptions( proxyPort: userPort, proxyConfigPath, open, - url: formatUrl(userHost, userPort, servePath), + url: formatUrl(userHost, userPort, servePath, ssl ? 'https' : 'http'), }; } +// Translate the `ssl`/`sslKey`/`sslCert` options into CLI flags for the +// spawned `ngc-rs serve`. Returns an empty array when ssl is off. When ssl +// is on: +// * both sslKey and sslCert set → forward `--ssl --ssl-key

--ssl-cert +//

` with the paths resolved against the workspace root; +// * exactly one set → throw, since both halves are required; +// * neither set → forward just `--ssl` and let the binary mint a +// self-signed certificate. +function buildSslArgs( + raw: Partial, + workspaceRoot: string, + ssl: boolean, +): string[] { + if (!ssl) { + return []; + } + const key = raw.sslKey ?? null; + const cert = raw.sslCert ?? null; + if ((key && !cert) || (!key && cert)) { + throw new OptionTranslationError( + 'ssl requires both sslKey and sslCert, or neither (to auto-generate a self-signed certificate).', + ); + } + const args = ['--ssl']; + if (key && cert) { + args.push('--ssl-key', path.resolve(workspaceRoot, key)); + args.push('--ssl-cert', path.resolve(workspaceRoot, cert)); + } + return args; +} + // Normalize a user-supplied servePath into the canonical `/foo/` form, or // return null when the value is empty / a bare `/` (i.e. no prefix). The // rust side runs the same normalization (`ngc_dev_server::normalize_serve_path`), @@ -196,9 +234,10 @@ export function formatUrl( host: string, port: number, servePath: string | null = null, + scheme: 'http' | 'https' = 'http', ): string { const isLoopbackName = host === 'localhost' || host === '0.0.0.0'; const display = isLoopbackName ? 'localhost' : host; const suffix = servePath ?? '/'; - return `http://${display}:${port}${suffix}`; + return `${scheme}://${display}:${port}${suffix}`; }