From 755d1b3a3fa2475a83974aa4360965c48fe01b41 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Fri, 5 Jun 2026 10:31:25 +0200 Subject: [PATCH 01/13] draft TLS implementation for Pingora proxy based on routix --- Cargo.lock | 459 ++++----------------- members/nullnet-proxy/Cargo.toml | 5 +- members/nullnet-proxy/src/env.rs | 7 + members/nullnet-proxy/src/main.rs | 77 +++- members/nullnet-proxy/src/nullnet_proxy.rs | 13 +- members/nullnet-proxy/src/tls.rs | 130 ++++++ 6 files changed, 309 insertions(+), 382 deletions(-) create mode 100644 members/nullnet-proxy/src/tls.rs diff --git a/Cargo.lock b/Cargo.lock index 22c42d3..e58bc7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,12 +39,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aliasable" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" - [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -137,45 +131,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "asn1-rs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "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.117", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -217,28 +172,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.9" @@ -538,7 +471,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -574,16 +507,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -716,35 +639,6 @@ dependencies = [ "syn 2.0.117", ] -[[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 = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" -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 = "derivative" version = "2.2.0" @@ -810,23 +704,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "either" version = "1.15.0" @@ -973,6 +850,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" @@ -982,12 +874,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" @@ -1246,12 +1132,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -1678,12 +1558,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.8.9" @@ -1748,7 +1622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "733ad7a1352f0748a620368f2ea5cd94f0e449c5108fbac86ace31df1601bf6f" dependencies = [ "cfg-if", - "core-foundation 0.10.1", + "core-foundation", "ipnet", "libc", "netlink-packet-core", @@ -1881,22 +1755,6 @@ dependencies = [ "memoffset 0.9.1", ] -[[package]] -name = "no_debug" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f23a60c850e1144fc1dd9435152e0cfdc7dd18725350b4243584118013a52a4" - -[[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" @@ -1985,7 +1843,10 @@ dependencies = [ "gag", "nullnet-grpc-lib", "nullnet-liberror", + "openssl", "pingora-core", + "pingora-http", + "pingora-openssl", "pingora-proxy", "tokio", ] @@ -2017,31 +1878,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2075,15 +1911,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "oid-registry" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" -dependencies = [ - "asn1-rs", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2096,6 +1923,31 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.0", + "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.117", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -2109,27 +1961,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "ouroboros" -version = "0.18.5" +name = "openssl-src" +version = "300.6.0+3.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", + "cc", ] [[package]] -name = "ouroboros_macro" -version = "0.18.5" +name = "openssl-sys" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ - "heck 0.4.1", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.117", + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", ] [[package]] @@ -2284,14 +2134,13 @@ dependencies = [ "nix 0.24.3", "once_cell", "openssl-probe 0.1.6", - "ouroboros", "parking_lot", "percent-encoding", "pingora-error", "pingora-http", + "pingora-openssl", "pingora-pool", "pingora-runtime", - "pingora-rustls", "pingora-timeout", "prometheus", "rand 0.8.6", @@ -2307,7 +2156,6 @@ dependencies = [ "tokio-test", "unicase", "windows-sys 0.59.0", - "x509-parser", "zstd", ] @@ -2356,6 +2204,19 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "pingora-openssl" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f288cacd77196168db0f6ae80817bc4844a8dd1448b75bb2da935eb6d9c3118" +dependencies = [ + "foreign-types", + "libc", + "openssl", + "openssl-sys", + "tokio-openssl", +] + [[package]] name = "pingora-pool" version = "0.8.0" @@ -2406,23 +2267,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "pingora-rustls" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239b663618bb822ddeddaf6d8384177a8ab226cb22febc627a72c2fd55e7bb75" -dependencies = [ - "log", - "no_debug", - "pingora-error", - "ring", - "rustls", - "rustls-native-certs 0.7.3", - "rustls-pemfile", - "rustls-pki-types", - "tokio-rustls", -] - [[package]] name = "pingora-timeout" version = "0.8.0" @@ -2453,12 +2297,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2509,19 +2347,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "version_check", - "yansi", -] - [[package]] name = "prometheus" version = "0.13.4" @@ -2553,7 +2378,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck", "itertools", "log", "multimap", @@ -2852,15 +2677,6 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" -[[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" @@ -2880,7 +2696,6 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ - "aws-lc-rs", "log", "once_cell", "rustls-pki-types", @@ -2889,19 +2704,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" -dependencies = [ - "openssl-probe 0.1.6", - "rustls-pemfile", - "rustls-pki-types", - "schannel", - "security-framework 2.11.1", -] - [[package]] name = "rustls-native-certs" version = "0.8.3" @@ -2911,16 +2713,7 @@ dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework 3.7.0", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", + "security-framework", ] [[package]] @@ -2938,7 +2731,6 @@ version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2980,19 +2772,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -3000,7 +2779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -3185,12 +2964,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" @@ -3212,7 +2985,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "rustversion", @@ -3253,17 +3026,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -3346,37 +3108,6 @@ dependencies = [ "trackable", ] -[[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 = "tokio" version = "1.51.1" @@ -3404,6 +3135,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-openssl" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd" +dependencies = [ + "openssl", + "openssl-sys", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3508,7 +3250,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "rustls-native-certs 0.8.3", + "rustls-native-certs", "socket2", "sync_wrapper", "tokio", @@ -4293,7 +4035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -4304,7 +4046,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap 2.13.1", "prettyplease", "syn 2.0.117", @@ -4365,23 +4107,6 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "x509-parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - [[package]] name = "xtask" version = "0.1.0" @@ -4390,12 +4115,6 @@ dependencies = [ "clap", ] -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "zerocopy" version = "0.8.48" diff --git a/members/nullnet-proxy/Cargo.toml b/members/nullnet-proxy/Cargo.toml index 9a852eb..204887e 100644 --- a/members/nullnet-proxy/Cargo.toml +++ b/members/nullnet-proxy/Cargo.toml @@ -7,8 +7,11 @@ authors.workspace = true [dependencies] async-trait.workspace = true -pingora-core = { version = "0.8.0", features = ["rustls"] } +pingora-core = { version = "0.8.0", features = ["openssl"] } pingora-proxy = "0.8.0" +pingora-http = "0.8.0" +pingora-openssl = "0.8.0" +openssl = "0.10" nullnet-grpc-lib.workspace = true nullnet-liberror.workspace = true tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/members/nullnet-proxy/src/env.rs b/members/nullnet-proxy/src/env.rs index 20c599e..7a1fcbd 100644 --- a/members/nullnet-proxy/src/env.rs +++ b/members/nullnet-proxy/src/env.rs @@ -13,3 +13,10 @@ pub static CONTROL_SERVICE_PORT: std::sync::LazyLock = std::sync::LazyLock: str.parse().unwrap_or(50051) }); + +pub static CERTS_DIR: std::sync::LazyLock = std::sync::LazyLock::new(|| { + std::env::var("CERTS_DIR").unwrap_or_else(|_| { + println!("'CERTS_DIR' environment variable not set; falling back to local 'certs' dir"); + "certs".to_string() + }) +}); diff --git a/members/nullnet-proxy/src/main.rs b/members/nullnet-proxy/src/main.rs index 9c420fb..f94deb4 100644 --- a/members/nullnet-proxy/src/main.rs +++ b/members/nullnet-proxy/src/main.rs @@ -1,7 +1,10 @@ mod env; mod nullnet_proxy; +mod tls; +use crate::env::CERTS_DIR; use crate::nullnet_proxy::NullnetProxy; +use crate::tls::{CertStore, TlsResolver}; use async_trait::async_trait; use nullnet_grpc_lib::nullnet_grpc::{ AgentEvent, AgentProxyClientNotInet, AgentProxyRequestInvalidHost, @@ -9,21 +12,49 @@ use nullnet_grpc_lib::nullnet_grpc::{ agent_event::Event as AgentEventKind, }; use nullnet_liberror::{ErrorHandler, Location, location}; +use pingora_core::listeners::tls::TlsSettings; use pingora_core::server::Server; use pingora_core::upstreams::peer::HttpPeer; use pingora_core::{Error, ErrorType, Result}; +use pingora_http::{RequestHeader, ResponseHeader}; use pingora_proxy::{ProxyHttp, Session}; use std::process; +use std::sync::Arc; use std::thread; use std::time::Instant; const PROXY_PORT: u16 = 80; +const HTTPS_PROXY_PORT: u16 = 443; #[async_trait] impl ProxyHttp for NullnetProxy { type CTX = (); fn new_ctx(&self) -> Self::CTX {} + async fn request_filter(&self, session: &mut Session, _ctx: &mut ()) -> Result { + // only the HTTP listener redirects, and only for hosts we can serve over TLS + if self.tls { + return Ok(false); + } + let req = session.req_header(); + let hostname = req + .headers + .get("host") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(':').next()) + .unwrap_or(""); + if !self.certs.has_cert(hostname) { + return Ok(false); + } + + let location = https_redirect_url(req, HTTPS_PROXY_PORT); + let mut resp = ResponseHeader::build(301, None)?; + resp.insert_header("location", location.as_str())?; + resp.insert_header("content-length", "0")?; + session.write_response_header(Box::new(resp), true).await?; + Ok(true) + } + async fn upstream_peer(&self, session: &mut Session, _ctx: &mut ()) -> Result> { println!( "Received new proxy request from client: {:?}\n", @@ -76,9 +107,7 @@ impl ProxyHttp for NullnetProxy { return Err(Error::explain(ErrorType::BindError, "Invalid host header")); } }; - let url = host_str - .strip_suffix(&format!(":{PROXY_PORT}")) - .unwrap_or(host_str); + let url = host_str.rsplit_once(':').map_or(host_str, |(host, _)| host); let client_ip = match session.client_addr() { None => { @@ -198,18 +227,32 @@ async fn main() -> Result<(), nullnet_liberror::Error> { }) .handle_err(location!())?; - let proxy_address = format!("0.0.0.0:{PROXY_PORT}"); + let http_address = format!("0.0.0.0:{PROXY_PORT}"); + let https_address = format!("0.0.0.0:{HTTPS_PROXY_PORT}"); // start proxy server let mut my_server = Server::new(None).handle_err(location!())?; my_server.bootstrap(); - let nullnet_proxy = NullnetProxy::new().await?; - let mut proxy = pingora_proxy::http_proxy_service(&my_server.configuration, nullnet_proxy); - proxy.add_tcp(&proxy_address); - my_server.add_service(proxy); + let cert_store = Arc::new(CertStore::load(CERTS_DIR.as_str())); + let nullnet_proxy = NullnetProxy::new(cert_store.clone()).await?; + + // HTTP listener: redirects to HTTPS for hosts that have a cert + let mut http_proxy = + pingora_proxy::http_proxy_service(&my_server.configuration, nullnet_proxy.clone()); + http_proxy.add_tcp(&http_address); + my_server.add_service(http_proxy); - println!("Running Nullnet proxy at {proxy_address}\n"); + // HTTPS listener: per-domain cert resolved by SNI (exact + wildcard) + let mut https_app = nullnet_proxy; + https_app.tls = true; + let tls_settings = TlsSettings::with_callbacks(Box::new(TlsResolver::new(cert_store))) + .handle_err(location!())?; + let mut https_proxy = pingora_proxy::http_proxy_service(&my_server.configuration, https_app); + https_proxy.add_tls_with_settings(&https_address, None, tls_settings); + my_server.add_service(https_proxy); + + println!("Running Nullnet proxy at {http_address} (HTTP) and {https_address} (HTTPS)\n"); // run on separate thread to avoid "cannot start a runtime from within a runtime" let handle = thread::spawn(|| my_server.run_forever()); @@ -217,6 +260,22 @@ async fn main() -> Result<(), nullnet_liberror::Error> { Ok(()) } +/// Build the `https://` redirect target from an HTTP request's Host header, +/// stripping any port (the target port is always `https_port`). +fn https_redirect_url(req: &RequestHeader, https_port: u16) -> String { + let host_header = req + .headers + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let hostname = host_header.split(':').next().unwrap_or(host_header); + if https_port == 443 { + format!("https://{hostname}{}", req.uri) + } else { + format!("https://{hostname}:{https_port}{}", req.uri) + } +} + // fn redirect_stdout_stderr_to_file() // -> Option<(gag::Redirect, gag::Redirect)> { // let dir = "/var/log/nullnet"; diff --git a/members/nullnet-proxy/src/nullnet_proxy.rs b/members/nullnet-proxy/src/nullnet_proxy.rs index 6f0daa4..ca68bb3 100644 --- a/members/nullnet-proxy/src/nullnet_proxy.rs +++ b/members/nullnet-proxy/src/nullnet_proxy.rs @@ -1,17 +1,22 @@ use crate::env::{CONTROL_SERVICE_ADDR, CONTROL_SERVICE_PORT}; +use crate::tls::CertStore; use nullnet_grpc_lib::NullnetGrpcInterface; use nullnet_grpc_lib::nullnet_grpc::{ AgentEvent, AgentUpstreamIpParseFailed, ProxyRequest, agent_event::Event as AgentEventKind, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +#[derive(Clone)] pub struct NullnetProxy { pub(crate) server: NullnetGrpcInterface, + pub(crate) certs: Arc, + pub(crate) tls: bool, } impl NullnetProxy { - pub async fn new() -> Result { + pub async fn new(certs: Arc) -> Result { let host = CONTROL_SERVICE_ADDR.to_string(); let port = *CONTROL_SERVICE_PORT; @@ -19,7 +24,11 @@ impl NullnetProxy { .await .handle_err(location!())?; - Ok(Self { server }) + Ok(Self { + server, + certs, + tls: false, + }) } pub async fn get_or_add_upstream(&self, proxy_req: ProxyRequest) -> Result { diff --git a/members/nullnet-proxy/src/tls.rs b/members/nullnet-proxy/src/tls.rs new file mode 100644 index 0000000..7aae6e9 --- /dev/null +++ b/members/nullnet-proxy/src/tls.rs @@ -0,0 +1,130 @@ +use async_trait::async_trait; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use openssl::ssl::NameType; +use pingora_core::listeners::TlsAccept; +use pingora_core::protocols::tls::TlsRef; +use pingora_openssl::ext; +use pingora_openssl::pkey::{PKey, Private}; +use pingora_openssl::ssl::{SslContextBuilder, SslMethod}; +use pingora_openssl::x509::X509; +use std::collections::HashMap; +use std::sync::Arc; + +/// A parsed TLS certificate: leaf + intermediate chain + matching private key. +pub struct Certificate { + leaf: X509, + chain: Vec, + private_key: PKey, +} + +impl Certificate { + fn new(cert_pem: &str, key_pem: &str) -> Result { + let mut certs = X509::stack_from_pem(cert_pem.as_bytes()).handle_err(location!())?; + if certs.is_empty() { + Err::<(), _>("no certificate found in PEM").handle_err(location!())?; + } + let leaf = certs.remove(0); + let chain = certs; + let private_key = PKey::private_key_from_pem(key_pem.as_bytes()).handle_err(location!())?; + + // ensure the private key actually matches the leaf certificate + let mut builder = SslContextBuilder::new(SslMethod::tls()).handle_err(location!())?; + builder.set_certificate(&leaf).handle_err(location!())?; + builder + .set_private_key(&private_key) + .handle_err(location!())?; + builder.check_private_key().handle_err(location!())?; + + Ok(Self { + leaf, + chain, + private_key, + }) + } +} + +/// In-memory certificate store keyed by domain, loaded once from disk at startup. +/// +/// Expected layout: `//fullchain.pem` + `//privkey.pem`. +/// A wildcard cert lives under a directory named `*.example.com`. +pub struct CertStore { + certs: HashMap>, +} + +impl CertStore { + pub fn load(dir: &str) -> Self { + let mut certs = HashMap::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + println!("Could not read certs dir '{dir}'; starting with no TLS certificates"); + return Self { certs }; + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(domain) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let (Ok(cert_pem), Ok(key_pem)) = ( + std::fs::read_to_string(path.join("fullchain.pem")), + std::fs::read_to_string(path.join("privkey.pem")), + ) else { + continue; + }; + match Certificate::new(&cert_pem, &key_pem) { + Ok(cert) => { + println!("Loaded TLS certificate for '{domain}'"); + certs.insert(domain.to_string(), Arc::new(cert)); + } + Err(_) => println!("Skipping '{domain}': invalid certificate or key"), + } + } + + Self { certs } + } + + /// Resolve a cert for an SNI hostname: exact match first, then wildcard + /// (`app.example.com` -> `*.example.com`). + fn get(&self, hostname: &str) -> Option> { + if let Some(cert) = self.certs.get(hostname) { + return Some(cert.clone()); + } + let (_, parent) = hostname.split_once('.')?; + self.certs.get(&format!("*.{parent}")).cloned() + } + + /// Whether a cert (exact or wildcard) is available for the given hostname. + pub fn has_cert(&self, hostname: &str) -> bool { + self.get(hostname).is_some() + } +} + +/// SNI-based certificate resolver invoked by pingora during the TLS handshake. +pub struct TlsResolver { + store: Arc, +} + +impl TlsResolver { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl TlsAccept for TlsResolver { + async fn certificate_callback(&self, ssl: &mut TlsRef) { + let Some(hostname) = ssl.servername(NameType::HOST_NAME) else { + println!("TLS handshake without SNI; no certificate selected"); + return; + }; + let Some(cert) = self.store.get(hostname) else { + println!("No TLS certificate found for '{hostname}'"); + return; + }; + + let _ = ext::ssl_use_certificate(ssl, &cert.leaf); + let _ = ext::ssl_use_private_key(ssl, &cert.private_key); + for intermediate in &cert.chain { + let _ = ext::ssl_add_chain_cert(ssl, intermediate); + } + } +} From 73668a10a6cbbbbf8951587d3b01ba65dae907a4 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Fri, 5 Jun 2026 11:47:21 +0200 Subject: [PATCH 02/13] add TLS to nullnet-proxy with server-driven cert distribution --- Cargo.lock | 10 ++ .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 28 +++++ members/nullnet-grpc-lib/src/lib.rs | 17 ++- .../src/proto/nullnet_grpc.rs | 115 ++++++++++++++++++ members/nullnet-proxy/Cargo.toml | 1 + members/nullnet-proxy/src/env.rs | 7 -- members/nullnet-proxy/src/main.rs | 42 ++++++- members/nullnet-proxy/src/nullnet_proxy.rs | 9 +- members/nullnet-proxy/src/tls.rs | 46 +++---- members/nullnet-server/src/certs.rs | 101 +++++++++++++++ members/nullnet-server/src/main.rs | 7 ++ .../nullnet-server/src/nullnet_grpc_impl.rs | 42 ++++++- 12 files changed, 381 insertions(+), 44 deletions(-) create mode 100644 members/nullnet-server/src/certs.rs diff --git a/Cargo.lock b/Cargo.lock index e58bc7b..99f4f88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,6 +125,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -1837,6 +1846,7 @@ source = "git+https://github.com/NullNet-ai/libguard.git?branch=nullnet-liberror name = "nullnet-proxy" version = "0.1.0" dependencies = [ + "arc-swap", "async-trait", "chrono", "ctrlc", diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index cc68128..a2d56bc 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -27,6 +27,13 @@ service NullnetGrpc { // Report an event from a client or proxy agent back to the server. rpc ReportEvent(AgentEvent) returns (Empty); + + // TLS certificate distribution APIs --------------------------------------------------------------------------------- + + // Long-lived stream: the server pushes the full certificate set immediately on + // subscribe (proxy startup load) and again whenever it changes, so every proxy + // hot-reloads without a restart. + rpc WatchCertificates(Empty) returns (stream CertBundle); } // TAP-based clients --------------------------------------------------------------------------------------------------- @@ -148,6 +155,27 @@ message BackendTriggerRequest { string initiator_container = 3; } +// TLS certificates ---------------------------------------------------------------------------------------------------- + +// A single TLS certificate, keyed by the SNI name it serves (exact, e.g. +// "color.com", or wildcard, e.g. "*.color.com"). +message TlsCertificate { + string domain = 1; + // Leaf certificate followed by any intermediates, PEM-encoded. + string fullchain_pem = 2; + // PEM-encoded private key. + // TODO(encryption-at-rest): the key is currently stored and transmitted in + // plaintext. Once encryption-at-rest lands, the server should decrypt just + // before sending and this channel must be TLS-protected (see gRPC TLS TODO). + string key_pem = 3; +} + +// The full certificate set. Sent in whole on every GetCertificates call and on +// every WatchCertificates push (the proxy rebuilds its store from the snapshot). +message CertBundle { + repeated TlsCertificate certificates = 1; +} + // Misc ---------------------------------------------------------------------------------------------------------------- message Empty { } diff --git a/members/nullnet-grpc-lib/src/lib.rs b/members/nullnet-grpc-lib/src/lib.rs index e19307f..1d64b4e 100644 --- a/members/nullnet-grpc-lib/src/lib.rs +++ b/members/nullnet-grpc-lib/src/lib.rs @@ -2,8 +2,8 @@ mod proto; use crate::nullnet_grpc::nullnet_grpc_client::NullnetGrpcClient; use crate::nullnet_grpc::{ - AgentEvent, BackendTriggerRequest, Empty, MsgId, NetMessage, NetType, ProxyRequest, Services, - ServicesListResponse, Upstream, + AgentEvent, BackendTriggerRequest, CertBundle, Empty, MsgId, NetMessage, NetType, ProxyRequest, + Services, ServicesListResponse, Upstream, }; pub use proto::*; use tokio::sync::mpsc; @@ -121,4 +121,17 @@ impl NullnetGrpcInterface { .map(|_| ()) .map_err(|e| e.to_string()) } + + /// Subscribe to certificate changes: the returned stream yields the full + /// certificate set immediately on subscribe and again whenever it changes. + #[allow(clippy::missing_errors_doc)] + pub async fn watch_certificates(&self) -> Result, String> { + Ok(self + .client + .clone() + .watch_certificates(Request::new(Empty {})) + .await + .map_err(|e| e.to_string())? + .into_inner()) + } } diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index f0e4e54..8620c68 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -161,6 +161,29 @@ pub struct BackendTriggerRequest { #[prost(string, tag = "3")] pub initiator_container: ::prost::alloc::string::String, } +/// A single TLS certificate, keyed by the SNI name it serves (exact, e.g. +/// "color.com", or wildcard, e.g. "\*.color.com"). +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct TlsCertificate { + #[prost(string, tag = "1")] + pub domain: ::prost::alloc::string::String, + /// Leaf certificate followed by any intermediates, PEM-encoded. + #[prost(string, tag = "2")] + pub fullchain_pem: ::prost::alloc::string::String, + /// PEM-encoded private key. + /// TODO(encryption-at-rest): the key is currently stored and transmitted in + /// plaintext. Once encryption-at-rest lands, the server should decrypt just + /// before sending and this channel must be TLS-protected (see gRPC TLS TODO). + #[prost(string, tag = "3")] + pub key_pem: ::prost::alloc::string::String, +} +/// The full certificate set. Sent in whole on every GetCertificates call and on +/// every WatchCertificates push (the proxy rebuilds its store from the snapshot). +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CertBundle { + #[prost(message, repeated, tag = "1")] + pub certificates: ::prost::alloc::vec::Vec, +} #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct Empty {} /// Agent event report — sent from nullnet-client or nullnet-proxy to the server. @@ -632,6 +655,35 @@ pub mod nullnet_grpc_client { .insert(GrpcMethod::new("nullnet_grpc.NullnetGrpc", "ReportEvent")); self.inner.unary(req, path, codec).await } + /// Long-lived stream: the server pushes the full certificate set immediately on + /// subscribe (proxy startup load) and again whenever it changes, so every proxy + /// hot-reloads without a restart. + pub async fn watch_certificates( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response>, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/nullnet_grpc.NullnetGrpc/WatchCertificates", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("nullnet_grpc.NullnetGrpc", "WatchCertificates"), + ); + self.inner.server_streaming(req, path, codec).await + } } } /// Generated server implementations. @@ -690,6 +742,22 @@ pub mod nullnet_grpc_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + /// Server streaming response type for the WatchCertificates method. + type WatchCertificatesStream: tonic::codegen::tokio_stream::Stream< + Item = std::result::Result, + > + + std::marker::Send + + 'static; + /// Long-lived stream: the server pushes the full certificate set immediately on + /// subscribe (proxy startup load) and again whenever it changes, so every proxy + /// hot-reloads without a restart. + async fn watch_certificates( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } #[derive(Debug)] pub struct NullnetGrpcServer { @@ -1028,6 +1096,53 @@ pub mod nullnet_grpc_server { }; Box::pin(fut) } + "/nullnet_grpc.NullnetGrpc/WatchCertificates" => { + #[allow(non_camel_case_types)] + struct WatchCertificatesSvc(pub Arc); + impl< + T: NullnetGrpc, + > tonic::server::ServerStreamingService + for WatchCertificatesSvc { + type Response = super::CertBundle; + type ResponseStream = T::WatchCertificatesStream; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::watch_certificates(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = WatchCertificatesSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.server_streaming(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { let mut response = http::Response::new( diff --git a/members/nullnet-proxy/Cargo.toml b/members/nullnet-proxy/Cargo.toml index 204887e..5cb2b06 100644 --- a/members/nullnet-proxy/Cargo.toml +++ b/members/nullnet-proxy/Cargo.toml @@ -12,6 +12,7 @@ pingora-proxy = "0.8.0" pingora-http = "0.8.0" pingora-openssl = "0.8.0" openssl = "0.10" +arc-swap = "1" nullnet-grpc-lib.workspace = true nullnet-liberror.workspace = true tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/members/nullnet-proxy/src/env.rs b/members/nullnet-proxy/src/env.rs index 7a1fcbd..20c599e 100644 --- a/members/nullnet-proxy/src/env.rs +++ b/members/nullnet-proxy/src/env.rs @@ -13,10 +13,3 @@ pub static CONTROL_SERVICE_PORT: std::sync::LazyLock = std::sync::LazyLock: str.parse().unwrap_or(50051) }); - -pub static CERTS_DIR: std::sync::LazyLock = std::sync::LazyLock::new(|| { - std::env::var("CERTS_DIR").unwrap_or_else(|_| { - println!("'CERTS_DIR' environment variable not set; falling back to local 'certs' dir"); - "certs".to_string() - }) -}); diff --git a/members/nullnet-proxy/src/main.rs b/members/nullnet-proxy/src/main.rs index f94deb4..a4f64d3 100644 --- a/members/nullnet-proxy/src/main.rs +++ b/members/nullnet-proxy/src/main.rs @@ -2,10 +2,11 @@ mod env; mod nullnet_proxy; mod tls; -use crate::env::CERTS_DIR; use crate::nullnet_proxy::NullnetProxy; use crate::tls::{CertStore, TlsResolver}; +use arc_swap::ArcSwap; use async_trait::async_trait; +use nullnet_grpc_lib::NullnetGrpcInterface; use nullnet_grpc_lib::nullnet_grpc::{ AgentEvent, AgentProxyClientNotInet, AgentProxyRequestInvalidHost, AgentProxyRequestMissingHost, AgentProxyRequestRouted, AgentUpstreamLookupFailed, ProxyRequest, @@ -43,7 +44,7 @@ impl ProxyHttp for NullnetProxy { .and_then(|v| v.to_str().ok()) .and_then(|v| v.split(':').next()) .unwrap_or(""); - if !self.certs.has_cert(hostname) { + if !self.certs.load().has_cert(hostname) { return Ok(false); } @@ -234,9 +235,18 @@ async fn main() -> Result<(), nullnet_liberror::Error> { let mut my_server = Server::new(None).handle_err(location!())?; my_server.bootstrap(); - let cert_store = Arc::new(CertStore::load(CERTS_DIR.as_str())); + // Certificates come from the control service over gRPC. Start empty; the + // watch task fills this and hot-reloads it on every change. + let cert_store: Arc> = Arc::new(ArcSwap::from_pointee(CertStore::default())); let nullnet_proxy = NullnetProxy::new(cert_store.clone()).await?; + // subscribe to certificate updates (initial set + every subsequent change) + { + let server = nullnet_proxy.server.clone(); + let store = cert_store.clone(); + tokio::spawn(async move { watch_certificates(server, store).await }); + } + // HTTP listener: redirects to HTTPS for hosts that have a cert let mut http_proxy = pingora_proxy::http_proxy_service(&my_server.configuration, nullnet_proxy.clone()); @@ -276,6 +286,32 @@ fn https_redirect_url(req: &RequestHeader, https_port: u16) -> String { } } +/// Subscribe to the control service's certificate stream and atomically swap the +/// proxy's cert store on every push (initial set + each change). Reconnects with +/// a short backoff if the stream drops. +async fn watch_certificates(server: NullnetGrpcInterface, store: Arc>) { + loop { + match server.watch_certificates().await { + Ok(mut stream) => loop { + match stream.message().await { + Ok(Some(bundle)) => { + let n = bundle.certificates.len(); + store.store(Arc::new(CertStore::from_bundle(&bundle))); + println!("Loaded {n} TLS certificate(s) from control service"); + } + Ok(None) => break, + Err(e) => { + eprintln!("Certificate watch stream error: {e}"); + break; + } + } + }, + Err(e) => eprintln!("Failed to open certificate watch stream: {e}"), + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } +} + // fn redirect_stdout_stderr_to_file() // -> Option<(gag::Redirect, gag::Redirect)> { // let dir = "/var/log/nullnet"; diff --git a/members/nullnet-proxy/src/nullnet_proxy.rs b/members/nullnet-proxy/src/nullnet_proxy.rs index ca68bb3..a775350 100644 --- a/members/nullnet-proxy/src/nullnet_proxy.rs +++ b/members/nullnet-proxy/src/nullnet_proxy.rs @@ -1,5 +1,6 @@ use crate::env::{CONTROL_SERVICE_ADDR, CONTROL_SERVICE_PORT}; use crate::tls::CertStore; +use arc_swap::ArcSwap; use nullnet_grpc_lib::NullnetGrpcInterface; use nullnet_grpc_lib::nullnet_grpc::{ AgentEvent, AgentUpstreamIpParseFailed, ProxyRequest, agent_event::Event as AgentEventKind, @@ -11,15 +12,19 @@ use std::sync::Arc; #[derive(Clone)] pub struct NullnetProxy { pub(crate) server: NullnetGrpcInterface, - pub(crate) certs: Arc, + pub(crate) certs: Arc>, pub(crate) tls: bool, } impl NullnetProxy { - pub async fn new(certs: Arc) -> Result { + pub async fn new(certs: Arc>) -> Result { let host = CONTROL_SERVICE_ADDR.to_string(); let port = *CONTROL_SERVICE_PORT; + // TODO(grpc-tls): connecting with tls = false. Cert private keys are + // delivered over this channel via WatchCertificates, so enable TLS + // (tls = true) once the control service serves gRPC over TLS, so keys + // never travel in the clear. let server = NullnetGrpcInterface::new(&host, port, false) .await .handle_err(location!())?; diff --git a/members/nullnet-proxy/src/tls.rs b/members/nullnet-proxy/src/tls.rs index 7aae6e9..7d053f1 100644 --- a/members/nullnet-proxy/src/tls.rs +++ b/members/nullnet-proxy/src/tls.rs @@ -1,4 +1,6 @@ +use arc_swap::ArcSwap; use async_trait::async_trait; +use nullnet_grpc_lib::nullnet_grpc::CertBundle; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use openssl::ssl::NameType; use pingora_core::listeners::TlsAccept; @@ -43,42 +45,28 @@ impl Certificate { } } -/// In-memory certificate store keyed by domain, loaded once from disk at startup. +/// In-memory certificate store keyed by domain (SNI). Rebuilt wholesale from a +/// `CertBundle` pushed by the control service and swapped in atomically. /// -/// Expected layout: `//fullchain.pem` + `//privkey.pem`. -/// A wildcard cert lives under a directory named `*.example.com`. +/// Keys are SNI names: exact (`color.com`) or wildcard (`*.color.com`). +#[derive(Default)] pub struct CertStore { certs: HashMap>, } impl CertStore { - pub fn load(dir: &str) -> Self { + /// Build a store from a bundle received over gRPC, validating each + /// certificate/key pair and skipping any that fail. + pub fn from_bundle(bundle: &CertBundle) -> Self { let mut certs = HashMap::new(); - let Ok(entries) = std::fs::read_dir(dir) else { - println!("Could not read certs dir '{dir}'; starting with no TLS certificates"); - return Self { certs }; - }; - - for entry in entries.flatten() { - let path = entry.path(); - let Some(domain) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - let (Ok(cert_pem), Ok(key_pem)) = ( - std::fs::read_to_string(path.join("fullchain.pem")), - std::fs::read_to_string(path.join("privkey.pem")), - ) else { - continue; - }; - match Certificate::new(&cert_pem, &key_pem) { + for c in &bundle.certificates { + match Certificate::new(&c.fullchain_pem, &c.key_pem) { Ok(cert) => { - println!("Loaded TLS certificate for '{domain}'"); - certs.insert(domain.to_string(), Arc::new(cert)); + certs.insert(c.domain.clone(), Arc::new(cert)); } - Err(_) => println!("Skipping '{domain}': invalid certificate or key"), + Err(_) => println!("Skipping '{}': invalid certificate or key", c.domain), } } - Self { certs } } @@ -99,12 +87,13 @@ impl CertStore { } /// SNI-based certificate resolver invoked by pingora during the TLS handshake. +/// Reads the live `ArcSwap` so hot-reloaded certs are picked up immediately. pub struct TlsResolver { - store: Arc, + store: Arc>, } impl TlsResolver { - pub fn new(store: Arc) -> Self { + pub fn new(store: Arc>) -> Self { Self { store } } } @@ -116,7 +105,8 @@ impl TlsAccept for TlsResolver { println!("TLS handshake without SNI; no certificate selected"); return; }; - let Some(cert) = self.store.get(hostname) else { + let store = self.store.load(); + let Some(cert) = store.get(hostname) else { println!("No TLS certificate found for '{hostname}'"); return; }; diff --git a/members/nullnet-server/src/certs.rs b/members/nullnet-server/src/certs.rs new file mode 100644 index 0000000..ce501fc --- /dev/null +++ b/members/nullnet-server/src/certs.rs @@ -0,0 +1,101 @@ +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use nullnet_grpc_lib::nullnet_grpc::{CertBundle, TlsCertificate}; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use std::ops::Sub; +use std::path::PathBuf; +use std::time::Duration; +use tokio::sync::mpsc as tokio_mpsc; +use tokio::sync::watch; +use tokio::time::Instant; + +const CERTS_DIR: &str = "./certs"; + +/// Read every certificate from disk into a `CertBundle`. +/// +/// Layout: `./certs//fullchain.pem` + `./certs//privkey.pem`, +/// where `` is the SNI key (exact `color.com` or wildcard `*.color.com`). +/// Unreadable/incomplete entries are skipped; the proxy validates the PEMs. +pub(crate) async fn load_certificates() -> CertBundle { + let _ = tokio::fs::create_dir_all(CERTS_DIR).await; + + let mut certificates = Vec::new(); + let Ok(mut entries) = tokio::fs::read_dir(CERTS_DIR).await else { + return CertBundle { certificates }; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(domain) = path + .file_name() + .and_then(|s| s.to_str()) + .map(str::to_string) + else { + continue; + }; + // TODO(encryption-at-rest): privkey.pem is read in plaintext here. Once + // encryption-at-rest lands, decrypt the key before adding it to the + // bundle, and ensure the gRPC channel is TLS-protected (see gRPC TLS + // TODO in main.rs) since the bundle carries private keys. + let (Ok(fullchain_pem), Ok(key_pem)) = ( + tokio::fs::read_to_string(path.join("fullchain.pem")).await, + tokio::fs::read_to_string(path.join("privkey.pem")).await, + ) else { + continue; + }; + println!("Loaded TLS certificate for '{domain}'"); + certificates.push(TlsCertificate { + domain, + fullchain_pem, + key_pem, + }); + } + + CertBundle { certificates } +} + +/// Watch `./certs` and push a fresh `CertBundle` through `certs_tx` on every +/// change, so subscribed proxies hot-reload. Mirrors the services watcher. +pub(crate) async fn watch(certs_tx: watch::Sender) -> Result<(), Error> { + let dir = PathBuf::from(CERTS_DIR); + let _ = tokio::fs::create_dir_all(&dir).await; + + let (tx, mut rx) = tokio_mpsc::unbounded_channel(); + let mut watcher = RecommendedWatcher::new( + move |event| { + let _ = tx.send(event); + }, + Config::default(), + ) + .handle_err(location!())?; + watcher + .watch(&dir, RecursiveMode::Recursive) + .handle_err(location!())?; + + let mut last_update_time = Instant::now().sub(Duration::from_mins(1)); + + loop { + let event = rx.recv().await; + if event.is_none() { + println!("Certs file watcher channel closed, stopping watch"); + break; + } + if let Some(Ok(Event { kind, .. })) = event + && matches!( + kind, + EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) + ) + // debounce duplicated events + && last_update_time.elapsed().as_millis() > 100 + { + // ensure file changes are fully flushed before reading + tokio::time::sleep(Duration::from_millis(100)).await; + let _ = certs_tx.send(load_certificates().await); + last_update_time = Instant::now(); + } + } + + Ok(()) +} diff --git a/members/nullnet-server/src/main.rs b/members/nullnet-server/src/main.rs index ab17b7d..505ab57 100644 --- a/members/nullnet-server/src/main.rs +++ b/members/nullnet-server/src/main.rs @@ -1,3 +1,4 @@ +mod certs; mod env; mod events; mod graphviz; @@ -33,6 +34,12 @@ async fn main() -> Result<(), Error> { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), PORT); + // TODO(grpc-tls): the gRPC server is plaintext. Once certificate private + // keys are served over this channel (GetCertificates/WatchCertificates), + // enable TLS here (e.g. `Server::builder().tls_config(...)`) — and ideally + // mTLS — so keys never travel in the clear. Until then the control plane + // must run on a trusted network. The proxy already supports a TLS channel + // via `NullnetGrpcInterface::new(.., tls = true)`. let mut server = Server::builder(); let nullnet = init_nullnet().await?; diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 6a7e175..0cbb674 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -12,14 +12,14 @@ use crate::services::service_info::ServiceInfo; use crate::timeout::check_timeouts; use nullnet_grpc_lib::nullnet_grpc::nullnet_grpc_server::NullnetGrpc; use nullnet_grpc_lib::nullnet_grpc::{ - AgentEvent, BackendTriggerRequest, Empty, MsgId, NetMessage, NetType, ProxyRequest, + AgentEvent, BackendTriggerRequest, CertBundle, Empty, MsgId, NetMessage, NetType, ProxyRequest, ServiceTrigger, Services, ServicesListResponse, Upstream, agent_event::Event as AgentEventKind, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::collections::{HashMap, HashSet}; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; -use tokio::sync::{Notify, RwLock, mpsc}; +use tokio::sync::{Notify, RwLock, mpsc, watch}; use tokio::task::JoinSet; use tonic::codegen::tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status, Streaming}; @@ -29,6 +29,9 @@ pub(crate) struct NullnetGrpcImpl { services: Arc>, /// Orchestrator to manage TAP-based clients and NET setups orchestrator: Orchestrator, + /// Latest TLS certificate set, kept in sync with `./certs` by a watcher. + /// Proxies fetch the current value and subscribe for updates. + certs: watch::Receiver, } /// Return the stack name that holds `service_name`, if any. Service names @@ -72,9 +75,18 @@ impl NullnetGrpcImpl { check_timeouts(services_2, orchestrator_2, config_changed).await; }); + // load TLS certificates and keep them in sync with the ./certs dir + let (certs_tx, certs_rx) = watch::channel(crate::certs::load_certificates().await); + tokio::spawn(async move { + if let Err(e) = crate::certs::watch(certs_tx).await { + eprintln!("failed to watch certs for changes: {e:?}"); + } + }); + Ok(NullnetGrpcImpl { services, orchestrator, + certs: certs_rx, }) } @@ -930,9 +942,11 @@ struct SuccessfulEdge { #[cfg(test)] impl NullnetGrpcImpl { pub(crate) fn new_for_test(services: StackMap) -> Self { + let (_, certs) = watch::channel(CertBundle::default()); NullnetGrpcImpl { services: Arc::new(RwLock::new(services)), orchestrator: Orchestrator::new(), + certs, } } @@ -998,6 +1012,30 @@ impl NullnetGrpc for NullnetGrpcImpl { .map_err(|err| Status::internal(err.to_str())) } + type WatchCertificatesStream = ReceiverStream>; + + async fn watch_certificates( + &self, + _: Request, + ) -> Result, Status> { + let mut certs = self.certs.clone(); + let (tx, rx) = mpsc::channel(4); + tokio::spawn(async move { + // send the current set immediately, then one snapshot per change + let initial = certs.borrow_and_update().clone(); + if tx.send(Ok(initial)).await.is_err() { + return; + } + while certs.changed().await.is_ok() { + let snapshot = certs.borrow_and_update().clone(); + if tx.send(Ok(snapshot)).await.is_err() { + break; + } + } + }); + Ok(Response::new(ReceiverStream::new(rx))) + } + async fn report_event(&self, req: Request) -> Result, Status> { let Some(kind) = req.into_inner().event else { return Ok(Response::new(Empty {})); From ce9f1279c51f1444b2589ae8867a2f04c9c7fbce Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Fri, 5 Jun 2026 12:08:45 +0200 Subject: [PATCH 03/13] refine TLS cert TODOs (encryption-at-rest plan, grpc-tls, ingest) --- members/nullnet-grpc-lib/proto/nullnet_grpc.proto | 7 +++---- members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs | 7 +++---- members/nullnet-server/src/certs.rs | 13 +++++++++---- members/nullnet-server/src/main.rs | 10 ++++------ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index a2d56bc..0b0ab9c 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -163,10 +163,9 @@ message TlsCertificate { string domain = 1; // Leaf certificate followed by any intermediates, PEM-encoded. string fullchain_pem = 2; - // PEM-encoded private key. - // TODO(encryption-at-rest): the key is currently stored and transmitted in - // plaintext. Once encryption-at-rest lands, the server should decrypt just - // before sending and this channel must be TLS-protected (see gRPC TLS TODO). + // PEM-encoded private key. Plaintext on the wire — protect this channel with + // TLS (see gRPC-TLS TODO). At-rest encryption (CERT_ENCRYPTION_KEY, + // AES-256-GCM) is applied server-side; the key is decrypted before it lands here. string key_pem = 3; } diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index 8620c68..4a99dfb 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -170,10 +170,9 @@ pub struct TlsCertificate { /// Leaf certificate followed by any intermediates, PEM-encoded. #[prost(string, tag = "2")] pub fullchain_pem: ::prost::alloc::string::String, - /// PEM-encoded private key. - /// TODO(encryption-at-rest): the key is currently stored and transmitted in - /// plaintext. Once encryption-at-rest lands, the server should decrypt just - /// before sending and this channel must be TLS-protected (see gRPC TLS TODO). + /// PEM-encoded private key. Plaintext on the wire — protect this channel with + /// TLS (see gRPC-TLS TODO). At-rest encryption (CERT_ENCRYPTION_KEY, + /// AES-256-GCM) is applied server-side; the key is decrypted before it lands here. #[prost(string, tag = "3")] pub key_pem: ::prost::alloc::string::String, } diff --git a/members/nullnet-server/src/certs.rs b/members/nullnet-server/src/certs.rs index ce501fc..3e334ce 100644 --- a/members/nullnet-server/src/certs.rs +++ b/members/nullnet-server/src/certs.rs @@ -10,6 +10,11 @@ use tokio::time::Instant; const CERTS_DIR: &str = "./certs"; +// TODO(cert-ingest): certs currently arrive as files dropped in ./certs. Plan: +// a UI/API that encrypts keys on ingest (see encryption-at-rest TODO) and writes +// ciphertext here. That API must be HTTPS, require admin auth, and never return +// private keys on read. + /// Read every certificate from disk into a `CertBundle`. /// /// Layout: `./certs//fullchain.pem` + `./certs//privkey.pem`, @@ -35,10 +40,10 @@ pub(crate) async fn load_certificates() -> CertBundle { else { continue; }; - // TODO(encryption-at-rest): privkey.pem is read in plaintext here. Once - // encryption-at-rest lands, decrypt the key before adding it to the - // bundle, and ensure the gRPC channel is TLS-protected (see gRPC TLS - // TODO in main.rs) since the bundle carries private keys. + // TODO(encryption-at-rest): keys are read in plaintext. Plan (routix + // style): AES-256-GCM with a master key from CERT_ENCRYPTION_KEY, + // encrypt on ingest, store ciphertext on disk, decrypt here before + // bundling. Pairs with the gRPC-TLS TODO since the bundle carries keys. let (Ok(fullchain_pem), Ok(key_pem)) = ( tokio::fs::read_to_string(path.join("fullchain.pem")).await, tokio::fs::read_to_string(path.join("privkey.pem")).await, diff --git a/members/nullnet-server/src/main.rs b/members/nullnet-server/src/main.rs index 505ab57..6626a2c 100644 --- a/members/nullnet-server/src/main.rs +++ b/members/nullnet-server/src/main.rs @@ -34,12 +34,10 @@ async fn main() -> Result<(), Error> { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), PORT); - // TODO(grpc-tls): the gRPC server is plaintext. Once certificate private - // keys are served over this channel (GetCertificates/WatchCertificates), - // enable TLS here (e.g. `Server::builder().tls_config(...)`) — and ideally - // mTLS — so keys never travel in the clear. Until then the control plane - // must run on a trusted network. The proxy already supports a TLS channel - // via `NullnetGrpcInterface::new(.., tls = true)`. + // TODO(grpc-tls): the gRPC server is plaintext, but WatchCertificates + // streams private keys. Enable TLS here (Server::builder().tls_config(..)), + // ideally mTLS, so keys never travel in clear; until then keep the control + // plane on a trusted network. Proxy side: NullnetGrpcInterface::new(.., true). let mut server = Server::builder(); let nullnet = init_nullnet().await?; From 55e4cf5d945b53fe4102f098e206955218d16b0b Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Mon, 8 Jun 2026 16:16:26 +0200 Subject: [PATCH 04/13] validate proxy TLS certs (expiry + domain match) and emit event on rejection --- .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 2 + .../src/proto/nullnet_grpc.rs | 11 +- members/nullnet-proxy/src/main.rs | 19 +- members/nullnet-proxy/src/tls.rs | 255 +++++++++++++++++- members/nullnet-server/src/events.rs | 17 +- .../nullnet-server/src/nullnet_grpc_impl.rs | 3 + 6 files changed, 290 insertions(+), 17 deletions(-) diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index 0b0ab9c..63fe1b6 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -206,6 +206,7 @@ message AgentEvent { AgentProxyRequestInvalidHost proxy_request_invalid_host = 19; AgentUpstreamIpParseFailed upstream_ip_parse_failed = 20; AgentProxyClientNotInet proxy_client_not_inet = 21; + AgentTlsCertificateInvalid tls_certificate_invalid = 23; // Proxy info events AgentProxyRequestRouted proxy_request_routed = 22; } @@ -232,4 +233,5 @@ message AgentProxyRequestMissingHost { string client_ip = 1; } message AgentProxyRequestInvalidHost { string client_ip = 1; } message AgentUpstreamIpParseFailed { string raw_ip = 1; string service_name = 2; } message AgentProxyClientNotInet { string address_family = 1; } +message AgentTlsCertificateInvalid { string domain = 1; string reason = 2; } message AgentProxyRequestRouted { string service_name = 1; string client_ip = 2; string upstream_ip = 3; uint64 latency_ms = 4; } \ No newline at end of file diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index 4a99dfb..f287458 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -190,7 +190,7 @@ pub struct Empty {} pub struct AgentEvent { #[prost( oneof = "agent_event::Event", - tags = "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22" + tags = "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 22" )] pub event: ::core::option::Option, } @@ -243,6 +243,8 @@ pub mod agent_event { UpstreamIpParseFailed(super::AgentUpstreamIpParseFailed), #[prost(message, tag = "21")] ProxyClientNotInet(super::AgentProxyClientNotInet), + #[prost(message, tag = "23")] + TlsCertificateInvalid(super::AgentTlsCertificateInvalid), /// Proxy info events #[prost(message, tag = "22")] ProxyRequestRouted(super::AgentProxyRequestRouted), @@ -388,6 +390,13 @@ pub struct AgentProxyClientNotInet { pub address_family: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AgentTlsCertificateInvalid { + #[prost(string, tag = "1")] + pub domain: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub reason: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct AgentProxyRequestRouted { #[prost(string, tag = "1")] pub service_name: ::prost::alloc::string::String, diff --git a/members/nullnet-proxy/src/main.rs b/members/nullnet-proxy/src/main.rs index a4f64d3..4afae72 100644 --- a/members/nullnet-proxy/src/main.rs +++ b/members/nullnet-proxy/src/main.rs @@ -9,8 +9,8 @@ use async_trait::async_trait; use nullnet_grpc_lib::NullnetGrpcInterface; use nullnet_grpc_lib::nullnet_grpc::{ AgentEvent, AgentProxyClientNotInet, AgentProxyRequestInvalidHost, - AgentProxyRequestMissingHost, AgentProxyRequestRouted, AgentUpstreamLookupFailed, ProxyRequest, - agent_event::Event as AgentEventKind, + AgentProxyRequestMissingHost, AgentProxyRequestRouted, AgentTlsCertificateInvalid, + AgentUpstreamLookupFailed, ProxyRequest, agent_event::Event as AgentEventKind, }; use nullnet_liberror::{ErrorHandler, Location, location}; use pingora_core::listeners::tls::TlsSettings; @@ -295,8 +295,19 @@ async fn watch_certificates(server: NullnetGrpcInterface, store: Arc loop { match stream.message().await { Ok(Some(bundle)) => { - let n = bundle.certificates.len(); - store.store(Arc::new(CertStore::from_bundle(&bundle))); + let (new_store, failures) = CertStore::from_bundle(&bundle); + let n = new_store.len(); + store.store(Arc::new(new_store)); + for (domain, reason) in failures { + eprintln!("Skipping TLS certificate for '{domain}': {reason}"); + let _ = server + .report_event(AgentEvent { + event: Some(AgentEventKind::TlsCertificateInvalid( + AgentTlsCertificateInvalid { domain, reason }, + )), + }) + .await; + } println!("Loaded {n} TLS certificate(s) from control service"); } Ok(None) => break, diff --git a/members/nullnet-proxy/src/tls.rs b/members/nullnet-proxy/src/tls.rs index 7d053f1..7076c1c 100644 --- a/members/nullnet-proxy/src/tls.rs +++ b/members/nullnet-proxy/src/tls.rs @@ -2,13 +2,16 @@ use arc_swap::ArcSwap; use async_trait::async_trait; use nullnet_grpc_lib::nullnet_grpc::CertBundle; use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use openssl::asn1::Asn1Time; +use openssl::nid::Nid; use openssl::ssl::NameType; use pingora_core::listeners::TlsAccept; use pingora_core::protocols::tls::TlsRef; use pingora_openssl::ext; use pingora_openssl::pkey::{PKey, Private}; use pingora_openssl::ssl::{SslContextBuilder, SslMethod}; -use pingora_openssl::x509::X509; +use pingora_openssl::x509::{X509, X509Ref}; +use std::cmp::Ordering; use std::collections::HashMap; use std::sync::Arc; @@ -20,22 +23,48 @@ pub struct Certificate { } impl Certificate { - fn new(cert_pem: &str, key_pem: &str) -> Result { - let mut certs = X509::stack_from_pem(cert_pem.as_bytes()).handle_err(location!())?; + /// Validate a cert/key pair for `domain`. Every failure is logged via + /// `handle_err`; the returned `Error`'s message doubles as the short reason + /// surfaced to the operator as an event (`Error::to_str`). + fn new(domain: &str, cert_pem: &str, key_pem: &str) -> Result { + let mut certs = X509::stack_from_pem(cert_pem.as_bytes()) + .map_err(|e| format!("invalid certificate PEM: {e}")) + .handle_err(location!())?; if certs.is_empty() { - Err::<(), _>("no certificate found in PEM").handle_err(location!())?; + return Err::("no certificate found in PEM").handle_err(location!()); } let leaf = certs.remove(0); let chain = certs; - let private_key = PKey::private_key_from_pem(key_pem.as_bytes()).handle_err(location!())?; + let private_key = PKey::private_key_from_pem(key_pem.as_bytes()) + .map_err(|e| format!("invalid private key PEM: {e}")) + .handle_err(location!())?; // ensure the private key actually matches the leaf certificate let mut builder = SslContextBuilder::new(SslMethod::tls()).handle_err(location!())?; builder.set_certificate(&leaf).handle_err(location!())?; builder .set_private_key(&private_key) + .map_err(|_| "private key does not match certificate") + .handle_err(location!())?; + builder + .check_private_key() + .map_err(|_| "private key does not match certificate") .handle_err(location!())?; - builder.check_private_key().handle_err(location!())?; + + // reject expired / not-yet-valid leaf certificates + let now = Asn1Time::days_from_now(0).handle_err(location!())?; + if leaf.not_after().compare(&now).handle_err(location!())? == Ordering::Less { + return Err::("certificate has expired").handle_err(location!()); + } + if leaf.not_before().compare(&now).handle_err(location!())? == Ordering::Greater { + return Err::("certificate is not yet valid").handle_err(location!()); + } + + // ensure the cert actually covers the domain it is filed under + if !cert_covers_domain(&leaf, domain) { + return Err::(format!("certificate does not cover domain '{domain}'")) + .handle_err(location!()); + } Ok(Self { leaf, @@ -45,6 +74,37 @@ impl Certificate { } } +/// Whether `leaf`'s SAN dNSNames (or CN, as fallback) cover `domain`, using +/// standard single-label wildcard rules. `domain` is the SNI key, exact +/// (`color.com`) or wildcard (`*.color.com`). +fn cert_covers_domain(leaf: &X509Ref, domain: &str) -> bool { + if let Some(sans) = leaf.subject_alt_names() + && sans + .iter() + .filter_map(|n| n.dnsname()) + .any(|dns| name_matches(dns, domain)) + { + return true; + } + leaf.subject_name() + .entries_by_nid(Nid::COMMONNAME) + .next() + .and_then(|e| e.data().as_utf8().ok()) + .is_some_and(|cn| name_matches(&cn, domain)) +} + +/// Match a cert name (`san`) against a target `domain`: exact (case-insensitive) +/// or a `*.`-prefixed wildcard covering exactly one label. +fn name_matches(san: &str, domain: &str) -> bool { + if san.eq_ignore_ascii_case(domain) { + return true; + } + if let (Some(suffix), Some((_, parent))) = (san.strip_prefix("*."), domain.split_once('.')) { + return parent.eq_ignore_ascii_case(suffix); + } + false +} + /// In-memory certificate store keyed by domain (SNI). Rebuilt wholesale from a /// `CertBundle` pushed by the control service and swapped in atomically. /// @@ -56,18 +116,25 @@ pub struct CertStore { impl CertStore { /// Build a store from a bundle received over gRPC, validating each - /// certificate/key pair and skipping any that fail. - pub fn from_bundle(bundle: &CertBundle) -> Self { + /// certificate/key pair. Returns the store plus the `(domain, reason)` of + /// every certificate that failed validation and was skipped. + pub fn from_bundle(bundle: &CertBundle) -> (Self, Vec<(String, String)>) { let mut certs = HashMap::new(); + let mut failures = Vec::new(); for c in &bundle.certificates { - match Certificate::new(&c.fullchain_pem, &c.key_pem) { + match Certificate::new(&c.domain, &c.fullchain_pem, &c.key_pem) { Ok(cert) => { certs.insert(c.domain.clone(), Arc::new(cert)); } - Err(_) => println!("Skipping '{}': invalid certificate or key", c.domain), + Err(e) => failures.push((c.domain.clone(), e.to_str().to_string())), } } - Self { certs } + (Self { certs }, failures) + } + + /// Number of valid certificates held. + pub fn len(&self) -> usize { + self.certs.len() } /// Resolve a cert for an SNI hostname: exact match first, then wildcard @@ -118,3 +185,169 @@ impl TlsAccept for TlsResolver { } } } + +#[cfg(test)] +mod tests { + use super::*; + use nullnet_grpc_lib::nullnet_grpc::TlsCertificate; + use openssl::hash::MessageDigest; + use openssl::rsa::Rsa; + use openssl::x509::extension::SubjectAlternativeName; + use openssl::x509::{X509Builder, X509NameBuilder}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + } + + /// Self-signed cert (PEM) + matching PKCS#8 key (PEM) for the given names + /// and validity window (unix seconds). + fn gen_cert(cn: &str, sans: &[&str], not_before: i64, not_after: i64) -> (String, String) { + let pkey = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + + let mut name = X509NameBuilder::new().unwrap(); + name.append_entry_by_nid(Nid::COMMONNAME, cn).unwrap(); + let name = name.build(); + + let mut b = X509Builder::new().unwrap(); + b.set_version(2).unwrap(); + b.set_subject_name(&name).unwrap(); + b.set_issuer_name(&name).unwrap(); + b.set_pubkey(&pkey).unwrap(); + b.set_not_before(&Asn1Time::from_unix(not_before).unwrap()) + .unwrap(); + b.set_not_after(&Asn1Time::from_unix(not_after).unwrap()) + .unwrap(); + if !sans.is_empty() { + let mut san = SubjectAlternativeName::new(); + for s in sans { + san.dns(s); + } + let ext = san.build(&b.x509v3_context(None, None)).unwrap(); + b.append_extension(ext).unwrap(); + } + b.sign(&pkey, MessageDigest::sha256()).unwrap(); + let cert = b.build(); + + ( + String::from_utf8(cert.to_pem().unwrap()).unwrap(), + String::from_utf8(pkey.private_key_to_pem_pkcs8().unwrap()).unwrap(), + ) + } + + fn valid(cn: &str, sans: &[&str]) -> (String, String) { + let now = now_unix(); + gen_cert(cn, sans, now - 3600, now + 3600) + } + + #[test] + fn name_matches_exact_and_case_insensitive() { + assert!(name_matches("color.com", "color.com")); + assert!(name_matches("Color.COM", "color.com")); + assert!(!name_matches("other.com", "color.com")); + } + + #[test] + fn name_matches_wildcard_single_label_only() { + assert!(name_matches("*.color.com", "app.color.com")); + assert!(name_matches("*.color.com", "*.color.com")); + // wildcard does not cover the apex... + assert!(!name_matches("*.color.com", "color.com")); + // ...nor more than one label + assert!(!name_matches("*.color.com", "a.b.color.com")); + } + + #[test] + fn accepts_valid_exact_cert() { + let (cert, key) = valid("color.com", &["color.com"]); + assert!(Certificate::new("color.com", &cert, &key).is_ok()); + } + + #[test] + fn wildcard_cert_covers_subdomain_but_not_apex() { + let (cert, key) = valid("*.color.com", &["*.color.com"]); + // filed for a subdomain -> covered by the wildcard SAN + assert!(Certificate::new("www.color.com", &cert, &key).is_ok()); + // filed for the apex -> wildcard does not cover it + let err = Certificate::new("color.com", &cert, &key).err().unwrap(); + assert!(err.to_str().contains("does not cover"), "{}", err.to_str()); + } + + #[test] + fn rejects_domain_mismatch() { + let (cert, key) = valid("color.com", &["color.com"]); + let err = Certificate::new("other.com", &cert, &key).err().unwrap(); + assert!(err.to_str().contains("does not cover"), "{}", err.to_str()); + } + + #[test] + fn rejects_expired_cert() { + let now = now_unix(); + let (cert, key) = gen_cert("color.com", &["color.com"], now - 7200, now - 3600); + let err = Certificate::new("color.com", &cert, &key).err().unwrap(); + assert!(err.to_str().contains("expired"), "{}", err.to_str()); + } + + #[test] + fn rejects_not_yet_valid_cert() { + let now = now_unix(); + let (cert, key) = gen_cert("color.com", &["color.com"], now + 3600, now + 7200); + let err = Certificate::new("color.com", &cert, &key).err().unwrap(); + assert!(err.to_str().contains("not yet valid"), "{}", err.to_str()); + } + + #[test] + fn rejects_key_mismatch() { + let (cert, _) = valid("color.com", &["color.com"]); + let (_, other_key) = valid("color.com", &["color.com"]); + let err = Certificate::new("color.com", &cert, &other_key) + .err() + .unwrap(); + assert!(err.to_str().contains("does not match"), "{}", err.to_str()); + } + + #[test] + fn from_bundle_splits_valid_and_invalid() { + let (ok_cert, ok_key) = valid("good.com", &["good.com"]); + let (bad_cert, bad_key) = valid("good.com", &["good.com"]); // SAN won't cover "bad.com" + let bundle = CertBundle { + certificates: vec![ + TlsCertificate { + domain: "good.com".to_string(), + fullchain_pem: ok_cert, + key_pem: ok_key, + }, + TlsCertificate { + domain: "bad.com".to_string(), + fullchain_pem: bad_cert, + key_pem: bad_key, + }, + ], + }; + let (store, failures) = CertStore::from_bundle(&bundle); + assert_eq!(store.len(), 1); + assert!(store.has_cert("good.com")); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].0, "bad.com"); + } + + #[test] + fn store_get_resolves_wildcard() { + let (cert, key) = valid("*.color.com", &["*.color.com"]); + let bundle = CertBundle { + certificates: vec![TlsCertificate { + domain: "*.color.com".to_string(), + fullchain_pem: cert, + key_pem: key, + }], + }; + let (store, failures) = CertStore::from_bundle(&bundle); + assert!(failures.is_empty()); + assert!(store.has_cert("app.color.com")); // wildcard parent match + assert!(!store.has_cert("color.com")); // apex not covered + assert!(!store.has_cert("a.b.color.com")); // multi-level not covered + } +} diff --git a/members/nullnet-server/src/events.rs b/members/nullnet-server/src/events.rs index 5ca7a8b..83bbbbd 100644 --- a/members/nullnet-server/src/events.rs +++ b/members/nullnet-server/src/events.rs @@ -240,6 +240,11 @@ pub(crate) enum Event { address_family: String, timestamp: u64, }, + TlsCertificateInvalid { + domain: String, + reason: String, + timestamp: u64, + }, // --- Proxy info events --- ProxyRequestRouted { @@ -294,6 +299,7 @@ impl Event { Self::ProxyRequestInvalidHost { .. } => "proxy_request_invalid_host", Self::UpstreamIpParseFailed { .. } => "upstream_ip_parse_failed", Self::ProxyClientNotInet { .. } => "proxy_client_not_inet", + Self::TlsCertificateInvalid { .. } => "tls_certificate_invalid", Self::ProxyRequestRouted { .. } => "proxy_request_routed", } } @@ -342,7 +348,8 @@ impl Event { | Self::ProxyRequestMissingHost { .. } | Self::ProxyRequestInvalidHost { .. } | Self::UpstreamIpParseFailed { .. } - | Self::ProxyClientNotInet { .. } => Severity::Error, + | Self::ProxyClientNotInet { .. } + | Self::TlsCertificateInvalid { .. } => Severity::Error, } } @@ -697,6 +704,14 @@ impl Event { } } + pub(crate) fn tls_certificate_invalid(domain: String, reason: String) -> Self { + Self::TlsCertificateInvalid { + domain, + reason, + timestamp: now_secs(), + } + } + pub(crate) fn proxy_request_routed( service_name: String, client_ip: String, diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 0cbb674..fc68a02 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -1094,6 +1094,9 @@ impl NullnetGrpc for NullnetGrpcImpl { Event::upstream_ip_parse_failed(e.raw_ip, e.service_name) } AgentEventKind::ProxyClientNotInet(e) => Event::proxy_client_not_inet(e.address_family), + AgentEventKind::TlsCertificateInvalid(e) => { + Event::tls_certificate_invalid(e.domain, e.reason) + } AgentEventKind::ProxyRequestRouted(e) => Event::proxy_request_routed( e.service_name, e.client_ip, From 5f2642f237740707cd4d84199c473c51c008c347 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Mon, 8 Jun 2026 16:59:51 +0200 Subject: [PATCH 05/13] encrypt cert keys at rest + cert ingest/renew UI/API --- Cargo.lock | 308 ++++++++++++++++++ members/nullnet-proxy/src/main.rs | 14 +- members/nullnet-server/.env | 3 +- members/nullnet-server/Cargo.toml | 3 + members/nullnet-server/src/certs.rs | 55 +++- members/nullnet-server/src/crypto.rs | 118 +++++++ .../src/http_server/certificates.rs | 185 +++++++++++ members/nullnet-server/src/http_server/mod.rs | 9 + members/nullnet-server/src/main.rs | 4 + members/nullnet-server/ui/src/App.tsx | 2 + .../ui/src/components/Layout.tsx | 3 +- .../ui/src/pages/Certificates.tsx | 175 ++++++++++ .../nullnet-server/ui/src/pages/Events.tsx | 2 + members/nullnet-server/ui/src/types.ts | 6 + 14 files changed, 865 insertions(+), 22 deletions(-) create mode 100644 members/nullnet-server/src/crypto.rs create mode 100644 members/nullnet-server/src/http_server/certificates.rs create mode 100644 members/nullnet-server/ui/src/pages/Certificates.tsx diff --git a/Cargo.lock b/Cargo.lock index 99f4f88..72a6e9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -140,6 +175,45 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "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.117", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -452,6 +526,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -581,9 +665,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -648,6 +742,35 @@ dependencies = [ "syn 2.0.117", ] +[[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 = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +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 = "derivative" version = "2.2.0" @@ -713,6 +836,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 2.0.117", +] + [[package]] name = "either" version = "1.15.0" @@ -1072,6 +1206,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1342,6 +1486,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1567,6 +1720,12 @@ 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.8.9" @@ -1764,6 +1923,16 @@ dependencies = [ "memoffset 0.9.1", ] +[[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" @@ -1865,8 +2034,10 @@ dependencies = [ name = "nullnet-server" version = "0.1.0" dependencies = [ + "aes-gcm", "async-trait", "axum", + "base64", "chrono", "ctrlc", "futures", @@ -1886,6 +2057,32 @@ dependencies = [ "tonic", "tonic-prost", "uuid", + "x509-parser", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", ] [[package]] @@ -1921,6 +2118,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1933,6 +2139,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.80" @@ -2307,6 +2519,24 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2687,6 +2917,15 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[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" @@ -3036,6 +3275,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -3118,6 +3368,37 @@ dependencies = [ "trackable", ] +[[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 = "tokio" version = "1.51.1" @@ -3449,6 +3730,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4117,6 +4408,23 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xtask" version = "0.1.0" diff --git a/members/nullnet-proxy/src/main.rs b/members/nullnet-proxy/src/main.rs index 4afae72..9307ea7 100644 --- a/members/nullnet-proxy/src/main.rs +++ b/members/nullnet-proxy/src/main.rs @@ -297,7 +297,18 @@ async fn watch_certificates(server: NullnetGrpcInterface, store: Arc { let (new_store, failures) = CertStore::from_bundle(&bundle); let n = new_store.len(); - store.store(Arc::new(new_store)); + // Guard: never let a transient empty (or all-invalid) bundle + // wipe live certs and take every HTTPS host dark. Keep the + // last-known-good set; clearing all certs needs a restart. + let live = store.load().len(); + if n == 0 && live > 0 { + eprintln!( + "Ignoring empty certificate set from control service; keeping {live} live cert(s)" + ); + } else { + store.store(Arc::new(new_store)); + println!("Loaded {n} TLS certificate(s) from control service"); + } for (domain, reason) in failures { eprintln!("Skipping TLS certificate for '{domain}': {reason}"); let _ = server @@ -308,7 +319,6 @@ async fn watch_certificates(server: NullnetGrpcInterface, store: Arc break, Err(e) => { diff --git a/members/nullnet-server/.env b/members/nullnet-server/.env index 9747221..2e750e8 100644 --- a/members/nullnet-server/.env +++ b/members/nullnet-server/.env @@ -1,2 +1,3 @@ NET_TYPE=VXLAN -TIMEOUT=0 \ No newline at end of file +TIMEOUT=0 +CERT_ENCRYPTION_KEY=3bf1a1f639274d6ca683b311ef137a49a056ce937c8cfc7faac2a7983c42a71b \ No newline at end of file diff --git a/members/nullnet-server/Cargo.toml b/members/nullnet-server/Cargo.toml index 61b7409..6f51445 100644 --- a/members/nullnet-server/Cargo.toml +++ b/members/nullnet-server/Cargo.toml @@ -27,6 +27,9 @@ chrono.workspace = true axum = "0.8" rust-embed = { version = "8", features = ["axum"] } mime_guess = "2" +aes-gcm = "0.10" +base64 = "0.22" +x509-parser = "0.16" [build-dependencies] diff --git a/members/nullnet-server/src/certs.rs b/members/nullnet-server/src/certs.rs index 3e334ce..4c869bb 100644 --- a/members/nullnet-server/src/certs.rs +++ b/members/nullnet-server/src/certs.rs @@ -1,25 +1,26 @@ +use crate::crypto; use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use nullnet_grpc_lib::nullnet_grpc::{CertBundle, TlsCertificate}; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::ops::Sub; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::sync::mpsc as tokio_mpsc; use tokio::sync::watch; use tokio::time::Instant; -const CERTS_DIR: &str = "./certs"; - -// TODO(cert-ingest): certs currently arrive as files dropped in ./certs. Plan: -// a UI/API that encrypts keys on ingest (see encryption-at-rest TODO) and writes -// ciphertext here. That API must be HTTPS, require admin auth, and never return -// private keys on read. +pub(crate) const CERTS_DIR: &str = "./certs"; +/// Encrypted private key (AES-256-GCM, at rest). Preferred. +pub(crate) const KEY_ENCRYPTED: &str = "privkey.enc"; +/// Legacy/plaintext private key (manual file-drop); migrated to encrypted on load. +pub(crate) const KEY_PLAINTEXT: &str = "privkey.pem"; /// Read every certificate from disk into a `CertBundle`. /// -/// Layout: `./certs//fullchain.pem` + `./certs//privkey.pem`, -/// where `` is the SNI key (exact `color.com` or wildcard `*.color.com`). -/// Unreadable/incomplete entries are skipped; the proxy validates the PEMs. +/// Layout: `./certs//fullchain.pem` + an encrypted `privkey.enc` (or a +/// legacy plaintext `privkey.pem`, migrated on first read). `` is the SNI +/// key (exact `color.com` or wildcard `*.color.com`). The bundle carries the +/// decrypted key; the proxy validates the PEMs. pub(crate) async fn load_certificates() -> CertBundle { let _ = tokio::fs::create_dir_all(CERTS_DIR).await; @@ -40,14 +41,10 @@ pub(crate) async fn load_certificates() -> CertBundle { else { continue; }; - // TODO(encryption-at-rest): keys are read in plaintext. Plan (routix - // style): AES-256-GCM with a master key from CERT_ENCRYPTION_KEY, - // encrypt on ingest, store ciphertext on disk, decrypt here before - // bundling. Pairs with the gRPC-TLS TODO since the bundle carries keys. - let (Ok(fullchain_pem), Ok(key_pem)) = ( - tokio::fs::read_to_string(path.join("fullchain.pem")).await, - tokio::fs::read_to_string(path.join("privkey.pem")).await, - ) else { + let Ok(fullchain_pem) = tokio::fs::read_to_string(path.join("fullchain.pem")).await else { + continue; + }; + let Some(key_pem) = load_or_migrate_key(&path).await else { continue; }; println!("Loaded TLS certificate for '{domain}'"); @@ -61,6 +58,28 @@ pub(crate) async fn load_certificates() -> CertBundle { CertBundle { certificates } } +/// Return the decrypted private key for a cert dir. Prefers the encrypted +/// `privkey.enc`; if only a legacy plaintext `privkey.pem` exists, encrypt it in +/// place (write `privkey.enc`, remove the plaintext) before returning it. +async fn load_or_migrate_key(dir: &Path) -> Option { + let enc_path = dir.join(KEY_ENCRYPTED); + if let Ok(encoded) = tokio::fs::read_to_string(&enc_path).await { + return crypto::cipher().decrypt(&encoded).ok(); + } + + // legacy / freshly dropped plaintext key: migrate to encrypted at rest + let key_pem = tokio::fs::read_to_string(dir.join(KEY_PLAINTEXT)) + .await + .ok()?; + if let Ok(encoded) = crypto::cipher().encrypt(&key_pem) + && tokio::fs::write(&enc_path, encoded).await.is_ok() + { + let _ = tokio::fs::remove_file(dir.join(KEY_PLAINTEXT)).await; + println!("Encrypted private key at rest for '{}'", dir.display()); + } + Some(key_pem) +} + /// Watch `./certs` and push a fresh `CertBundle` through `certs_tx` on every /// change, so subscribed proxies hot-reload. Mirrors the services watcher. pub(crate) async fn watch(certs_tx: watch::Sender) -> Result<(), Error> { diff --git a/members/nullnet-server/src/crypto.rs b/members/nullnet-server/src/crypto.rs new file mode 100644 index 0000000..84afc02 --- /dev/null +++ b/members/nullnet-server/src/crypto.rs @@ -0,0 +1,118 @@ +use aes_gcm::aead::rand_core::RngCore; +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use nullnet_liberror::{Error, ErrorHandler, Location, location}; +use std::sync::OnceLock; + +/// Process-wide cipher used to encrypt certificate private keys at rest. +static CIPHER: OnceLock = OnceLock::new(); + +/// AES-256-GCM encryptor. On-disk format is `base64(nonce[12] || ciphertext)`. +pub(crate) struct Encryptor { + cipher: Aes256Gcm, +} + +impl Encryptor { + fn new(key: &[u8; 32]) -> Self { + Self { + cipher: Aes256Gcm::new(Key::::from_slice(key)), + } + } + + pub(crate) fn encrypt(&self, plaintext: &str) -> Result { + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let mut combined = nonce_bytes.to_vec(); + combined.extend( + self.cipher + .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_bytes()) + .handle_err(location!())?, + ); + Ok(STANDARD.encode(combined)) + } + + pub(crate) fn decrypt(&self, encoded: &str) -> Result { + let combined = STANDARD.decode(encoded.trim()).handle_err(location!())?; + if combined.len() < 12 { + return Err::("ciphertext too short to contain nonce") + .handle_err(location!()); + } + let (nonce_bytes, ciphertext) = combined.split_at(12); + let plaintext = self + .cipher + .decrypt(Nonce::from_slice(nonce_bytes), ciphertext) + .handle_err(location!())?; + String::from_utf8(plaintext).handle_err(location!()) + } +} + +/// Initialize the global cipher from `CERT_ENCRYPTION_KEY` (32 raw bytes or 64 +/// hex chars). Call once at startup; fails fast if the key is missing/invalid. +pub(crate) fn init_from_env() -> Result<(), Error> { + let raw = std::env::var("CERT_ENCRYPTION_KEY") + .map_err(|_| "CERT_ENCRYPTION_KEY not set") + .handle_err(location!())?; + + let key: [u8; 32] = if raw.len() == 64 { + decode_hex(&raw)? + .try_into() + .map_err(|_| "CERT_ENCRYPTION_KEY: hex must decode to exactly 32 bytes") + .handle_err(location!())? + } else if raw.len() == 32 { + raw.as_bytes() + .try_into() + .map_err(|_| "CERT_ENCRYPTION_KEY: failed to read as 32-byte key") + .handle_err(location!())? + } else { + return Err::<(), _>(format!( + "CERT_ENCRYPTION_KEY: expected 32 raw chars or 64 hex chars, got {}", + raw.len() + )) + .handle_err(location!()); + }; + + let _ = CIPHER.set(Encryptor::new(&key)); + Ok(()) +} + +/// The global cipher. Panics if [`init_from_env`] was not called first. +pub(crate) fn cipher() -> &'static Encryptor { + CIPHER.get().expect("cert cipher not initialized") +} + +fn decode_hex(s: &str) -> Result, Error> { + if !s.len().is_multiple_of(2) { + return Err::, _>("hex string must have even length").handle_err(location!()); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).handle_err(location!())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::Encryptor; + + #[test] + fn round_trip() { + let enc = Encryptor::new(&[7u8; 32]); + let pem = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----"; + let ct = enc.encrypt(pem).unwrap(); + assert_ne!(ct, pem); + assert_eq!(enc.decrypt(&ct).unwrap(), pem); + } + + #[test] + fn nonce_randomizes_ciphertext() { + let enc = Encryptor::new(&[7u8; 32]); + assert_ne!(enc.encrypt("same").unwrap(), enc.encrypt("same").unwrap()); + } + + #[test] + fn wrong_key_fails() { + let ct = Encryptor::new(&[1u8; 32]).encrypt("secret").unwrap(); + assert!(Encryptor::new(&[2u8; 32]).decrypt(&ct).is_err()); + } +} diff --git a/members/nullnet-server/src/http_server/certificates.rs b/members/nullnet-server/src/http_server/certificates.rs new file mode 100644 index 0000000..8636b01 --- /dev/null +++ b/members/nullnet-server/src/http_server/certificates.rs @@ -0,0 +1,185 @@ +use crate::certs::{CERTS_DIR, KEY_ENCRYPTED, KEY_PLAINTEXT}; +use crate::crypto; +use axum::extract::Path as AxumPath; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Serialize)] +struct CertJson { + domain: String, + /// Leaf `notAfter` as unix seconds (best-effort; `None` if unparseable). + expires_at: Option, +} + +#[derive(Serialize)] +struct ErrorJson { + error: String, +} + +#[derive(Deserialize)] +pub(super) struct UploadReq { + domain: String, + fullchain_pem: String, + key_pem: String, +} + +fn bad_request(error: &str) -> axum::response::Response { + ( + StatusCode::BAD_REQUEST, + axum::Json(ErrorJson { + error: error.to_string(), + }), + ) + .into_response() +} + +/// List installed certs (domain + best-effort expiry). Never returns keys. +pub(super) async fn list_handler() -> impl IntoResponse { + let mut certs: Vec = Vec::new(); + let Ok(mut entries) = tokio::fs::read_dir(CERTS_DIR).await else { + return axum::Json(certs); + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(domain) = path + .file_name() + .and_then(|s| s.to_str()) + .map(str::to_string) + else { + continue; + }; + let expires_at = read_expiry(&path.join("fullchain.pem")).await; + certs.push(CertJson { domain, expires_at }); + } + certs.sort_by(|a, b| a.domain.cmp(&b.domain)); + axum::Json(certs) +} + +/// Ingest or replace (renew) a cert: writes the cert plaintext + the key +/// encrypted at rest. The certs watcher then pushes it to the proxies, which +/// validate it and report any problem back as an event. +pub(super) async fn upload_handler(axum::Json(req): axum::Json) -> impl IntoResponse { + let Some(domain) = sanitize_domain(&req.domain) else { + return bad_request("invalid domain"); + }; + if !req.fullchain_pem.contains("BEGIN CERTIFICATE") { + return bad_request("fullchain_pem is not a PEM certificate"); + } + if !req.key_pem.contains("PRIVATE KEY") { + return bad_request("key_pem is not a PEM private key"); + } + let Ok(encoded) = crypto::cipher().encrypt(&req.key_pem) else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(ErrorJson { + error: "failed to encrypt private key".to_string(), + }), + ) + .into_response(); + }; + + let dir = PathBuf::from(CERTS_DIR).join(&domain); + if tokio::fs::create_dir_all(&dir).await.is_err() + || tokio::fs::write(dir.join("fullchain.pem"), &req.fullchain_pem) + .await + .is_err() + || tokio::fs::write(dir.join(KEY_ENCRYPTED), &encoded) + .await + .is_err() + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(ErrorJson { + error: "failed to write certificate".to_string(), + }), + ) + .into_response(); + } + // drop any stale plaintext key from a previous file-drop + let _ = tokio::fs::remove_file(dir.join(KEY_PLAINTEXT)).await; + StatusCode::NO_CONTENT.into_response() +} + +/// Remove a cert. Note: clearing the *last* cert won't fully propagate until the +/// proxies restart (they keep the last-known-good set to avoid going dark). +pub(super) async fn delete_handler(AxumPath(domain): AxumPath) -> impl IntoResponse { + let Some(domain) = sanitize_domain(&domain) else { + return StatusCode::BAD_REQUEST; + }; + let dir = PathBuf::from(CERTS_DIR).join(&domain); + match tokio::fs::remove_dir_all(&dir).await { + Ok(()) => StatusCode::NO_CONTENT, + Err(_) => StatusCode::NOT_FOUND, + } +} + +async fn read_expiry(fullchain: &Path) -> Option { + let bytes = tokio::fs::read(fullchain).await.ok()?; + let (_, pem) = x509_parser::pem::parse_x509_pem(&bytes).ok()?; + let cert = pem.parse_x509().ok()?; + Some(cert.validity().not_after.timestamp()) +} + +/// Validate a domain as a safe directory name: exact (`color.com`) or single-level +/// wildcard (`*.color.com`). Rejects path separators, `..`, and stray characters, +/// so it is safe to join onto `CERTS_DIR`. +fn sanitize_domain(input: &str) -> Option { + let d = input.trim(); + if d.is_empty() || d.len() > 253 { + return None; + } + let labels = d.strip_prefix("*.").unwrap_or(d); + if labels.is_empty() { + return None; + } + let ok = labels.split('.').all(|l| { + !l.is_empty() + && l.len() <= 63 + && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + && !l.starts_with('-') + && !l.ends_with('-') + }); + ok.then(|| d.to_string()) +} + +#[cfg(test)] +mod tests { + use super::sanitize_domain; + + #[test] + fn accepts_exact_and_wildcard() { + assert_eq!(sanitize_domain("color.com").as_deref(), Some("color.com")); + assert_eq!( + sanitize_domain("*.color.com").as_deref(), + Some("*.color.com") + ); + assert_eq!( + sanitize_domain(" a-b.example.io ").as_deref(), + Some("a-b.example.io") + ); + } + + #[test] + fn rejects_traversal_and_junk() { + for bad in [ + "", + "..", + "../etc", + "a/b", + "a/../b", + "color..com", + "*.*.com", + "-bad.com", + "bad-.com", + "a b.com", + "color.com/", + ] { + assert!(sanitize_domain(bad).is_none(), "should reject {bad:?}"); + } + } +} diff --git a/members/nullnet-server/src/http_server/mod.rs b/members/nullnet-server/src/http_server/mod.rs index 0f02dd4..48e7d93 100644 --- a/members/nullnet-server/src/http_server/mod.rs +++ b/members/nullnet-server/src/http_server/mod.rs @@ -7,6 +7,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use tokio::sync::RwLock; +mod certificates; mod config; mod events; mod events_stream; @@ -37,6 +38,14 @@ pub async fn serve(state: AppState) { .route("/api/graph/{stack}", get(graph::graph_handler)) .route("/api/sessions", get(sessions::list_handler)) .route("/api/sessions/{id}", delete(sessions::teardown_handler)) + .route( + "/api/certificates", + get(certificates::list_handler).post(certificates::upload_handler), + ) + .route( + "/api/certificates/{domain}", + delete(certificates::delete_handler), + ) .route("/api/events", get(events::events_handler)) .route( "/api/events/stream", diff --git a/members/nullnet-server/src/main.rs b/members/nullnet-server/src/main.rs index 6626a2c..bd30f4b 100644 --- a/members/nullnet-server/src/main.rs +++ b/members/nullnet-server/src/main.rs @@ -1,4 +1,5 @@ mod certs; +mod crypto; mod env; mod events; mod graphviz; @@ -34,6 +35,9 @@ async fn main() -> Result<(), Error> { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), PORT); + // cert private keys are encrypted at rest with this key; fail fast if absent + crypto::init_from_env()?; + // TODO(grpc-tls): the gRPC server is plaintext, but WatchCertificates // streams private keys. Enable TLS here (Server::builder().tls_config(..)), // ideally mTLS, so keys never travel in clear; until then keep the control diff --git a/members/nullnet-server/ui/src/App.tsx b/members/nullnet-server/ui/src/App.tsx index 1a24fc8..1bd4049 100644 --- a/members/nullnet-server/ui/src/App.tsx +++ b/members/nullnet-server/ui/src/App.tsx @@ -7,6 +7,7 @@ import Sessions from './pages/Sessions'; import Pool from './pages/Pool'; import Config from './pages/Config'; import Events from './pages/Events'; +import Certificates from './pages/Certificates'; export default function App() { return ( @@ -19,6 +20,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/members/nullnet-server/ui/src/components/Layout.tsx b/members/nullnet-server/ui/src/components/Layout.tsx index 24d2365..71d3aba 100644 --- a/members/nullnet-server/ui/src/components/Layout.tsx +++ b/members/nullnet-server/ui/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { useStack } from '../StackContext'; import { useApi } from '../hooks/useApi'; import type { SessionJson } from '../types'; -type Page = 'dashboard' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config' | 'events'; +type Page = 'dashboard' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config' | 'certificates' | 'events'; interface Props { page: Page; @@ -32,6 +32,7 @@ const NAV = [ group: 'Ops', items: [ { id: 'events', icon: '≡', label: 'Events', to: '/events' }, + { id: 'certificates', icon: '⛨', label: 'Certificates', to: '/certificates' }, { id: 'config', icon: '⚙', label: 'Config', to: '/config' }, ], }, diff --git a/members/nullnet-server/ui/src/pages/Certificates.tsx b/members/nullnet-server/ui/src/pages/Certificates.tsx new file mode 100644 index 0000000..c589097 --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Certificates.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import Layout from '../components/Layout'; +import { useApi } from '../hooks/useApi'; +import type { CertJson } from '../types'; + +function formatExpiry(unix: number | null): { text: string; color: string } { + if (unix === null) return { text: 'unknown', color: 'var(--t2)' }; + const ms = unix * 1000; + const days = Math.floor((ms - Date.now()) / 86_400_000); + const date = new Date(ms).toLocaleDateString(); + if (days < 0) return { text: `expired (${date})`, color: 'var(--red, #e5484d)' }; + if (days < 30) return { text: `${days}d · ${date}`, color: 'var(--amber)' }; + return { text: `${days}d · ${date}`, color: 'var(--t1)' }; +} + +export default function Certificates() { + const { data: certs, loading, refetch } = useApi('/api/certificates', 10000); + const [deleting, setDeleting] = useState>(new Set()); + + const [domain, setDomain] = useState(''); + const [fullchain, setFullchain] = useState(''); + const [key, setKey] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + const list = certs ?? []; + const existing = new Set(list.map(c => c.domain)); + const isRenew = existing.has(domain.trim()); + + async function readInto(file: File | undefined, set: (v: string) => void) { + if (file) set(await file.text()); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setFormError(null); + setSubmitting(true); + try { + const res = await fetch('/api/certificates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domain: domain.trim(), fullchain_pem: fullchain, key_pem: key }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + throw new Error(body.error ?? `HTTP ${res.status}`); + } + setDomain(''); setFullchain(''); setKey(''); + refetch(); + } catch (err) { + setFormError(String(err instanceof Error ? err.message : err)); + } finally { + setSubmitting(false); + } + } + + async function remove(d: string) { + if (!confirm(`Delete certificate for ${d}? Clearing the last cert needs a proxy restart to fully take effect.`)) return; + setDeleting(prev => new Set(prev).add(d)); + try { + await fetch(`/api/certificates/${encodeURIComponent(d)}`, { method: 'DELETE' }); + refetch(); + } finally { + setDeleting(prev => { const next = new Set(prev); next.delete(d); return next; }); + } + } + + const canSubmit = domain.trim() !== '' && fullchain.includes('BEGIN CERTIFICATE') && key.includes('PRIVATE KEY') && !submitting; + + return ( + +
+
+ {list.length} + installed TLS certificates +
+ +
+
+ Certificates + private keys encrypted at rest +
+ + + + + + + + + + {loading && ( + + )} + {list.map(c => { + const exp = formatExpiry(c.expires_at); + return ( + + + + + + ); + })} + {!loading && list.length === 0 && ( + + )} + +
DomainExpires
Loading…
{c.domain}{exp.text} + +
No certificates installed
+
+ +
+
+ {isRenew ? 'Renew / replace certificate' : 'Add certificate'} + {isRenew && overwrites existing «{domain.trim()}»} +
+
+ + +